From 1c6a06d4119074f8564e2a176ea485b189075d00 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 25 May 2026 18:33:01 +0530 Subject: [PATCH 001/250] first commit --- README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000000..4a5ad31b03 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# agent-orchestrator From d8dc430b8fa6833cd1ea42c5acc8ec486d3407bf Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 25 May 2026 18:34:09 +0530 Subject: [PATCH 002/250] first commit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4a5ad31b03..dd361a05e0 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ # agent-orchestrator +# agent-orchestrator From a1fb47000527e50a86f0356cab3a14262136e029 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 26 May 2026 15:22:36 +0530 Subject: [PATCH 003/250] chore: scaffold backend/ and frontend/ skeletons for rewrite Initial buildable skeleton for the agent-orchestrator rewrite, splitting the repo into a Go backend daemon and an Electron + TypeScript frontend. - backend/: go.mod (Go 1.22) + main.go that compiles and prints a startup line - frontend/: package.json, strict tsconfig.json, Electron main-process stub - .gitignore for Node/Electron/Go/OS/editor/env artifacts - README note describing the new two-folder structure No app logic or architecture layering yet (routes/controllers/services/etc. come in a later task). go build and tsc --noEmit both pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 32 ++ README.md | 4 +- backend/go.mod | 3 + backend/main.go | 7 + frontend/package-lock.json | 886 +++++++++++++++++++++++++++++++++++++ frontend/package.json | 16 + frontend/src/main.ts | 30 ++ frontend/tsconfig.json | 21 + 8 files changed, 998 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 backend/go.mod create mode 100644 backend/main.go create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/main.ts create mode 100644 frontend/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..291f2c955a --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Node / Electron +node_modules/ +dist/ +out/ +build/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Go +bin/ +*.test +*.out +vendor/ +# compiled daemon binary +/backend/backend + +# Environment +.env +.env.* +!.env.example + +# Editor / IDE +.vscode/ +.idea/ +*.swp +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index dd361a05e0..0f28a2e3ec 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # agent-orchestrator -# agent-orchestrator + +Rewrite of the agent-orchestrator: a long-running Go backend daemon (`backend/`) +paired with an Electron + TypeScript frontend (`frontend/`). diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000000..22a555cda5 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,3 @@ +module github.com/aoagents/agent-orchestrator/backend + +go 1.22 diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000000..30a6e84c6a --- /dev/null +++ b/backend/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("ao backend daemon starting") +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000..7eeda0af87 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,886 @@ +{ + "name": "agent-orchestrator-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agent-orchestrator-frontend", + "version": "0.0.0", + "devDependencies": { + "electron": "^33.0.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "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.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "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.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "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.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/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.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "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.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "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.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "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.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "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.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "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.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "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.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/electron": { + "version": "33.4.11", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz", + "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "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.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "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.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "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.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "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.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "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.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "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.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "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.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "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.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "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.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "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.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "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.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "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.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "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.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "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.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "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.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000..b58407e875 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,16 @@ +{ + "name": "agent-orchestrator-frontend", + "version": "0.0.0", + "private": true, + "description": "Electron + TypeScript frontend for the agent-orchestrator rewrite", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "start": "npm run build && electron ." + }, + "devDependencies": { + "electron": "^33.0.0", + "typescript": "^5.6.0" + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000000..e40b704c05 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,30 @@ +import { app, BrowserWindow } from "electron"; + +function createWindow(): void { + const window = new BrowserWindow({ + width: 1200, + height: 800, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + }); + + void window.loadURL("about:blank"); +} + +app.whenReady().then(() => { + createWindow(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000..35c1777f6b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2022", "DOM"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} From d630319f0411a635747b9d70a3e85108f73eb8f7 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 26 May 2026 20:00:31 +0530 Subject: [PATCH 004/250] feat(backend): LCM + Session Manager contract package (domain + ports) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract-first boundary for the Lifecycle Manager + Session Manager lane. Pure shapes only — types, interfaces, and the display-status derivation — so adil (SCM poller), Tom (persistence), and aditi (API) can review and build against a stable boundary before any behaviour lands. domain/ - CanonicalSessionLifecycle: the only persisted state (session/pr/runtime sub-states), with Activity + Detecting sub-states added as decider inputs that must survive between observations. - DeriveLegacyStatus: the sole producer of the derived display status (never persisted), with 11 table tests. ports/ - inbound: LifecycleManager (Apply* pipeline, per-session serialised) and SessionManager. - outbound: LifecycleStore (Tom), Notifier, AgentMessenger, and the Runtime/Agent/Workspace plugin ports (co-owned with the agents lane). - facts: SCMFacts / RuntimeFacts / ActivitySignal DTOs. decide/ pure-core signatures + I/O types; bodies stubbed for the next PR. Folds in four design-review fixes (documented in-code, pending team confirm): 1. Activity + Detecting persisted so the pure decider has memory across calls. 2. Per-session serialisation documented; LifecyclePatch.ExpectedVersion offers optimistic-locking as an alternative. 3. LifecyclePatch is a sparse pointer-field merge-patch (+ ClearDetecting). 4. SCMFacts gains Fetched (failed fetch != "PR closed") and per-comment IsBot (bot vs human route to different reactions). go build / go vet / go test all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/domain/decide/decide.go | 108 +++++++++++++ backend/internal/domain/lifecycle.go | 186 +++++++++++++++++++++++ backend/internal/domain/session.go | 33 ++++ backend/internal/domain/status.go | 95 ++++++++++++ backend/internal/domain/status_test.go | 87 +++++++++++ backend/internal/ports/facts.go | 143 +++++++++++++++++ backend/internal/ports/inbound.go | 68 +++++++++ backend/internal/ports/outbound.go | 123 +++++++++++++++ 8 files changed, 843 insertions(+) create mode 100644 backend/internal/domain/decide/decide.go create mode 100644 backend/internal/domain/lifecycle.go create mode 100644 backend/internal/domain/session.go create mode 100644 backend/internal/domain/status.go create mode 100644 backend/internal/domain/status_test.go create mode 100644 backend/internal/ports/facts.go create mode 100644 backend/internal/ports/inbound.go create mode 100644 backend/internal/ports/outbound.go diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go new file mode 100644 index 0000000000..e92ed694ee --- /dev/null +++ b/backend/internal/domain/decide/decide.go @@ -0,0 +1,108 @@ +// Package decide is the pure DECIDE core: total, deterministic, zero I/O. It +// collapses observed facts (plus the prior detecting/activity memory) into one +// LifecycleDecision. Every function here must remain side-effect free so the +// whole status truth-table can be tested in isolation. +// +// NOTE: function bodies are stubbed in this contracts PR. The real logic + the +// exhaustive truth-table tests land in the follow-up "decide core" PR. The +// signatures and the input/output shapes are what we are stabilising now. +package decide + +import ( + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// Anti-flap tuning. detecting escalates to stuck only after this many +// consecutive unchanged-evidence ticks OR once this much wallclock has elapsed +// since first entering detecting. +const ( + DetectingMaxAttempts = 3 + DetectingMaxDuration = 5 * time.Minute +) + +// LifecycleDecision is the output of every decider: the derived display status +// plus the canonical sub-state values to persist, the human-readable evidence, +// and the (possibly updated) detecting memory. +type LifecycleDecision struct { + Status domain.SessionStatus + Evidence string + Detecting *domain.DetectingState + SessionState domain.SessionState + SessionReason domain.SessionReason + PRState domain.PRState + PRReason domain.PRReason +} + +// ProbeInput reconciles runtime + process liveness. A *failed* probe (timeout +// or error) is distinct from a "dead" verdict and must route to detecting, +// never to a death conclusion. KillRequested short-circuits to terminal. +type ProbeInput struct { + Runtime domain.RuntimeState + RuntimeFailed bool + Process ProcessLiveness + ProcessFailed bool + RecentActivity bool + KillRequested bool + Prior *domain.DetectingState + Now time.Time +} + +// ProcessLiveness mirrors isProcessRunning's three-valued answer. +type ProcessLiveness string + +const ( + ProcessAlive ProcessLiveness = "alive" + ProcessDead ProcessLiveness = "dead" + ProcessIndeterminate ProcessLiveness = "indeterminate" +) + +// OpenPRInput drives the PR pipeline ladder for an open PR. +type OpenPRInput struct { + CIFailing bool + ChangesRequested bool + Approved bool + Mergeable bool + ReviewPending bool + IdleBeyond bool // idle past the stuck threshold + Number int + URL string +} + +// DetectingInput feeds the quarantine counter. Evidence is hashed with +// timestamps stripped, so "same ambiguous signal" keeps the counter climbing +// while any real change resets it. +type DetectingInput struct { + Evidence string + ProposedState domain.SessionState + ProposedReason domain.SessionReason + Prior *domain.DetectingState + Now time.Time +} + +// ResolveProbeDecision reconciles runtime/process liveness into a decision. +func ResolveProbeDecision(in ProbeInput) LifecycleDecision { + panic("decide.ResolveProbeDecision: not implemented (decide-core PR)") +} + +// ResolveOpenPRDecision walks the PR pipeline ladder. +func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { + panic("decide.ResolveOpenPRDecision: not implemented (decide-core PR)") +} + +// ResolveTerminalPRStateDecision handles merged/closed PRs. +func ResolveTerminalPRStateDecision(pr domain.PRState) LifecycleDecision { + panic("decide.ResolveTerminalPRStateDecision: not implemented (decide-core PR)") +} + +// CreateDetectingDecision advances or escalates the anti-flap quarantine. +func CreateDetectingDecision(in DetectingInput) LifecycleDecision { + panic("decide.CreateDetectingDecision: not implemented (decide-core PR)") +} + +// HashEvidence normalises an evidence string (stripping timestamps) and hashes +// it, so unchanged-but-restamped signals compare equal. +func HashEvidence(evidence string) string { + panic("decide.HashEvidence: not implemented (decide-core PR)") +} diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go new file mode 100644 index 0000000000..1727fad718 --- /dev/null +++ b/backend/internal/domain/lifecycle.go @@ -0,0 +1,186 @@ +// Package domain holds the shared contract types for the LCM + Session Manager +// lane: the canonical session state model, the derived display status, and the +// session read-model. It has no behaviour beyond pure derivation (status.go) +// and imports nothing outside the standard library, so every other package can +// depend on it without creating cycles. +package domain + +import "time" + +// LifecycleVersion is the schema version stamped onto every persisted record. +// Greenfield: we start at 1 and carry no migration/synthesis code. +const LifecycleVersion = 1 + +// CanonicalSessionLifecycle is the ONLY thing persisted for a session's state. +// The display status is derived from it on read (see DeriveLegacyStatus) and is +// never stored — this prevents canonical truth and display from drifting. +// +// Three orthogonal (state, reason) sub-states describe the session, its PR, and +// its runtime. Activity and Detecting are decider *inputs* that must survive +// between observations (they are read back by the pure decide core), so they +// live in the persisted record too. +type CanonicalSessionLifecycle struct { + Version int `json:"version"` + Session SessionSubstate `json:"session"` + PR PRSubstate `json:"pr"` + Runtime RuntimeSubstate `json:"runtime"` + + // Activity is the last-known agent activity. It arrives on a different + // cadence (ApplyActivitySignal) than runtime probes (the reaper), so the + // probe decider reads it from here to answer "was there recent activity?". + Activity ActivitySubstate `json:"activity"` + + // Detecting is the anti-flap quarantine memory. It is non-nil only while + // the session is in the detecting state; it carries the attempt counter, + // the first-entry time, and a hash of the (timestamp-stripped) evidence so + // the decider can tell "same ambiguous signal N times" from "signal moved". + Detecting *DetectingState `json:"detecting,omitempty"` +} + +// ---- session sub-state ---- + +type SessionState string + +const ( + SessionNotStarted SessionState = "not_started" + SessionWorking SessionState = "working" + SessionIdle SessionState = "idle" + SessionNeedsInput SessionState = "needs_input" + SessionStuck SessionState = "stuck" + SessionDetecting SessionState = "detecting" + SessionDone SessionState = "done" + SessionTerminated SessionState = "terminated" +) + +type SessionReason string + +const ( + ReasonSpawnRequested SessionReason = "spawn_requested" + ReasonAgentAcknowledged SessionReason = "agent_acknowledged" + ReasonTaskInProgress SessionReason = "task_in_progress" + ReasonPRCreated SessionReason = "pr_created" + ReasonFixingCI SessionReason = "fixing_ci" + ReasonResolvingReviewComments SessionReason = "resolving_review_comments" + ReasonAwaitingUserInput SessionReason = "awaiting_user_input" + ReasonAwaitingExternalReview SessionReason = "awaiting_external_review" + ReasonResearchComplete SessionReason = "research_complete" + ReasonMergedWaitingDecision SessionReason = "merged_waiting_decision" + ReasonManuallyKilled SessionReason = "manually_killed" + ReasonPRMerged SessionReason = "pr_merged" + ReasonAutoCleanup SessionReason = "auto_cleanup" + ReasonRuntimeLost SessionReason = "runtime_lost" + ReasonAgentProcessExited SessionReason = "agent_process_exited" + ReasonProbeFailure SessionReason = "probe_failure" + ReasonErrorInProcess SessionReason = "error_in_process" +) + +type SessionSubstate struct { + State SessionState `json:"state"` + Reason SessionReason `json:"reason"` +} + +// ---- PR sub-state ---- + +type PRState string + +const ( + PRNone PRState = "none" + PROpen PRState = "open" + PRMerged PRState = "merged" + PRClosed PRState = "closed" +) + +type PRReason string + +const ( + PRReasonNotCreated PRReason = "not_created" + PRReasonInProgress PRReason = "in_progress" + PRReasonCIFailing PRReason = "ci_failing" + PRReasonReviewPending PRReason = "review_pending" + PRReasonChangesRequested PRReason = "changes_requested" + PRReasonApproved PRReason = "approved" + PRReasonMergeReady PRReason = "merge_ready" + PRReasonMerged PRReason = "merged" + PRReasonClosedUnmerged PRReason = "closed_unmerged" + PRReasonClearedOnRestore PRReason = "cleared_on_restore" +) + +type PRSubstate struct { + State PRState `json:"state"` + Reason PRReason `json:"reason"` + Number int `json:"number,omitempty"` + URL string `json:"url,omitempty"` +} + +// ---- runtime sub-state ---- + +type RuntimeState string + +const ( + RuntimeUnknown RuntimeState = "unknown" + RuntimeAlive RuntimeState = "alive" + RuntimeExited RuntimeState = "exited" + RuntimeMissing RuntimeState = "missing" + RuntimeProbeFailed RuntimeState = "probe_failed" +) + +type RuntimeReason string + +const ( + RuntimeReasonSpawnIncomplete RuntimeReason = "spawn_incomplete" + RuntimeReasonProcessRunning RuntimeReason = "process_running" + RuntimeReasonProcessMissing RuntimeReason = "process_missing" + RuntimeReasonTmuxMissing RuntimeReason = "tmux_missing" + RuntimeReasonManualKillRequested RuntimeReason = "manual_kill_requested" + RuntimeReasonPRMergedCleanup RuntimeReason = "pr_merged_cleanup" + RuntimeReasonAutoCleanup RuntimeReason = "auto_cleanup" + RuntimeReasonProbeError RuntimeReason = "probe_error" +) + +type RuntimeSubstate struct { + State RuntimeState `json:"state"` + Reason RuntimeReason `json:"reason"` +} + +// ---- activity sub-state (decider input) ---- + +type ActivityState string + +const ( + ActivityActive ActivityState = "active" + ActivityReady ActivityState = "ready" + ActivityIdle ActivityState = "idle" + ActivityWaitingInput ActivityState = "waiting_input" // sticky: does not decay by wallclock + ActivityBlocked ActivityState = "blocked" // sticky: does not decay by wallclock + ActivityExited ActivityState = "exited" +) + +// IsSticky reports whether an activity state must NOT be aged/demoted by the +// passage of time (a paused agent is still paused until a new signal says so). +func (a ActivityState) IsSticky() bool { + return a == ActivityWaitingInput || a == ActivityBlocked +} + +type ActivitySource string + +const ( + SourceNative ActivitySource = "native" + SourceTerminal ActivitySource = "terminal" + SourceHook ActivitySource = "hook" + SourceRuntime ActivitySource = "runtime" + SourceNone ActivitySource = "none" +) + +type ActivitySubstate struct { + State ActivityState `json:"state"` + LastActivityAt time.Time `json:"lastActivityAt"` + Source ActivitySource `json:"source"` +} + +// ---- detecting quarantine memory (decider input) ---- + +type DetectingState struct { + Attempts int `json:"attempts"` + StartedAt time.Time `json:"startedAt"` + EvidenceHash string `json:"evidenceHash"` +} diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go new file mode 100644 index 0000000000..f7761d99dd --- /dev/null +++ b/backend/internal/domain/session.go @@ -0,0 +1,33 @@ +package domain + +import "time" + +// SessionID, ProjectID, IssueID are distinct string types so they can't be +// swapped at a call site by accident. +type ( + SessionID string + ProjectID string + IssueID string +) + +type SessionKind string + +const ( + KindWorker SessionKind = "worker" + KindOrchestrator SessionKind = "orchestrator" +) + +// Session is the read-model returned across the API boundary (to controllers, +// then the frontend). Status is the DERIVED display status, attached on read by +// the Session Manager so the API layer never recomputes it (single producer). +type Session struct { + ID SessionID `json:"id"` + ProjectID ProjectID `json:"projectId"` + IssueID IssueID `json:"issueId,omitempty"` + Kind SessionKind `json:"kind"` + Lifecycle CanonicalSessionLifecycle `json:"lifecycle"` + Status SessionStatus `json:"status"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go new file mode 100644 index 0000000000..7eed758acb --- /dev/null +++ b/backend/internal/domain/status.go @@ -0,0 +1,95 @@ +package domain + +// SessionStatus is the single-word DISPLAY status the dashboard renders. It is +// derived from the canonical lifecycle on read and never persisted. +type SessionStatus string + +const ( + StatusSpawning SessionStatus = "spawning" + StatusWorking SessionStatus = "working" + StatusDetecting SessionStatus = "detecting" + StatusPROpen SessionStatus = "pr_open" + StatusCIFailed SessionStatus = "ci_failed" + StatusReviewPending SessionStatus = "review_pending" + StatusChangesRequested SessionStatus = "changes_requested" + StatusApproved SessionStatus = "approved" + StatusMergeable SessionStatus = "mergeable" + StatusMerged SessionStatus = "merged" + StatusCleanup SessionStatus = "cleanup" + StatusNeedsInput SessionStatus = "needs_input" + StatusStuck SessionStatus = "stuck" + StatusErrored SessionStatus = "errored" + StatusKilled SessionStatus = "killed" + StatusIdle SessionStatus = "idle" + StatusDone SessionStatus = "done" + StatusTerminated SessionStatus = "terminated" +) + +// DeriveLegacyStatus is the ONLY producer of the display status. It must stay a +// pure, total function of the canonical record. +// +// Order matters and encodes the core invariant "PR facts dominate session facts +// once a PR exists": +// 1. Terminal / hard session states map directly (terminated sub-switches on reason). +// 2. A merged PR wins. +// 3. An open PR maps by its reason. +// 4. Otherwise fall through to the raw session state. +func DeriveLegacyStatus(l CanonicalSessionLifecycle) SessionStatus { + switch l.Session.State { + case SessionDone: + return StatusDone + case SessionTerminated: + return terminatedStatus(l.Session.Reason) + case SessionNeedsInput: + return StatusNeedsInput + case SessionStuck: + return StatusStuck + case SessionDetecting: + return StatusDetecting + case SessionNotStarted: + return StatusSpawning + } + + if l.PR.State == PRMerged { + return StatusMerged + } + + if l.PR.State == PROpen { + return openPRStatus(l.PR.Reason) + } + + if l.Session.State == SessionIdle { + return StatusIdle + } + return StatusWorking +} + +func terminatedStatus(r SessionReason) SessionStatus { + switch r { + case ReasonManuallyKilled, ReasonRuntimeLost, ReasonAgentProcessExited: + return StatusKilled + case ReasonAutoCleanup, ReasonPRMerged: + return StatusCleanup + case ReasonErrorInProcess, ReasonProbeFailure: + return StatusErrored + default: + return StatusTerminated + } +} + +func openPRStatus(r PRReason) SessionStatus { + switch r { + case PRReasonCIFailing: + return StatusCIFailed + case PRReasonChangesRequested: + return StatusChangesRequested + case PRReasonApproved: + return StatusApproved + case PRReasonMergeReady: + return StatusMergeable + case PRReasonReviewPending: + return StatusReviewPending + default: + return StatusPROpen + } +} diff --git a/backend/internal/domain/status_test.go b/backend/internal/domain/status_test.go new file mode 100644 index 0000000000..12b0ade059 --- /dev/null +++ b/backend/internal/domain/status_test.go @@ -0,0 +1,87 @@ +package domain + +import "testing" + +func TestDeriveLegacyStatus(t *testing.T) { + tests := []struct { + name string + in CanonicalSessionLifecycle + want SessionStatus + }{ + { + name: "not_started maps to spawning", + in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionNotStarted, Reason: ReasonSpawnRequested}}, + want: StatusSpawning, + }, + { + name: "terminated+manually_killed maps to killed", + in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated, Reason: ReasonManuallyKilled}}, + want: StatusKilled, + }, + { + name: "terminated+auto_cleanup maps to cleanup", + in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated, Reason: ReasonAutoCleanup}}, + want: StatusCleanup, + }, + { + name: "terminated+error maps to errored", + in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated, Reason: ReasonErrorInProcess}}, + want: StatusErrored, + }, + { + name: "hard state needs_input maps directly", + in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionNeedsInput}}, + want: StatusNeedsInput, + }, + { + name: "merged PR dominates an idle session", + in: CanonicalSessionLifecycle{ + Session: SessionSubstate{State: SessionIdle}, + PR: PRSubstate{State: PRMerged}, + }, + want: StatusMerged, + }, + { + name: "open PR with failing CI dominates idle session", + in: CanonicalSessionLifecycle{ + Session: SessionSubstate{State: SessionIdle}, + PR: PRSubstate{State: PROpen, Reason: PRReasonCIFailing}, + }, + want: StatusCIFailed, + }, + { + name: "open PR approved", + in: CanonicalSessionLifecycle{ + Session: SessionSubstate{State: SessionWorking}, + PR: PRSubstate{State: PROpen, Reason: PRReasonApproved}, + }, + want: StatusApproved, + }, + { + name: "open PR merge_ready maps to mergeable", + in: CanonicalSessionLifecycle{ + Session: SessionSubstate{State: SessionWorking}, + PR: PRSubstate{State: PROpen, Reason: PRReasonMergeReady}, + }, + want: StatusMergeable, + }, + { + name: "no PR falls through to idle", + in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionIdle}}, + want: StatusIdle, + }, + { + name: "no PR falls through to working", + in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionWorking}}, + want: StatusWorking, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DeriveLegacyStatus(tt.in); got != tt.want { + t.Errorf("DeriveLegacyStatus() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/backend/internal/ports/facts.go b/backend/internal/ports/facts.go new file mode 100644 index 0000000000..b8d9eaaf96 --- /dev/null +++ b/backend/internal/ports/facts.go @@ -0,0 +1,143 @@ +// Package ports declares the boundary contracts for the LCM + Session Manager +// lane: the inbound interfaces we implement, the outbound interfaces others +// implement for us, and the fact DTOs that cross those boundaries. +// +// These are the types adil (SCM poller), Tom (persistence), and aditi (API) +// build against, so they are committed and stabilised before the LCM/SM logic. +package ports + +import ( + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// SCMFacts is produced by adil's SCM poller and handed to ApplySCMObservation. +// +// Fetched is the failed-probe guard: when false, the GitHub query timed out or +// errored and the rest of the struct is meaningless — the LCM must NOT read it +// as "no PR / PR closed" (the SCM analogue of "failed probe != dead"). +// +// CIFailureLogTail is a pointer because it is only populated when CI is failing; +// it carries ~120 lines and we don't want it on the hot poll path otherwise. +type SCMFacts struct { + Fetched bool + ObservedAt time.Time + PRState domain.PRState + PRNumber int + PRURL string + CISummary CISummary + ReviewDecision ReviewDecision + Mergeability Mergeability + PendingComments []ReviewComment + CIFailureLogTail *string +} + +type CISummary string + +const ( + CIPending CISummary = "pending" + CIPassing CISummary = "passing" + CIFailing CISummary = "failing" + CINone CISummary = "none" +) + +type ReviewDecision string + +const ( + ReviewApproved ReviewDecision = "approved" + ReviewChangesRequested ReviewDecision = "changes_requested" + ReviewPending ReviewDecision = "pending" + ReviewNone ReviewDecision = "none" +) + +// Mergeability is the structured "can this merge?" answer. CIPassing/Approved +// here overlap CISummary/ReviewDecision by design (different granularity); +// Mergeability is authoritative for the merge gate, the others for display. +type Mergeability struct { + Mergeable bool + CIPassing bool + Approved bool + NoConflicts bool + Blockers []string +} + +// ReviewComment carries IsBot so the decider can route bot review comments +// (bugbot-comments reaction) differently from human ones (changes-requested). +type ReviewComment struct { + Author string + Body string + IsBot bool + URL string +} + +// RuntimeFacts is produced by the reaper and handed to ApplyRuntimeObservation. +type RuntimeFacts struct { + ObservedAt time.Time + RuntimeState RuntimeProbe + ProcessState ProcessProbe +} + +// RuntimeProbe / ProcessProbe keep "failed" (the probe call itself errored or +// timed out) distinct from "indeterminate" (the probe ran but couldn't tell) — +// they route differently in the decider. +type RuntimeProbe string + +const ( + RuntimeProbeAlive RuntimeProbe = "alive" + RuntimeProbeDead RuntimeProbe = "dead" + RuntimeProbeIndeterminate RuntimeProbe = "indeterminate" + RuntimeProbeFailed RuntimeProbe = "failed" +) + +type ProcessProbe string + +const ( + ProcessProbeAlive ProcessProbe = "alive" + ProcessProbeDead ProcessProbe = "dead" + ProcessProbeIndeterminate ProcessProbe = "indeterminate" + ProcessProbeFailed ProcessProbe = "failed" +) + +// ActivitySignal is pushed by agent hooks / the FS watcher. State is the +// confidence wrapper (so unavailable/probe_failure != idleness); Activity is +// the actual classification. +type ActivitySignal struct { + State SignalConfidence + Activity domain.ActivityState + Timestamp time.Time + Source domain.ActivitySource +} + +type SignalConfidence string + +const ( + SignalValid SignalConfidence = "valid" + SignalStale SignalConfidence = "stale" + SignalNull SignalConfidence = "null" + SignalUnavailable SignalConfidence = "unavailable" + SignalProbeFailure SignalConfidence = "probe_failure" +) + +// SpawnOutcome is what the Session Manager reports to the LCM after a spawn. +type SpawnOutcome struct { + Branch string + WorkspacePath string + RuntimeHandle string + AgentSessionID string +} + +// KillReason is what the Session Manager reports to the LCM when a kill is +// requested. Kind drives whether the terminal state is killed/cleanup/errored. +type KillReason struct { + Kind LifecycleKillReason + Detail string +} + +type LifecycleKillReason string + +const ( + KillManual LifecycleKillReason = "manual" + KillCleanup LifecycleKillReason = "cleanup" + KillError LifecycleKillReason = "error" +) diff --git a/backend/internal/ports/inbound.go b/backend/internal/ports/inbound.go new file mode 100644 index 0000000000..9f2e2bb42b --- /dev/null +++ b/backend/internal/ports/inbound.go @@ -0,0 +1,68 @@ +package ports + +import ( + "context" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// LifecycleManager is the inbound contract we implement. Every Apply* method +// runs the same synchronous pipeline: load canonical -> pure decide -> diff -> +// persist (merge-patch) -> if the status transitioned, fire reactions. The LCM +// never polls; observers (SCM poller, reaper, activity ingest) call in. +// +// Concurrency: the LCM serialises per session, so concurrent Apply* calls for +// the same session do not race the load/decide/persist read-modify-write. +type LifecycleManager interface { + // Raw-fact entrypoints (each runs decide internally). + ApplySCMObservation(ctx context.Context, id domain.SessionID, f SCMFacts) error + ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f RuntimeFacts) error + ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ActivitySignal) error + + // Mutation outcomes reported by the Session Manager. + OnSpawnCompleted(ctx context.Context, id domain.SessionID, o SpawnOutcome) error + OnKillRequested(ctx context.Context, id domain.SessionID, r KillReason) error + + // Reaper heartbeat that drives duration-based escalation (a non-polling + // LCM can't wake itself to fire a "30m elapsed" escalation). + TickEscalations(ctx context.Context, now time.Time) error +} + +// SessionManager is the inbound contract called by the API layer and CLI. It +// owns explicit mutations (spawn/kill/restore/cleanup) and never derives or +// writes observed state directly — it routes outcomes to the LCM. +type SessionManager interface { + Spawn(ctx context.Context, cfg SpawnConfig) (domain.Session, error) + Kill(ctx context.Context, id domain.SessionID, opts KillOptions) (KillResult, error) + List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) + Get(ctx context.Context, id domain.SessionID) (domain.Session, error) + Send(ctx context.Context, id domain.SessionID, message string) error + Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) + Cleanup(ctx context.Context, project domain.ProjectID) (CleanupResult, error) +} + +type SpawnConfig struct { + ProjectID domain.ProjectID + IssueID domain.IssueID + Kind domain.SessionKind + Branch string + Prompt string + AgentRules string + OpenTerminal bool +} + +type KillOptions struct { + Reason LifecycleKillReason + Detail string +} + +type KillResult struct { + ID domain.SessionID + WorkspaceFreed bool +} + +type CleanupResult struct { + Cleaned []domain.SessionID + Skipped []domain.SessionID // e.g. paths that still held uncommitted work +} diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go new file mode 100644 index 0000000000..2ad91cb996 --- /dev/null +++ b/backend/internal/ports/outbound.go @@ -0,0 +1,123 @@ +package ports + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// LifecycleStore is Tom's persistence layer, the ONLY disk writer. It owns +// merge-patch, atomic write, file lock, and CDC eventing. The LCM and SM only +// ever touch state through this narrow interface. +type LifecycleStore interface { + Load(ctx context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) + PatchLifecycle(ctx context.Context, id domain.SessionID, patch LifecyclePatch) error + List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) + GetMetadata(ctx context.Context, id domain.SessionID) (map[string]string, error) + PatchMetadata(ctx context.Context, id domain.SessionID, kv map[string]string) error +} + +// LifecyclePatch is a sparse merge-patch: a nil field is left untouched, a +// non-nil field is written. Detecting needs three-way semantics (leave / set / +// clear-to-nil) which a single pointer can't express, so ClearDetecting handles +// the clear case explicitly. +// +// ExpectedVersion supports optimistic concurrency: when non-nil the store must +// reject the patch if the stored Version differs. (Open for Tom to confirm vs. +// the LCM owning all serialisation itself.) +type LifecyclePatch struct { + Session *domain.SessionSubstate + PR *domain.PRSubstate + Runtime *domain.RuntimeSubstate + Activity *domain.ActivitySubstate + Detecting *domain.DetectingState + ClearDetecting bool + ExpectedVersion *int +} + +// Notifier delivers events to the human (desktop/Slack later). Push, never pull. +type Notifier interface { + Notify(ctx context.Context, event OrchestratorEvent) error +} + +type EventPriority string + +const ( + PriorityUrgent EventPriority = "urgent" + PriorityAction EventPriority = "action" + PriorityWarning EventPriority = "warning" + PriorityInfo EventPriority = "info" +) + +type OrchestratorEvent struct { + Type string + Priority EventPriority + SessionID domain.SessionID + ProjectID domain.ProjectID + Message string + Data map[string]any +} + +// AgentMessenger injects a message into a running agent. The implementation +// busy-detects (waits for the agent to be idle/ready) and verifies delivery, +// which is why activity-detection accuracy matters. +type AgentMessenger interface { + Send(ctx context.Context, id domain.SessionID, message string) error +} + +// The runtime/agent/workspace plugin ports are co-owned with the coding-agents +// lane; the method sets below are the minimum the Session Manager spawn/kill +// pipelines call. They will be fleshed out alongside the tmux/claude-code impls. + +type Runtime interface { + Create(ctx context.Context, cfg RuntimeConfig) (RuntimeHandle, error) + Destroy(ctx context.Context, handle RuntimeHandle) error + SendMessage(ctx context.Context, handle RuntimeHandle, message string) error + GetOutput(ctx context.Context, handle RuntimeHandle, lines int) (string, error) + IsAlive(ctx context.Context, handle RuntimeHandle) (bool, error) +} + +type RuntimeConfig struct { + SessionID domain.SessionID + WorkspacePath string + LaunchCommand string + Env map[string]string +} + +type RuntimeHandle struct { + ID string + RuntimeName string +} + +type Agent interface { + GetLaunchCommand(cfg AgentConfig) string + GetEnvironment(cfg AgentConfig) map[string]string + IsProcessRunning(ctx context.Context, handle RuntimeHandle) (domain.ActivityState, error) + GetRestoreCommand(agentSessionID string) string +} + +type AgentConfig struct { + SessionID domain.SessionID + WorkspacePath string + Prompt string +} + +type Workspace interface { + Create(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) + Destroy(ctx context.Context, info WorkspaceInfo) error + List(ctx context.Context, project domain.ProjectID) ([]WorkspaceInfo, error) + Restore(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) +} + +type WorkspaceConfig struct { + ProjectID domain.ProjectID + SessionID domain.SessionID + Branch string +} + +type WorkspaceInfo struct { + Path string + Branch string + SessionID domain.SessionID + ProjectID domain.ProjectID +} From e8f60d0b2733e995b0b85d6e36a2cf4299d5aec0 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 26 May 2026 20:55:27 +0530 Subject: [PATCH 005/250] refactor(backend): address contract review on LCM+SM ports Review feedback on PR #2: - Add CanonicalSessionLifecycle.Revision (monotonic write counter) distinct from the schema Version; LifecyclePatch.ExpectedVersion -> ExpectedRevision now compares it, so optimistic locking actually works. - LifecycleStore.List returns []domain.SessionRecord (persistence shape, no derived status); add SessionRecord and make Session embed it. Keeps the Session Manager the single producer of the derived display status. - SpawnOutcome.RuntimeHandle is now the structured ports.RuntimeHandle, not a string, so Destroy/SendMessage get the handle without ad-hoc encoding. - Agent.IsProcessRunning -> ProbeProcess returning ProcessProbe (liveness), not domain.ActivityState; the name no longer implies a boolean. - Document LifecyclePatch Detecting vs ClearDetecting precedence (clear wins). - Correct the DeriveLegacyStatus doc: hard session states outrank PR facts; "PR dominates" applies only to the soft idle/working states. Implementation was already correct (matches canonical AO); only the comment overstated it. - Replace personal-name attributions in package/interface comments with role-based terms (SCM poller / persistence adapter / API layer). go build / go vet / go test all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/domain/lifecycle.go | 13 ++++++--- backend/internal/domain/session.go | 19 ++++++++---- backend/internal/domain/status.go | 17 +++++++---- backend/internal/ports/facts.go | 10 ++++--- backend/internal/ports/outbound.go | 43 +++++++++++++++++----------- 5 files changed, 67 insertions(+), 35 deletions(-) diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go index 1727fad718..567a47693c 100644 --- a/backend/internal/domain/lifecycle.go +++ b/backend/internal/domain/lifecycle.go @@ -20,10 +20,15 @@ const LifecycleVersion = 1 // between observations (they are read back by the pure decide core), so they // live in the persisted record too. type CanonicalSessionLifecycle struct { - Version int `json:"version"` - Session SessionSubstate `json:"session"` - PR PRSubstate `json:"pr"` - Runtime RuntimeSubstate `json:"runtime"` + // Version is the schema version of this record's shape (LifecycleVersion). + Version int `json:"version"` + // Revision is a monotonic counter the store bumps on every write. It is used + // for optimistic-concurrency checks (LifecyclePatch.ExpectedRevision) and is + // distinct from the schema Version above. + Revision int `json:"revision"` + Session SessionSubstate `json:"session"` + PR PRSubstate `json:"pr"` + Runtime RuntimeSubstate `json:"runtime"` // Activity is the last-known agent activity. It arrives on a different // cadence (ApplyActivitySignal) than runtime probes (the reaper), so the diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index f7761d99dd..578cca4066 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -17,17 +17,26 @@ const ( KindOrchestrator SessionKind = "orchestrator" ) -// Session is the read-model returned across the API boundary (to controllers, -// then the frontend). Status is the DERIVED display status, attached on read by -// the Session Manager so the API layer never recomputes it (single producer). -type Session struct { +// SessionRecord is the PERSISTENCE shape: identity, canonical lifecycle, and +// metadata — everything the store holds, and nothing derived. The store reads +// and writes records; it never produces the derived display status. +type SessionRecord struct { ID SessionID `json:"id"` ProjectID ProjectID `json:"projectId"` IssueID IssueID `json:"issueId,omitempty"` Kind SessionKind `json:"kind"` Lifecycle CanonicalSessionLifecycle `json:"lifecycle"` - Status SessionStatus `json:"status"` Metadata map[string]string `json:"metadata,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } + +// Session is the read-model returned across the API boundary (to controllers, +// then the frontend): a SessionRecord plus the DERIVED display Status. The +// Session Manager is the single producer of Status — it builds a Session from a +// stored SessionRecord by calling DeriveLegacyStatus, so the store and API +// never recompute (or accidentally persist) it. +type Session struct { + SessionRecord + Status SessionStatus `json:"status"` +} diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index 7eed758acb..b12b2b9f63 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -28,12 +28,17 @@ const ( // DeriveLegacyStatus is the ONLY producer of the display status. It must stay a // pure, total function of the canonical record. // -// Order matters and encodes the core invariant "PR facts dominate session facts -// once a PR exists": -// 1. Terminal / hard session states map directly (terminated sub-switches on reason). -// 2. A merged PR wins. -// 3. An open PR maps by its reason. -// 4. Otherwise fall through to the raw session state. +// Order matters: +// 1. Terminal / hard session states (done, terminated, needs_input, stuck, +// detecting, not_started) map directly — these OUTRANK PR facts. +// 2. Otherwise a merged PR wins. +// 3. Otherwise an open PR maps by its reason. +// 4. Otherwise fall through to the SOFT session state (idle/working). +// +// So "PR facts dominate session facts" applies only to the soft states: an idle +// or working session with an open, CI-failing PR displays as ci_failed — but a +// session that is stuck or needs_input shows that regardless of PR state, since +// it needs a human either way. func DeriveLegacyStatus(l CanonicalSessionLifecycle) SessionStatus { switch l.Session.State { case SessionDone: diff --git a/backend/internal/ports/facts.go b/backend/internal/ports/facts.go index b8d9eaaf96..55f4f6ca6a 100644 --- a/backend/internal/ports/facts.go +++ b/backend/internal/ports/facts.go @@ -2,8 +2,8 @@ // lane: the inbound interfaces we implement, the outbound interfaces others // implement for us, and the fact DTOs that cross those boundaries. // -// These are the types adil (SCM poller), Tom (persistence), and aditi (API) -// build against, so they are committed and stabilised before the LCM/SM logic. +// These are the types the SCM poller, persistence adapter, and API layer build +// against, so they are committed and stabilised before the LCM/SM logic. package ports import ( @@ -12,7 +12,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// SCMFacts is produced by adil's SCM poller and handed to ApplySCMObservation. +// SCMFacts is produced by the SCM poller and handed to ApplySCMObservation. // // Fetched is the failed-probe guard: when false, the GitHub query timed out or // errored and the rest of the struct is meaningless — the LCM must NOT read it @@ -120,10 +120,12 @@ const ( ) // SpawnOutcome is what the Session Manager reports to the LCM after a spawn. +// RuntimeHandle is the same structured handle the Runtime port returns, so no +// ad-hoc string encoding is needed for later Destroy/SendMessage calls. type SpawnOutcome struct { Branch string WorkspacePath string - RuntimeHandle string + RuntimeHandle RuntimeHandle AgentSessionID string } diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 2ad91cb996..7a3649ae53 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -6,33 +6,41 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// LifecycleStore is Tom's persistence layer, the ONLY disk writer. It owns +// LifecycleStore is the persistence adapter, the ONLY disk writer. It owns // merge-patch, atomic write, file lock, and CDC eventing. The LCM and SM only // ever touch state through this narrow interface. +// +// List returns persistence records (no derived status); the Session Manager +// turns those into domain.Session by attaching the derived display status. type LifecycleStore interface { Load(ctx context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) PatchLifecycle(ctx context.Context, id domain.SessionID, patch LifecyclePatch) error - List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) + List(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) GetMetadata(ctx context.Context, id domain.SessionID) (map[string]string, error) PatchMetadata(ctx context.Context, id domain.SessionID, kv map[string]string) error } // LifecyclePatch is a sparse merge-patch: a nil field is left untouched, a -// non-nil field is written. Detecting needs three-way semantics (leave / set / -// clear-to-nil) which a single pointer can't express, so ClearDetecting handles -// the clear case explicitly. +// non-nil field is written. +// +// Detecting needs three-way semantics (leave / set / clear-to-nil): +// - ClearDetecting == true → store clears the detecting memory and IGNORES +// the Detecting field (clear wins; setting both is a caller bug). +// - ClearDetecting == false, Detecting != nil → set/replace the memory. +// - ClearDetecting == false, Detecting == nil → leave it untouched. // -// ExpectedVersion supports optimistic concurrency: when non-nil the store must -// reject the patch if the stored Version differs. (Open for Tom to confirm vs. -// the LCM owning all serialisation itself.) +// ExpectedRevision supports optimistic concurrency: when non-nil the store must +// reject the patch if the stored Revision (the monotonic write counter, NOT the +// schema Version) differs. This is the alternative to the LCM owning all +// per-session serialisation itself. type LifecyclePatch struct { - Session *domain.SessionSubstate - PR *domain.PRSubstate - Runtime *domain.RuntimeSubstate - Activity *domain.ActivitySubstate - Detecting *domain.DetectingState - ClearDetecting bool - ExpectedVersion *int + Session *domain.SessionSubstate + PR *domain.PRSubstate + Runtime *domain.RuntimeSubstate + Activity *domain.ActivitySubstate + Detecting *domain.DetectingState + ClearDetecting bool + ExpectedRevision *int } // Notifier delivers events to the human (desktop/Slack later). Push, never pull. @@ -92,7 +100,10 @@ type RuntimeHandle struct { type Agent interface { GetLaunchCommand(cfg AgentConfig) string GetEnvironment(cfg AgentConfig) map[string]string - IsProcessRunning(ctx context.Context, handle RuntimeHandle) (domain.ActivityState, error) + // ProbeProcess returns the agent process liveness classification + // (alive/dead/indeterminate/failed) — not a boolean and not an activity + // state. Activity classification arrives separately via ActivitySignal. + ProbeProcess(ctx context.Context, handle RuntimeHandle) (ProcessProbe, error) GetRestoreCommand(agentSessionID string) string } From d824c4f7675cb0abccef810bee147e39e058c53b Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 26 May 2026 21:02:58 +0530 Subject: [PATCH 006/250] ci: add Go build/test and gitleaks secret-scan workflows - go.yml: gofmt check, build, vet, and race-enabled tests for backend/, triggered on backend changes and pushes to main. - gitleaks.yml: secret scanning on PRs and main using gitleaks-action v1 (license-free; v2 requires GITLEAKS_LICENSE for org repos). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/gitleaks.yml | 22 +++++++++++++++++ .github/workflows/go.yml | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .github/workflows/gitleaks.yml create mode 100644 .github/workflows/go.yml diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000000..15c70781d1 --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,22 @@ +name: gitleaks + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # gitleaks-action v1 scans for committed secrets and needs no license + # key (v2 requires GITLEAKS_LICENSE for organization repos). + - name: Scan for secrets + uses: zricethezav/gitleaks-action@v1.6.0 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000000..e3ceaf1cc6 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,44 @@ +name: Go + +on: + push: + branches: [main] + pull_request: + paths: + - "backend/**" + - ".github/workflows/go.yml" + +permissions: + contents: read + +jobs: + build-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache: false + + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race ./... From 6e907342762668e0572e8eb83849b203a0ade014 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 26 May 2026 23:54:55 +0530 Subject: [PATCH 007/250] feat(decide): implement pure DECIDE core with exhaustive truth-table tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the stubbed deciders in domain/decide with real, total, side-effect-free implementations: - ResolveProbeDecision: kill-intent short-circuits to terminal; failed probes and probe disagreement route to detecting; only runtime-dead + process-dead + no-recent-activity concludes killed. - ResolveOpenPRDecision: the PR pipeline ladder (ci_failing > changes_requested > approved+mergeable > approved > review_pending > idle-beyond > open). - ResolveTerminalPRStateDecision: merged -> idle/merged_waiting_decision, closed -> idle. - CreateDetectingDecision: anti-flap quarantine — unchanged-evidence counter with StartedAt preserved across the episode so the duration cap is a real wall-clock safety net; escalates to stuck at 3 ticks or 5m. - HashEvidence: strips timestamps/epochs and collapses whitespace before hashing so restamped-but-unchanged signals compare equal. Table tests cover every branch (100% statement coverage), including a consistency check that the open-PR ladder's display Status matches DeriveLegacyStatus over the canonical state it emits. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/domain/decide/decide.go | 208 +++++++- backend/internal/domain/decide/decide_test.go | 468 ++++++++++++++++++ 2 files changed, 663 insertions(+), 13 deletions(-) create mode 100644 backend/internal/domain/decide/decide_test.go diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go index e92ed694ee..5862763290 100644 --- a/backend/internal/domain/decide/decide.go +++ b/backend/internal/domain/decide/decide.go @@ -2,13 +2,14 @@ // collapses observed facts (plus the prior detecting/activity memory) into one // LifecycleDecision. Every function here must remain side-effect free so the // whole status truth-table can be tested in isolation. -// -// NOTE: function bodies are stubbed in this contracts PR. The real logic + the -// exhaustive truth-table tests land in the follow-up "decide core" PR. The -// signatures and the input/output shapes are what we are stabilising now. package decide import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strings" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -82,27 +83,208 @@ type DetectingInput struct { } // ResolveProbeDecision reconciles runtime/process liveness into a decision. +// +// The ordering encodes the load-bearing invariants: +// - an explicit kill short-circuits straight to terminal (the only inferred +// terminal this decider may reach without quarantine); +// - a *failed* probe (timeout/error) is never read as death — it routes to +// detecting, as does any disagreement between the two probes; +// - only runtime-dead + process-dead + no-recent-activity reaches killed. func ResolveProbeDecision(in ProbeInput) LifecycleDecision { - panic("decide.ResolveProbeDecision: not implemented (decide-core PR)") + if in.KillRequested { + return LifecycleDecision{ + Status: domain.StatusKilled, + Evidence: "manual kill requested", + SessionState: domain.SessionTerminated, + SessionReason: domain.ReasonManuallyKilled, + } + } + + if in.RuntimeFailed || in.ProcessFailed || in.Runtime == domain.RuntimeProbeFailed { + ev := fmt.Sprintf("probe_failed runtime=%s runtimeFailed=%t process=%s processFailed=%t", + in.Runtime, in.RuntimeFailed, in.Process, in.ProcessFailed) + return detecting(in, domain.ReasonProbeFailure, ev) + } + + switch in.Runtime { + case domain.RuntimeAlive: + if in.Process == ProcessDead { + // Runtime up but the agent process is gone: probes disagree. + ev := fmt.Sprintf("disagree runtime=alive process=%s recentActivity=%t", in.Process, in.RecentActivity) + return detecting(in, domain.ReasonAgentProcessExited, ev) + } + return LifecycleDecision{ + Status: domain.StatusWorking, + Evidence: fmt.Sprintf("alive runtime=alive process=%s", in.Process), + SessionState: domain.SessionWorking, + SessionReason: domain.ReasonTaskInProgress, + } + + case domain.RuntimeExited, domain.RuntimeMissing: + // Runtime is gone. Death is only concluded when the process is *also* + // confirmed dead AND nothing has been heard from the agent recently; + // any other shape is ambiguous and quarantines. + if in.Process == ProcessAlive || in.RecentActivity { + ev := fmt.Sprintf("disagree runtime=%s process=%s recentActivity=%t", in.Runtime, in.Process, in.RecentActivity) + return detecting(in, domain.ReasonRuntimeLost, ev) + } + if in.Process == ProcessDead { + return LifecycleDecision{ + Status: domain.StatusKilled, + Evidence: fmt.Sprintf("dead runtime=%s process=dead recentActivity=false", in.Runtime), + SessionState: domain.SessionTerminated, + SessionReason: domain.ReasonRuntimeLost, + } + } + // Process indeterminate: cannot confirm death, so quarantine. + ev := fmt.Sprintf("runtime_lost runtime=%s process=%s recentActivity=false", in.Runtime, in.Process) + return detecting(in, domain.ReasonRuntimeLost, ev) + + default: + // unknown (not yet probed): ambiguous, never conclude death. + ev := fmt.Sprintf("runtime_unknown runtime=%s process=%s recentActivity=%t", in.Runtime, in.Process, in.RecentActivity) + return detecting(in, domain.ReasonRuntimeLost, ev) + } } -// ResolveOpenPRDecision walks the PR pipeline ladder. +// ResolveOpenPRDecision walks the PR pipeline ladder. CI failure dominates +// everything, then requested changes, then the approval/merge states, then a +// pending review, then a stalled (idle-beyond-threshold) PR, else plain open. func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { - panic("decide.ResolveOpenPRDecision: not implemented (decide-core PR)") + base := func(status domain.SessionStatus, prReason domain.PRReason, ss domain.SessionState, sr domain.SessionReason) LifecycleDecision { + return LifecycleDecision{ + Status: status, + SessionState: ss, + SessionReason: sr, + PRState: domain.PROpen, + PRReason: prReason, + } + } + + switch { + case in.CIFailing: + return base(domain.StatusCIFailed, domain.PRReasonCIFailing, domain.SessionWorking, domain.ReasonFixingCI) + case in.ChangesRequested: + return base(domain.StatusChangesRequested, domain.PRReasonChangesRequested, domain.SessionWorking, domain.ReasonResolvingReviewComments) + case in.Approved && in.Mergeable: + return base(domain.StatusMergeable, domain.PRReasonMergeReady, domain.SessionIdle, domain.ReasonAwaitingExternalReview) + case in.Approved: + return base(domain.StatusApproved, domain.PRReasonApproved, domain.SessionIdle, domain.ReasonAwaitingExternalReview) + case in.ReviewPending: + return base(domain.StatusReviewPending, domain.PRReasonReviewPending, domain.SessionIdle, domain.ReasonAwaitingExternalReview) + case in.IdleBeyond: + // A PR open but quiet past the stuck threshold needs a human nudge. + return base(domain.StatusStuck, domain.PRReasonInProgress, domain.SessionStuck, domain.ReasonAwaitingUserInput) + default: + return base(domain.StatusPROpen, domain.PRReasonInProgress, domain.SessionWorking, domain.ReasonPRCreated) + } } -// ResolveTerminalPRStateDecision handles merged/closed PRs. +// ResolveTerminalPRStateDecision handles merged/closed PRs. A merge parks the +// session idle awaiting a human's post-merge decision; a close drops to idle. +// none/open are not terminal — callers should route those to the open-PR or +// probe deciders — but the function stays total for safety. func ResolveTerminalPRStateDecision(pr domain.PRState) LifecycleDecision { - panic("decide.ResolveTerminalPRStateDecision: not implemented (decide-core PR)") + switch pr { + case domain.PRMerged: + return LifecycleDecision{ + Status: domain.StatusMerged, + Evidence: "pr merged", + SessionState: domain.SessionIdle, + SessionReason: domain.ReasonMergedWaitingDecision, + PRState: domain.PRMerged, + PRReason: domain.PRReasonMerged, + } + case domain.PRClosed: + return LifecycleDecision{ + Status: domain.StatusIdle, + Evidence: "pr closed unmerged", + SessionState: domain.SessionIdle, + SessionReason: domain.ReasonAwaitingUserInput, + PRState: domain.PRClosed, + PRReason: domain.PRReasonClosedUnmerged, + } + default: + return LifecycleDecision{ + Status: domain.StatusWorking, + Evidence: fmt.Sprintf("non-terminal pr state=%s", pr), + SessionState: domain.SessionWorking, + SessionReason: domain.ReasonTaskInProgress, + PRState: pr, + } + } } // CreateDetectingDecision advances or escalates the anti-flap quarantine. +// +// The attempt counter climbs only while the (timestamp-stripped) evidence hash +// is unchanged and resets the moment the evidence moves; StartedAt is preserved +// across the whole detecting episode so the duration cap is a real wall-clock +// safety net even when the evidence keeps flapping. Escalation to stuck fires +// at DetectingMaxAttempts consecutive unchanged ticks OR DetectingMaxDuration +// elapsed since first entering detecting. func CreateDetectingDecision(in DetectingInput) LifecycleDecision { - panic("decide.CreateDetectingDecision: not implemented (decide-core PR)") + hash := HashEvidence(in.Evidence) + + attempts := 1 + startedAt := in.Now + if in.Prior != nil { + startedAt = in.Prior.StartedAt + if in.Prior.EvidenceHash == hash { + attempts = in.Prior.Attempts + 1 + } + } + + escalate := attempts >= DetectingMaxAttempts || !in.Now.Before(startedAt.Add(DetectingMaxDuration)) + if escalate { + return LifecycleDecision{ + Status: domain.StatusStuck, + Evidence: in.Evidence, + SessionState: domain.SessionStuck, + SessionReason: in.ProposedReason, + } + } + + return LifecycleDecision{ + Status: domain.StatusDetecting, + Evidence: in.Evidence, + Detecting: &domain.DetectingState{Attempts: attempts, StartedAt: startedAt, EvidenceHash: hash}, + SessionState: domain.SessionDetecting, + SessionReason: in.ProposedReason, + } } -// HashEvidence normalises an evidence string (stripping timestamps) and hashes -// it, so unchanged-but-restamped signals compare equal. +// HashEvidence normalises an evidence string (stripping timestamps and +// collapsing whitespace) and hashes it, so unchanged-but-restamped signals +// compare equal and the detecting counter is not reset by clock movement alone. func HashEvidence(evidence string) string { - panic("decide.HashEvidence: not implemented (decide-core PR)") + s := evidence + for _, re := range timestampPatterns { + s = re.ReplaceAllString(s, "") + } + s = strings.Join(strings.Fields(s), " ") + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// timestampPatterns strip the time-varying parts of an evidence string before +// hashing. Order matters: the full datetime form is removed before the bare +// time-of-day and epoch forms so they don't partially match. +var timestampPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?`), + regexp.MustCompile(`\d{2}:\d{2}:\d{2}(?:\.\d+)?`), + regexp.MustCompile(`\b\d{10,13}\b`), +} + +// detecting builds a quarantine decision from a probe verdict, threading the +// prior counter through CreateDetectingDecision so probe ambiguity is subject +// to the same anti-flap escalation as any other detecting cause. +func detecting(in ProbeInput, reason domain.SessionReason, evidence string) LifecycleDecision { + return CreateDetectingDecision(DetectingInput{ + Evidence: evidence, + ProposedState: domain.SessionDetecting, + ProposedReason: reason, + Prior: in.Prior, + Now: in.Now, + }) } diff --git a/backend/internal/domain/decide/decide_test.go b/backend/internal/domain/decide/decide_test.go new file mode 100644 index 0000000000..9d615cdba8 --- /dev/null +++ b/backend/internal/domain/decide/decide_test.go @@ -0,0 +1,468 @@ +package decide + +import ( + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +var t0 = time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) + +func TestResolveProbeDecision(t *testing.T) { + tests := []struct { + name string + in ProbeInput + wantStatus domain.SessionStatus + wantState domain.SessionState + wantReason domain.SessionReason + wantDetect bool // expect non-nil Detecting memory + wantTermNil bool // expect terminal (Detecting must be nil) + }{ + { + name: "kill requested short-circuits to terminal killed", + in: ProbeInput{KillRequested: true, Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0}, + wantStatus: domain.StatusKilled, + wantState: domain.SessionTerminated, + wantReason: domain.ReasonManuallyKilled, + wantTermNil: true, + }, + { + name: "kill requested wins even over a dead+dead probe", + in: ProbeInput{KillRequested: true, Runtime: domain.RuntimeMissing, Process: ProcessDead, Now: t0}, + wantStatus: domain.StatusKilled, + wantState: domain.SessionTerminated, + wantReason: domain.ReasonManuallyKilled, + wantTermNil: true, + }, + { + name: "runtime probe failed routes to detecting, never death", + in: ProbeInput{Runtime: domain.RuntimeMissing, RuntimeFailed: true, Process: ProcessDead, Now: t0}, + wantStatus: domain.StatusDetecting, + wantState: domain.SessionDetecting, + wantReason: domain.ReasonProbeFailure, + wantDetect: true, + }, + { + name: "process probe failed routes to detecting", + in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessDead, ProcessFailed: true, Now: t0}, + wantStatus: domain.StatusDetecting, + wantState: domain.SessionDetecting, + wantReason: domain.ReasonProbeFailure, + wantDetect: true, + }, + { + name: "runtime state probe_failed routes to detecting", + in: ProbeInput{Runtime: domain.RuntimeProbeFailed, Process: ProcessIndeterminate, Now: t0}, + wantStatus: domain.StatusDetecting, + wantState: domain.SessionDetecting, + wantReason: domain.ReasonProbeFailure, + wantDetect: true, + }, + { + name: "runtime alive + process alive is working", + in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0}, + wantStatus: domain.StatusWorking, + wantState: domain.SessionWorking, + wantReason: domain.ReasonTaskInProgress, + }, + { + name: "runtime alive + process indeterminate leans alive", + in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessIndeterminate, Now: t0}, + wantStatus: domain.StatusWorking, + wantState: domain.SessionWorking, + wantReason: domain.ReasonTaskInProgress, + }, + { + name: "runtime alive + process dead disagree -> detecting (agent_process_exited)", + in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessDead, Now: t0}, + wantStatus: domain.StatusDetecting, + wantState: domain.SessionDetecting, + wantReason: domain.ReasonAgentProcessExited, + wantDetect: true, + }, + { + name: "runtime dead + process alive disagree -> detecting (runtime_lost)", + in: ProbeInput{Runtime: domain.RuntimeExited, Process: ProcessAlive, Now: t0}, + wantStatus: domain.StatusDetecting, + wantState: domain.SessionDetecting, + wantReason: domain.ReasonRuntimeLost, + wantDetect: true, + }, + { + name: "runtime dead + recent activity disagree -> detecting (runtime_lost)", + in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessDead, RecentActivity: true, Now: t0}, + wantStatus: domain.StatusDetecting, + wantState: domain.SessionDetecting, + wantReason: domain.ReasonRuntimeLost, + wantDetect: true, + }, + { + name: "runtime dead + process indeterminate cannot confirm -> detecting", + in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0}, + wantStatus: domain.StatusDetecting, + wantState: domain.SessionDetecting, + wantReason: domain.ReasonRuntimeLost, + wantDetect: true, + }, + { + name: "runtime exited + process dead + no activity -> killed terminal", + in: ProbeInput{Runtime: domain.RuntimeExited, Process: ProcessDead, Now: t0}, + wantStatus: domain.StatusKilled, + wantState: domain.SessionTerminated, + wantReason: domain.ReasonRuntimeLost, + wantTermNil: true, + }, + { + name: "runtime missing + process dead + no activity -> killed terminal", + in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessDead, Now: t0}, + wantStatus: domain.StatusKilled, + wantState: domain.SessionTerminated, + wantReason: domain.ReasonRuntimeLost, + wantTermNil: true, + }, + { + name: "runtime unknown is ambiguous -> detecting (runtime_lost)", + in: ProbeInput{Runtime: domain.RuntimeUnknown, Process: ProcessDead, Now: t0}, + wantStatus: domain.StatusDetecting, + wantState: domain.SessionDetecting, + wantReason: domain.ReasonRuntimeLost, + wantDetect: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveProbeDecision(tt.in) + if got.Status != tt.wantStatus { + t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus) + } + if got.SessionState != tt.wantState { + t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState) + } + if got.SessionReason != tt.wantReason { + t.Errorf("SessionReason = %q, want %q", got.SessionReason, tt.wantReason) + } + if tt.wantDetect && got.Detecting == nil { + t.Errorf("expected non-nil Detecting memory, got nil") + } + if tt.wantTermNil && got.Detecting != nil { + t.Errorf("terminal decision must carry nil Detecting, got %+v", got.Detecting) + } + }) + } +} + +func TestResolveOpenPRDecision(t *testing.T) { + tests := []struct { + name string + in OpenPRInput + wantStatus domain.SessionStatus + wantPR domain.PRReason + wantState domain.SessionState + }{ + { + name: "ci failing dominates everything", + in: OpenPRInput{CIFailing: true, ChangesRequested: true, Approved: true, Mergeable: true}, + wantStatus: domain.StatusCIFailed, + wantPR: domain.PRReasonCIFailing, + wantState: domain.SessionWorking, + }, + { + name: "changes requested before approval states", + in: OpenPRInput{ChangesRequested: true, Approved: true, Mergeable: true}, + wantStatus: domain.StatusChangesRequested, + wantPR: domain.PRReasonChangesRequested, + wantState: domain.SessionWorking, + }, + { + name: "approved + mergeable -> mergeable", + in: OpenPRInput{Approved: true, Mergeable: true}, + wantStatus: domain.StatusMergeable, + wantPR: domain.PRReasonMergeReady, + wantState: domain.SessionIdle, + }, + { + name: "approved but not mergeable -> approved", + in: OpenPRInput{Approved: true}, + wantStatus: domain.StatusApproved, + wantPR: domain.PRReasonApproved, + wantState: domain.SessionIdle, + }, + { + name: "review pending", + in: OpenPRInput{ReviewPending: true}, + wantStatus: domain.StatusReviewPending, + wantPR: domain.PRReasonReviewPending, + wantState: domain.SessionIdle, + }, + { + name: "idle beyond threshold -> stuck", + in: OpenPRInput{IdleBeyond: true}, + wantStatus: domain.StatusStuck, + wantPR: domain.PRReasonInProgress, + wantState: domain.SessionStuck, + }, + { + name: "review pending wins over idle-beyond", + in: OpenPRInput{ReviewPending: true, IdleBeyond: true}, + wantStatus: domain.StatusReviewPending, + wantPR: domain.PRReasonReviewPending, + wantState: domain.SessionIdle, + }, + { + name: "nothing set -> plain open", + in: OpenPRInput{}, + wantStatus: domain.StatusPROpen, + wantPR: domain.PRReasonInProgress, + wantState: domain.SessionWorking, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveOpenPRDecision(tt.in) + if got.Status != tt.wantStatus { + t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus) + } + if got.PRReason != tt.wantPR { + t.Errorf("PRReason = %q, want %q", got.PRReason, tt.wantPR) + } + if got.PRState != domain.PROpen { + t.Errorf("PRState = %q, want %q", got.PRState, domain.PROpen) + } + if got.SessionState != tt.wantState { + t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState) + } + }) + } +} + +func TestResolveOpenPRDecisionDerivesConsistently(t *testing.T) { + // The display Status produced by the ladder must equal what DeriveLegacyStatus + // would produce from the same canonical (session, pr) it emits. + inputs := []OpenPRInput{ + {CIFailing: true}, + {ChangesRequested: true}, + {Approved: true, Mergeable: true}, + {Approved: true}, + {ReviewPending: true}, + {IdleBeyond: true}, + {}, + } + for _, in := range inputs { + d := ResolveOpenPRDecision(in) + l := domain.CanonicalSessionLifecycle{ + Session: domain.SessionSubstate{State: d.SessionState, Reason: d.SessionReason}, + PR: domain.PRSubstate{State: d.PRState, Reason: d.PRReason}, + } + if got := domain.DeriveLegacyStatus(l); got != d.Status { + t.Errorf("input %+v: decision Status=%q but DeriveLegacyStatus=%q", in, d.Status, got) + } + } +} + +func TestResolveTerminalPRStateDecision(t *testing.T) { + tests := []struct { + name string + pr domain.PRState + wantStatus domain.SessionStatus + wantState domain.SessionState + wantReason domain.SessionReason + wantPR domain.PRReason + }{ + { + name: "merged parks idle awaiting decision", + pr: domain.PRMerged, + wantStatus: domain.StatusMerged, + wantState: domain.SessionIdle, + wantReason: domain.ReasonMergedWaitingDecision, + wantPR: domain.PRReasonMerged, + }, + { + name: "closed drops to idle", + pr: domain.PRClosed, + wantStatus: domain.StatusIdle, + wantState: domain.SessionIdle, + wantReason: domain.ReasonAwaitingUserInput, + wantPR: domain.PRReasonClosedUnmerged, + }, + { + name: "non-terminal none is a working no-op", + pr: domain.PRNone, + wantStatus: domain.StatusWorking, + wantState: domain.SessionWorking, + wantReason: domain.ReasonTaskInProgress, + }, + { + name: "non-terminal open is a working no-op", + pr: domain.PROpen, + wantStatus: domain.StatusWorking, + wantState: domain.SessionWorking, + wantReason: domain.ReasonTaskInProgress, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveTerminalPRStateDecision(tt.pr) + if got.Status != tt.wantStatus { + t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus) + } + if got.SessionState != tt.wantState { + t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState) + } + if got.SessionReason != tt.wantReason { + t.Errorf("SessionReason = %q, want %q", got.SessionReason, tt.wantReason) + } + if tt.wantPR != "" && got.PRReason != tt.wantPR { + t.Errorf("PRReason = %q, want %q", got.PRReason, tt.wantPR) + } + }) + } +} + +func TestCreateDetectingDecision(t *testing.T) { + const ev = "runtime_lost runtime=missing process=indeterminate" + hash := HashEvidence(ev) + + t.Run("first entry records attempt 1 and stays detecting", func(t *testing.T) { + got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Now: t0}) + if got.Status != domain.StatusDetecting || got.SessionState != domain.SessionDetecting { + t.Fatalf("want detecting, got Status=%q State=%q", got.Status, got.SessionState) + } + if got.Detecting == nil || got.Detecting.Attempts != 1 { + t.Fatalf("want attempts=1, got %+v", got.Detecting) + } + if !got.Detecting.StartedAt.Equal(t0) { + t.Errorf("StartedAt = %v, want %v", got.Detecting.StartedAt, t0) + } + if got.Detecting.EvidenceHash != hash { + t.Errorf("EvidenceHash = %q, want %q", got.Detecting.EvidenceHash, hash) + } + if got.SessionReason != domain.ReasonRuntimeLost { + t.Errorf("SessionReason = %q, want %q", got.SessionReason, domain.ReasonRuntimeLost) + } + }) + + t.Run("unchanged evidence climbs the counter", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash} + got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)}) + if got.Detecting == nil || got.Detecting.Attempts != 2 { + t.Fatalf("want attempts=2, got %+v", got.Detecting) + } + if !got.Detecting.StartedAt.Equal(t0) { + t.Errorf("StartedAt must be preserved, got %v", got.Detecting.StartedAt) + } + }) + + t.Run("escalates to stuck on the third unchanged tick", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: hash} + got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)}) + if got.Status != domain.StatusStuck || got.SessionState != domain.SessionStuck { + t.Fatalf("want stuck, got Status=%q State=%q", got.Status, got.SessionState) + } + if got.Detecting != nil { + t.Errorf("stuck decision must drop detecting memory, got %+v", got.Detecting) + } + if got.SessionReason != domain.ReasonRuntimeLost { + t.Errorf("escalation should carry the why, got %q", got.SessionReason) + } + }) + + t.Run("changing evidence resets the counter but preserves StartedAt", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: hash} + got := CreateDetectingDecision(DetectingInput{Evidence: "different evidence", ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)}) + if got.Status != domain.StatusDetecting { + t.Fatalf("changed evidence should stay detecting, got %q", got.Status) + } + if got.Detecting == nil || got.Detecting.Attempts != 1 { + t.Fatalf("counter should reset to 1, got %+v", got.Detecting) + } + if !got.Detecting.StartedAt.Equal(t0) { + t.Errorf("StartedAt must survive an evidence change, got %v", got.Detecting.StartedAt) + } + }) + + t.Run("duration cap escalates even below the attempt count", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash} + got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(DetectingMaxDuration)}) + if got.Status != domain.StatusStuck { + t.Fatalf("want stuck from duration cap, got %q", got.Status) + } + }) + + t.Run("duration cap fires even when evidence keeps flapping", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash} + got := CreateDetectingDecision(DetectingInput{Evidence: "ever-changing", ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(DetectingMaxDuration + time.Minute)}) + if got.Status != domain.StatusStuck { + t.Fatalf("duration cap must override a reset counter, got %q", got.Status) + } + }) +} + +func TestProbeDetectingEscalationFlow(t *testing.T) { + // An unchanging ambiguous probe should escalate to stuck after exactly + // DetectingMaxAttempts ticks. + in := ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0} + d := ResolveProbeDecision(in) + for i := 1; i < DetectingMaxAttempts; i++ { + if d.Status != domain.StatusDetecting { + t.Fatalf("tick %d: expected detecting, got %q", i, d.Status) + } + in.Prior = d.Detecting + in.Now = t0.Add(time.Duration(i) * time.Second) + d = ResolveProbeDecision(in) + } + if d.Status != domain.StatusStuck { + t.Fatalf("expected escalation to stuck after %d ticks, got %q", DetectingMaxAttempts, d.Status) + } +} + +func TestHashEvidence(t *testing.T) { + t.Run("identical strings hash identically", func(t *testing.T) { + if HashEvidence("same input") != HashEvidence("same input") { + t.Error("identical evidence must hash equal") + } + }) + + t.Run("different evidence hashes differently", func(t *testing.T) { + if HashEvidence("runtime_lost") == HashEvidence("agent_process_exited") { + t.Error("distinct evidence must hash differently") + } + }) + + t.Run("only the timestamp differs -> equal hash", func(t *testing.T) { + a := "probe failed at 2026-05-26T12:00:00Z runtime=missing" + b := "probe failed at 2026-05-26T12:05:43.218Z runtime=missing" + if HashEvidence(a) != HashEvidence(b) { + t.Errorf("restamped evidence should hash equal:\n a=%q\n b=%q", a, b) + } + }) + + t.Run("bare time-of-day stripped", func(t *testing.T) { + if HashEvidence("idle since 12:00:00") != HashEvidence("idle since 13:30:59") { + t.Error("time-of-day differences should be stripped") + } + }) + + t.Run("unix epoch stripped", func(t *testing.T) { + if HashEvidence("last seen 1716724800") != HashEvidence("last seen 1716728400") { + t.Error("epoch differences should be stripped") + } + }) + + t.Run("a real content change still changes the hash", func(t *testing.T) { + a := "probe at 2026-05-26T12:00:00Z runtime=missing" + b := "probe at 2026-05-26T12:00:00Z runtime=alive" + if HashEvidence(a) == HashEvidence(b) { + t.Error("non-timestamp content change must change the hash") + } + }) + + t.Run("whitespace differences are normalised", func(t *testing.T) { + if HashEvidence("runtime=missing process=dead") != HashEvidence("runtime=missing process=dead") { + t.Error("collapsed whitespace should hash equal") + } + }) +} From 9920c6daaadfd069bd973722f8e7b91b642542e6 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 00:06:57 +0530 Subject: [PATCH 008/250] fix(decide): reach mergeable without formal approval; broaden consistency test Address PR review (aa-1): - ResolveOpenPRDecision now keys MERGEABLE on Mergeable alone (checked before Approved). Mergeability is the authoritative merge gate, so a PR on a no-required-review repo no longer falls through to PR_OPEN. The approved+mergeable and approved-only cases are unchanged. - Broaden the derive-consistency test to cover the probe and terminal deciders too, not just the open-PR ladder. - Document the HashEvidence epoch-stripping regex's breadth. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/domain/decide/decide.go | 11 ++++- backend/internal/domain/decide/decide_test.go | 45 ++++++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go index 5862763290..e43ad7e405 100644 --- a/backend/internal/domain/decide/decide.go +++ b/backend/internal/domain/decide/decide.go @@ -166,7 +166,11 @@ func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { return base(domain.StatusCIFailed, domain.PRReasonCIFailing, domain.SessionWorking, domain.ReasonFixingCI) case in.ChangesRequested: return base(domain.StatusChangesRequested, domain.PRReasonChangesRequested, domain.SessionWorking, domain.ReasonResolvingReviewComments) - case in.Approved && in.Mergeable: + case in.Mergeable: + // Mergeability is the authoritative merge gate, so it already folds in + // "approved if review is required". Checking it before Approved means a + // PR on a no-required-review repo (mergeable, not formally approved) is + // still surfaced as ready-to-merge instead of falling through to PR_OPEN. return base(domain.StatusMergeable, domain.PRReasonMergeReady, domain.SessionIdle, domain.ReasonAwaitingExternalReview) case in.Approved: return base(domain.StatusApproved, domain.PRReasonApproved, domain.SessionIdle, domain.ReasonAwaitingExternalReview) @@ -270,6 +274,11 @@ func HashEvidence(evidence string) string { // timestampPatterns strip the time-varying parts of an evidence string before // hashing. Order matters: the full datetime form is removed before the bare // time-of-day and epoch forms so they don't partially match. +// +// The epoch pattern strips any bare 10-13 digit run (unix seconds/millis). That +// is broad enough to also clobber a same-width numeric ID if one ever appears in +// an evidence string; evidence is decider-authored, so today nothing else lands +// in that range, but keep IDs out of evidence strings to preserve hash fidelity. var timestampPatterns = []*regexp.Regexp{ regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?`), regexp.MustCompile(`\d{2}:\d{2}:\d{2}(?:\.\d+)?`), diff --git a/backend/internal/domain/decide/decide_test.go b/backend/internal/domain/decide/decide_test.go index 9d615cdba8..d24497b22c 100644 --- a/backend/internal/domain/decide/decide_test.go +++ b/backend/internal/domain/decide/decide_test.go @@ -182,6 +182,13 @@ func TestResolveOpenPRDecision(t *testing.T) { wantPR: domain.PRReasonMergeReady, wantState: domain.SessionIdle, }, + { + name: "mergeable without formal approval (no required review) -> mergeable", + in: OpenPRInput{Mergeable: true}, + wantStatus: domain.StatusMergeable, + wantPR: domain.PRReasonMergeReady, + wantState: domain.SessionIdle, + }, { name: "approved but not mergeable -> approved", in: OpenPRInput{Approved: true}, @@ -238,26 +245,50 @@ func TestResolveOpenPRDecision(t *testing.T) { } } -func TestResolveOpenPRDecisionDerivesConsistently(t *testing.T) { - // The display Status produced by the ladder must equal what DeriveLegacyStatus - // would produce from the same canonical (session, pr) it emits. - inputs := []OpenPRInput{ +func TestDecidersDeriveConsistently(t *testing.T) { + // Every decision a decider produces must be self-consistent: the display + // Status it reports must equal what DeriveLegacyStatus produces from the + // canonical (session, pr) sub-states it emits. This locks the deciders and + // the display-derivation against drifting apart. + // + // The ResolveTerminalPRStateDecision none/open default is intentionally + // excluded — it is a documented no-op for misuse, not a real verdict. + var decisions []LifecycleDecision + + for _, in := range []OpenPRInput{ {CIFailing: true}, {ChangesRequested: true}, {Approved: true, Mergeable: true}, + {Mergeable: true}, {Approved: true}, {ReviewPending: true}, {IdleBeyond: true}, {}, + } { + decisions = append(decisions, ResolveOpenPRDecision(in)) } - for _, in := range inputs { - d := ResolveOpenPRDecision(in) + + decisions = append(decisions, + ResolveTerminalPRStateDecision(domain.PRMerged), + ResolveTerminalPRStateDecision(domain.PRClosed), + ) + + for _, in := range []ProbeInput{ + {KillRequested: true, Now: t0}, + {Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0}, + {Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0}, + {Runtime: domain.RuntimeExited, Process: ProcessDead, Now: t0}, + } { + decisions = append(decisions, ResolveProbeDecision(in)) + } + + for _, d := range decisions { l := domain.CanonicalSessionLifecycle{ Session: domain.SessionSubstate{State: d.SessionState, Reason: d.SessionReason}, PR: domain.PRSubstate{State: d.PRState, Reason: d.PRReason}, } if got := domain.DeriveLegacyStatus(l); got != d.Status { - t.Errorf("input %+v: decision Status=%q but DeriveLegacyStatus=%q", in, d.Status, got) + t.Errorf("decision %+v: Status=%q but DeriveLegacyStatus=%q", d, d.Status, got) } } } From 4d8a20676ad0a73a7edcc321abc71b265b11a6c3 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 00:25:44 +0530 Subject: [PATCH 009/250] refactor(decide): split type definitions into types.go; clarify detecting helper Address PR review (aa-1): - Move the decider input/output type definitions (LifecycleDecision, ProbeInput, ProcessLiveness, OpenPRInput, DetectingInput) out of the execution file into a dedicated types.go, leaving decide.go for behaviour. - Expand the detecting() helper doc to spell out that it adapts a probe verdict into the shared CreateDetectingDecision anti-flap path so each probe branch doesn't re-implement the quarantine counter. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/domain/decide/decide.go | 67 ++---------------------- backend/internal/domain/decide/types.go | 66 +++++++++++++++++++++++ 2 files changed, 71 insertions(+), 62 deletions(-) create mode 100644 backend/internal/domain/decide/types.go diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go index e43ad7e405..87b91b189c 100644 --- a/backend/internal/domain/decide/decide.go +++ b/backend/internal/domain/decide/decide.go @@ -23,65 +23,6 @@ const ( DetectingMaxDuration = 5 * time.Minute ) -// LifecycleDecision is the output of every decider: the derived display status -// plus the canonical sub-state values to persist, the human-readable evidence, -// and the (possibly updated) detecting memory. -type LifecycleDecision struct { - Status domain.SessionStatus - Evidence string - Detecting *domain.DetectingState - SessionState domain.SessionState - SessionReason domain.SessionReason - PRState domain.PRState - PRReason domain.PRReason -} - -// ProbeInput reconciles runtime + process liveness. A *failed* probe (timeout -// or error) is distinct from a "dead" verdict and must route to detecting, -// never to a death conclusion. KillRequested short-circuits to terminal. -type ProbeInput struct { - Runtime domain.RuntimeState - RuntimeFailed bool - Process ProcessLiveness - ProcessFailed bool - RecentActivity bool - KillRequested bool - Prior *domain.DetectingState - Now time.Time -} - -// ProcessLiveness mirrors isProcessRunning's three-valued answer. -type ProcessLiveness string - -const ( - ProcessAlive ProcessLiveness = "alive" - ProcessDead ProcessLiveness = "dead" - ProcessIndeterminate ProcessLiveness = "indeterminate" -) - -// OpenPRInput drives the PR pipeline ladder for an open PR. -type OpenPRInput struct { - CIFailing bool - ChangesRequested bool - Approved bool - Mergeable bool - ReviewPending bool - IdleBeyond bool // idle past the stuck threshold - Number int - URL string -} - -// DetectingInput feeds the quarantine counter. Evidence is hashed with -// timestamps stripped, so "same ambiguous signal" keeps the counter climbing -// while any real change resets it. -type DetectingInput struct { - Evidence string - ProposedState domain.SessionState - ProposedReason domain.SessionReason - Prior *domain.DetectingState - Now time.Time -} - // ResolveProbeDecision reconciles runtime/process liveness into a decision. // // The ordering encodes the load-bearing invariants: @@ -285,9 +226,11 @@ var timestampPatterns = []*regexp.Regexp{ regexp.MustCompile(`\b\d{10,13}\b`), } -// detecting builds a quarantine decision from a probe verdict, threading the -// prior counter through CreateDetectingDecision so probe ambiguity is subject -// to the same anti-flap escalation as any other detecting cause. +// detecting adapts a probe verdict into the shared anti-flap path. It packages +// the proposed reason + evidence (plus the prior counter from the same probe +// input) into a DetectingInput and defers to CreateDetectingDecision, so every +// probe-driven ambiguity is counted and escalated by the identical quarantine +// logic instead of each probe branch re-implementing the counter. func detecting(in ProbeInput, reason domain.SessionReason, evidence string) LifecycleDecision { return CreateDetectingDecision(DetectingInput{ Evidence: evidence, diff --git a/backend/internal/domain/decide/types.go b/backend/internal/domain/decide/types.go new file mode 100644 index 0000000000..92d50df777 --- /dev/null +++ b/backend/internal/domain/decide/types.go @@ -0,0 +1,66 @@ +package decide + +import ( + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// LifecycleDecision is the output of every decider: the derived display status +// plus the canonical sub-state values to persist, the human-readable evidence, +// and the (possibly updated) detecting memory. +type LifecycleDecision struct { + Status domain.SessionStatus + Evidence string + Detecting *domain.DetectingState + SessionState domain.SessionState + SessionReason domain.SessionReason + PRState domain.PRState + PRReason domain.PRReason +} + +// ProbeInput reconciles runtime + process liveness. A *failed* probe (timeout +// or error) is distinct from a "dead" verdict and must route to detecting, +// never to a death conclusion. KillRequested short-circuits to terminal. +type ProbeInput struct { + Runtime domain.RuntimeState + RuntimeFailed bool + Process ProcessLiveness + ProcessFailed bool + RecentActivity bool + KillRequested bool + Prior *domain.DetectingState + Now time.Time +} + +// ProcessLiveness mirrors isProcessRunning's three-valued answer. +type ProcessLiveness string + +const ( + ProcessAlive ProcessLiveness = "alive" + ProcessDead ProcessLiveness = "dead" + ProcessIndeterminate ProcessLiveness = "indeterminate" +) + +// OpenPRInput drives the PR pipeline ladder for an open PR. +type OpenPRInput struct { + CIFailing bool + ChangesRequested bool + Approved bool + Mergeable bool + ReviewPending bool + IdleBeyond bool // idle past the stuck threshold + Number int + URL string +} + +// DetectingInput feeds the quarantine counter. Evidence is hashed with +// timestamps stripped, so "same ambiguous signal" keeps the counter climbing +// while any real change resets it. +type DetectingInput struct { + Evidence string + ProposedState domain.SessionState + ProposedReason domain.SessionReason + Prior *domain.DetectingState + Now time.Time +} From fbfbcd5f1b5f8f60d14677376101151910573a51 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 00:26:33 +0530 Subject: [PATCH 010/250] docs(decide): document each timestampPatterns regex with examples Clarify the timestamp-stripping block flagged in review: spell out what each of the three regexes matches (full ISO/RFC3339 datetime, bare time-of-day, bare unix epoch) and why order matters. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/domain/decide/decide.go | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go index 87b91b189c..f1b948ac9c 100644 --- a/backend/internal/domain/decide/decide.go +++ b/backend/internal/domain/decide/decide.go @@ -212,14 +212,22 @@ func HashEvidence(evidence string) string { return hex.EncodeToString(sum[:]) } -// timestampPatterns strip the time-varying parts of an evidence string before -// hashing. Order matters: the full datetime form is removed before the bare -// time-of-day and epoch forms so they don't partially match. +// timestampPatterns is the list of regexes HashEvidence applies (in order) to +// delete the time-varying parts of an evidence string before hashing, so the +// same ambiguous signal restamped with a new clock value hashes equal and the +// detecting counter keeps climbing instead of resetting every tick. // -// The epoch pattern strips any bare 10-13 digit run (unix seconds/millis). That -// is broad enough to also clobber a same-width numeric ID if one ever appears in -// an evidence string; evidence is decider-authored, so today nothing else lands -// in that range, but keep IDs out of evidence strings to preserve hash fidelity. +// Order matters: the full datetime form is removed first so its embedded +// HH:MM:SS isn't half-eaten by the bare time-of-day pattern that follows. +// +// 1. full ISO-8601 / RFC3339 datetime — date, a T or space separator, +// HH:MM:SS, optional fractional seconds, optional Z or ±HH:MM offset. +// e.g. "2026-05-26T12:00:00Z", "2026-05-26 12:00:00.218+05:30" +// 2. a bare time-of-day, e.g. "12:00:00" or "12:00:00.218" +// 3. a bare unix epoch — any 10-13 digit run (seconds or millis), e.g. +// "1716724800". This is broad enough to also clobber a same-width numeric +// ID if one ever appears in evidence; evidence is decider-authored, so keep +// IDs out of evidence strings to preserve hash fidelity. var timestampPatterns = []*regexp.Regexp{ regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?`), regexp.MustCompile(`\d{2}:\d{2}:\d{2}(?:\.\d+)?`), From cdfcdd2def397a9f274c2b3f3168f6d7b6aadf8e Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 00:26:46 +0530 Subject: [PATCH 011/250] style(decide): gofmt the timestampPatterns doc block Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/domain/decide/decide.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go index f1b948ac9c..8beb133a72 100644 --- a/backend/internal/domain/decide/decide.go +++ b/backend/internal/domain/decide/decide.go @@ -220,14 +220,14 @@ func HashEvidence(evidence string) string { // Order matters: the full datetime form is removed first so its embedded // HH:MM:SS isn't half-eaten by the bare time-of-day pattern that follows. // -// 1. full ISO-8601 / RFC3339 datetime — date, a T or space separator, -// HH:MM:SS, optional fractional seconds, optional Z or ±HH:MM offset. -// e.g. "2026-05-26T12:00:00Z", "2026-05-26 12:00:00.218+05:30" -// 2. a bare time-of-day, e.g. "12:00:00" or "12:00:00.218" -// 3. a bare unix epoch — any 10-13 digit run (seconds or millis), e.g. -// "1716724800". This is broad enough to also clobber a same-width numeric -// ID if one ever appears in evidence; evidence is decider-authored, so keep -// IDs out of evidence strings to preserve hash fidelity. +// 1. full ISO-8601 / RFC3339 datetime — date, a T or space separator, +// HH:MM:SS, optional fractional seconds, optional Z or ±HH:MM offset. +// e.g. "2026-05-26T12:00:00Z", "2026-05-26 12:00:00.218+05:30" +// 2. a bare time-of-day, e.g. "12:00:00" or "12:00:00.218" +// 3. a bare unix epoch — any 10-13 digit run (seconds or millis), e.g. +// "1716724800". This is broad enough to also clobber a same-width numeric +// ID if one ever appears in evidence; evidence is decider-authored, so keep +// IDs out of evidence strings to preserve hash fidelity. var timestampPatterns = []*regexp.Regexp{ regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?`), regexp.MustCompile(`\d{2}:\d{2}:\d{2}(?:\.\d+)?`), From 918c5b4b3ab037759c24de8c3f07315bdf4af5d4 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 00:38:34 +0530 Subject: [PATCH 012/250] feat(decide): populate open-PR Evidence; document decision zero-value contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review: - ResolveOpenPRDecision now sets a stable, timestamp-free Evidence summary " # " for every ladder outcome, consuming the previously-unused OpenPRInput.Number/URL identity inputs and making PR decisions traceable in logs. Covered by TestResolveOpenPRDecisionEvidence. - Document LifecycleDecision's zero-value contract: an empty PRState/PRReason means "this decider does not address PR — leave unchanged", not PRNone. The LCM must map empty PR fields to a nil LifecyclePatch.PR; writing PRNone on a probe tick would clobber a live PR. (Pointers were considered but the empty sentinel is distinguishable from every valid state and consistent with the codebase's value-enum style; LifecyclePatch already owns nil-means-unchanged.) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/domain/decide/decide.go | 29 ++++++++++++----- backend/internal/domain/decide/decide_test.go | 31 +++++++++++++++++++ backend/internal/domain/decide/types.go | 10 ++++++ 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go index 8beb133a72..e7f2c44572 100644 --- a/backend/internal/domain/decide/decide.go +++ b/backend/internal/domain/decide/decide.go @@ -92,9 +92,22 @@ func ResolveProbeDecision(in ProbeInput) LifecycleDecision { // everything, then requested changes, then the approval/merge states, then a // pending review, then a stalled (idle-beyond-threshold) PR, else plain open. func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { - base := func(status domain.SessionStatus, prReason domain.PRReason, ss domain.SessionState, sr domain.SessionReason) LifecycleDecision { + // evidence is a stable, timestamp-free summary " # " + // for logs/traceability; it folds in the PR identity inputs (Number/URL). + evidence := func(cond string) string { + s := cond + if in.Number > 0 { + s += fmt.Sprintf(" #%d", in.Number) + } + if in.URL != "" { + s += " " + in.URL + } + return s + } + base := func(status domain.SessionStatus, cond string, prReason domain.PRReason, ss domain.SessionState, sr domain.SessionReason) LifecycleDecision { return LifecycleDecision{ Status: status, + Evidence: evidence(cond), SessionState: ss, SessionReason: sr, PRState: domain.PROpen, @@ -104,24 +117,24 @@ func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { switch { case in.CIFailing: - return base(domain.StatusCIFailed, domain.PRReasonCIFailing, domain.SessionWorking, domain.ReasonFixingCI) + return base(domain.StatusCIFailed, "ci_failing", domain.PRReasonCIFailing, domain.SessionWorking, domain.ReasonFixingCI) case in.ChangesRequested: - return base(domain.StatusChangesRequested, domain.PRReasonChangesRequested, domain.SessionWorking, domain.ReasonResolvingReviewComments) + return base(domain.StatusChangesRequested, "changes_requested", domain.PRReasonChangesRequested, domain.SessionWorking, domain.ReasonResolvingReviewComments) case in.Mergeable: // Mergeability is the authoritative merge gate, so it already folds in // "approved if review is required". Checking it before Approved means a // PR on a no-required-review repo (mergeable, not formally approved) is // still surfaced as ready-to-merge instead of falling through to PR_OPEN. - return base(domain.StatusMergeable, domain.PRReasonMergeReady, domain.SessionIdle, domain.ReasonAwaitingExternalReview) + return base(domain.StatusMergeable, "merge_ready", domain.PRReasonMergeReady, domain.SessionIdle, domain.ReasonAwaitingExternalReview) case in.Approved: - return base(domain.StatusApproved, domain.PRReasonApproved, domain.SessionIdle, domain.ReasonAwaitingExternalReview) + return base(domain.StatusApproved, "approved", domain.PRReasonApproved, domain.SessionIdle, domain.ReasonAwaitingExternalReview) case in.ReviewPending: - return base(domain.StatusReviewPending, domain.PRReasonReviewPending, domain.SessionIdle, domain.ReasonAwaitingExternalReview) + return base(domain.StatusReviewPending, "review_pending", domain.PRReasonReviewPending, domain.SessionIdle, domain.ReasonAwaitingExternalReview) case in.IdleBeyond: // A PR open but quiet past the stuck threshold needs a human nudge. - return base(domain.StatusStuck, domain.PRReasonInProgress, domain.SessionStuck, domain.ReasonAwaitingUserInput) + return base(domain.StatusStuck, "idle_beyond", domain.PRReasonInProgress, domain.SessionStuck, domain.ReasonAwaitingUserInput) default: - return base(domain.StatusPROpen, domain.PRReasonInProgress, domain.SessionWorking, domain.ReasonPRCreated) + return base(domain.StatusPROpen, "pr_open", domain.PRReasonInProgress, domain.SessionWorking, domain.ReasonPRCreated) } } diff --git a/backend/internal/domain/decide/decide_test.go b/backend/internal/domain/decide/decide_test.go index d24497b22c..d6e027f1e9 100644 --- a/backend/internal/domain/decide/decide_test.go +++ b/backend/internal/domain/decide/decide_test.go @@ -245,6 +245,37 @@ func TestResolveOpenPRDecision(t *testing.T) { } } +func TestResolveOpenPRDecisionEvidence(t *testing.T) { + tests := []struct { + name string + in OpenPRInput + want string + }{ + { + name: "condition with PR number and URL", + in: OpenPRInput{CIFailing: true, Number: 123, URL: "https://example.com/pr/123"}, + want: "ci_failing #123 https://example.com/pr/123", + }, + { + name: "condition with number only", + in: OpenPRInput{Approved: true, Mergeable: true, Number: 7}, + want: "merge_ready #7", + }, + { + name: "no identity falls back to the bare condition", + in: OpenPRInput{}, + want: "pr_open", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ResolveOpenPRDecision(tt.in).Evidence; got != tt.want { + t.Errorf("Evidence = %q, want %q", got, tt.want) + } + }) + } +} + func TestDecidersDeriveConsistently(t *testing.T) { // Every decision a decider produces must be self-consistent: the display // Status it reports must equal what DeriveLegacyStatus produces from the diff --git a/backend/internal/domain/decide/types.go b/backend/internal/domain/decide/types.go index 92d50df777..7ac4adf1d4 100644 --- a/backend/internal/domain/decide/types.go +++ b/backend/internal/domain/decide/types.go @@ -9,6 +9,16 @@ import ( // LifecycleDecision is the output of every decider: the derived display status // plus the canonical sub-state values to persist, the human-readable evidence, // and the (possibly updated) detecting memory. +// +// Zero-value sub-state fields mean "this decider does not address that +// sub-state — leave it unchanged", NOT "set it to the empty value". SessionState +// is always populated, but the probe/detecting/kill paths legitimately leave +// PRState/PRReason empty: a liveness verdict knows nothing about the PR. When +// the LCM turns a decision into a LifecyclePatch it must therefore map an empty +// PRState to a nil patch.PR (left untouched) rather than writing it through — +// writing PRNone on a routine probe tick would clobber a live PR. Detecting is +// nil-by-default for the same reason; see LifecyclePatch's three-way +// Detecting/ClearDetecting semantics. type LifecycleDecision struct { Status domain.SessionStatus Evidence string From 1420bb9493d5962ded7fc78b639497a495c487c8 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 01:26:15 +0530 Subject: [PATCH 013/250] feat(lifecycle): implement LCM Apply* pipeline (split A) Implements ports.LifecycleManager as a synchronous observe->decide->persist reducer. Every entrypoint runs the shared pipeline under a per-session lock: load canonical -> run the matching pure decider -> diff into a sparse merge-patch -> persist. Never polls, never writes the display status. - ApplyRuntimeObservation -> probe decider; always writes the runtime axis. - ApplySCMObservation -> open-PR / terminal-PR deciders (failed fetch is a no-op: failed probe != "no PR"). Open PRs write only the PR axis. - ApplyActivitySignal -> updates the activity axis + maps onto the session axis; only valid-confidence signals are authoritative. - OnSpawnCompleted -> runtime alive + handles to metadata; session stays not_started (display: spawning). - OnKillRequested -> SM's explicit terminal-write authority. - TickEscalations -> no-op stub (reaction/escalation engine is split B). Composition rule (#1): liveness owns the runtime + death axis; activity owns the working/idle/waiting axis. A healthy probe verdict writes the session axis only to recover a liveness-owned state, so it never clobbers an activity-owned needs_input/blocked. Activity is the mirror: it stays off the death axis. Detecting clear (#3): a non-detecting probe verdict clears stale detecting memory so the next probe reads no phantom Prior. Built/tested against in-memory fakes (LifecycleStore with full merge-patch + ExpectedRevision, recording Notifier/AgentMessenger). Per-session serialisation verified under -race. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/lifecycle/decide_bridge.go | 204 ++++++++++ backend/internal/lifecycle/fakes_test.go | 161 ++++++++ backend/internal/lifecycle/manager.go | 327 +++++++++++++++++ backend/internal/lifecycle/manager_test.go | 388 ++++++++++++++++++++ 4 files changed, 1080 insertions(+) create mode 100644 backend/internal/lifecycle/decide_bridge.go create mode 100644 backend/internal/lifecycle/fakes_test.go create mode 100644 backend/internal/lifecycle/manager.go create mode 100644 backend/internal/lifecycle/manager_test.go diff --git a/backend/internal/lifecycle/decide_bridge.go b/backend/internal/lifecycle/decide_bridge.go new file mode 100644 index 0000000000..059ac2eb6d --- /dev/null +++ b/backend/internal/lifecycle/decide_bridge.go @@ -0,0 +1,204 @@ +package lifecycle + +import ( + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/domain/decide" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// defaultRecentActivityWindow is how fresh the last activity signal must be for +// the probe decider to treat the agent as "recently active" (which keeps an +// ambiguous dead-runtime probe in detecting instead of concluding death). +const defaultRecentActivityWindow = 60 * time.Second + +// ---- fact translation: ports DTOs -> pure decide inputs ---- + +// runtimeFactsToProbeInput maps a raw RuntimeFacts (plus the prior detecting +// memory and last-known activity read back from canonical) into the probe +// decider's input. KillRequested is always false here: the inferred-death path +// never carries an explicit kill — that arrives via OnKillRequested. +func runtimeFactsToProbeInput(f ports.RuntimeFacts, cur domain.CanonicalSessionLifecycle, window time.Duration) decide.ProbeInput { + rt, rtFailed := runtimeProbeToState(f.RuntimeState) + proc, procFailed := processProbeToLiveness(f.ProcessState) + now := nowOr(f.ObservedAt) + return decide.ProbeInput{ + Runtime: rt, + RuntimeFailed: rtFailed, + Process: proc, + ProcessFailed: procFailed, + RecentActivity: hasRecentActivity(cur.Activity, now, window), + Prior: cur.Detecting, + Now: now, + } +} + +func runtimeProbeToState(p ports.RuntimeProbe) (domain.RuntimeState, bool) { + switch p { + case ports.RuntimeProbeAlive: + return domain.RuntimeAlive, false + case ports.RuntimeProbeDead: + return domain.RuntimeExited, false + case ports.RuntimeProbeFailed: + return domain.RuntimeProbeFailed, true + default: // indeterminate / unset: ambiguous, never a death conclusion + return domain.RuntimeUnknown, false + } +} + +func processProbeToLiveness(p ports.ProcessProbe) (decide.ProcessLiveness, bool) { + switch p { + case ports.ProcessProbeAlive: + return decide.ProcessAlive, false + case ports.ProcessProbeDead: + return decide.ProcessDead, false + case ports.ProcessProbeFailed: + return decide.ProcessIndeterminate, true + default: // indeterminate / unset + return decide.ProcessIndeterminate, false + } +} + +// runtimeSubstateFromFacts derives the runtime sub-state to persist. Liveness +// always owns this axis, so it is written on every runtime observation +// regardless of what the session axis does. +func runtimeSubstateFromFacts(f ports.RuntimeFacts) domain.RuntimeSubstate { + switch f.RuntimeState { + case ports.RuntimeProbeAlive: + return domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning} + case ports.RuntimeProbeDead: + return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonTmuxMissing} + case ports.RuntimeProbeFailed: + return domain.RuntimeSubstate{State: domain.RuntimeProbeFailed, Reason: domain.RuntimeReasonProbeError} + default: + return domain.RuntimeSubstate{State: domain.RuntimeUnknown, Reason: domain.RuntimeReasonProbeError} + } +} + +// hasRecentActivity answers the probe decider's "was the agent heard from +// recently?" question. Sticky states (waiting_input/blocked) count as recent +// because they mean a live-but-paused agent; an explicit exited signal never +// counts; otherwise we age the last-activity timestamp against the window. +func hasRecentActivity(a domain.ActivitySubstate, now time.Time, window time.Duration) bool { + if a.State == domain.ActivityExited { + return false + } + if a.State.IsSticky() { + return true + } + if a.LastActivityAt.IsZero() { + return false + } + return now.Sub(a.LastActivityAt) <= window +} + +// openPRInput maps SCM facts onto the open-PR ladder. IdleBeyond is always false +// in split A — the idle-duration signal is owned by the escalation engine +// (split B); the synchronous LCM has no clock of its own here. +func openPRInput(f ports.SCMFacts) decide.OpenPRInput { + return decide.OpenPRInput{ + CIFailing: f.CISummary == ports.CIFailing, + ChangesRequested: f.ReviewDecision == ports.ReviewChangesRequested, + Approved: f.ReviewDecision == ports.ReviewApproved, + Mergeable: f.Mergeability.Mergeable, + ReviewPending: f.ReviewDecision == ports.ReviewPending, + Number: f.PRNumber, + URL: f.PRURL, + } +} + +// ---- activity -> session axis mapping (activity owns working/idle/waiting) ---- + +// activityToSession maps an activity classification onto the session sub-state. +// exited returns ok=false: an exit signal must NOT write a terminal session +// state — only the probe pipeline (via detecting) may conclude inferred death. +func activityToSession(a domain.ActivityState) (domain.SessionState, domain.SessionReason, bool) { + switch a { + case domain.ActivityActive: + return domain.SessionWorking, domain.ReasonTaskInProgress, true + case domain.ActivityReady, domain.ActivityIdle: + return domain.SessionIdle, domain.ReasonResearchComplete, true + case domain.ActivityWaitingInput: + return domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, true + case domain.ActivityBlocked: + return domain.SessionStuck, domain.ReasonAwaitingUserInput, true + default: // exited / unset + return "", "", false + } +} + +// ---- composition predicates: who may write the session axis ---- + +// isTerminal reports a final session state that must not be resurrected by an +// observation (only an explicit Restore reopens a terminal session). +func isTerminal(s domain.SessionState) bool { + return s == domain.SessionDone || s == domain.SessionTerminated +} + +// isLivenessOwned reports whether the current session sub-state was set by the +// liveness/death axis (the probe pipeline) and may therefore be recovered by a +// later healthy probe. detecting is always liveness-owned; a stuck/terminated +// state is liveness-owned only when its reason came from a death inference. +func isLivenessOwned(s domain.SessionSubstate) bool { + if s.State == domain.SessionDetecting { + return true + } + switch s.Reason { + case domain.ReasonRuntimeLost, domain.ReasonAgentProcessExited, domain.ReasonProbeFailure: + return true + } + return false +} + +// shouldWriteSessionRuntime is the #1 composition rule for ApplyRuntimeObservation. +// A death-axis verdict (detecting/stuck/terminal) always writes — it overrides +// activity because a (maybe) dead agent can't be working/waiting. A healthy +// "working" verdict only writes when it is recovering a liveness-owned state +// (e.g. detecting -> working); it must NOT clobber an activity-owned +// needs_input/blocked/idle the activity axis is responsible for. +func shouldWriteSessionRuntime(d decide.LifecycleDecision, cur domain.CanonicalSessionLifecycle) bool { + if d.SessionState == domain.SessionWorking { + return !isTerminal(cur.Session.State) && isLivenessOwned(cur.Session) + } + return true +} + +// shouldWriteSessionActivity is the mirror rule for ApplyActivitySignal: the +// activity axis owns working/idle/waiting, but it must not touch the death axis. +// It writes unless the session is terminal or currently liveness-owned (let the +// probe pipeline resolve detecting / death-inferred states instead). +func shouldWriteSessionActivity(cur domain.CanonicalSessionLifecycle) bool { + return !isTerminal(cur.Session.State) && !isLivenessOwned(cur.Session) +} + +// ---- explicit-kill mapping (SM's terminal-write authority) ---- + +func killSession(k ports.LifecycleKillReason) domain.SessionSubstate { + switch k { + case ports.KillManual: + return domain.SessionSubstate{State: domain.SessionTerminated, Reason: domain.ReasonManuallyKilled} + case ports.KillCleanup: + return domain.SessionSubstate{State: domain.SessionTerminated, Reason: domain.ReasonAutoCleanup} + default: // error + return domain.SessionSubstate{State: domain.SessionTerminated, Reason: domain.ReasonErrorInProcess} + } +} + +func killRuntime(k ports.LifecycleKillReason) domain.RuntimeSubstate { + switch k { + case ports.KillManual: + return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonManualKillRequested} + case ports.KillCleanup: + return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonAutoCleanup} + default: // error + return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonProbeError} + } +} + +func nowOr(t time.Time) time.Time { + if t.IsZero() { + return time.Now() + } + return t +} diff --git a/backend/internal/lifecycle/fakes_test.go b/backend/internal/lifecycle/fakes_test.go new file mode 100644 index 0000000000..904693aa44 --- /dev/null +++ b/backend/internal/lifecycle/fakes_test.go @@ -0,0 +1,161 @@ +package lifecycle + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// fakeStore is an in-memory LifecycleStore that faithfully applies merge-patch +// semantics (sparse field writes, the three-way Detecting/ClearDetecting rule, +// ExpectedRevision optimistic-concurrency check, monotonic Revision bump) so +// tests assert against the real persisted canonical. +type fakeStore struct { + mu sync.Mutex + records map[domain.SessionID]*domain.SessionRecord + metadata map[domain.SessionID]map[string]string +} + +var _ ports.LifecycleStore = (*fakeStore)(nil) + +func newFakeStore() *fakeStore { + return &fakeStore{ + records: map[domain.SessionID]*domain.SessionRecord{}, + metadata: map[domain.SessionID]map[string]string{}, + } +} + +// seed installs a starting lifecycle for a session id (bypassing the patch path). +func (s *fakeStore) seed(id domain.SessionID, l domain.CanonicalSessionLifecycle) { + s.mu.Lock() + defer s.mu.Unlock() + if l.Version == 0 { + l.Version = domain.LifecycleVersion + } + s.records[id] = &domain.SessionRecord{ID: id, Lifecycle: l} +} + +func (s *fakeStore) Load(_ context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + rec, ok := s.records[id] + if !ok { + return domain.CanonicalSessionLifecycle{}, false, nil + } + return rec.Lifecycle, true, nil +} + +func (s *fakeStore) PatchLifecycle(_ context.Context, id domain.SessionID, p ports.LifecyclePatch) error { + s.mu.Lock() + defer s.mu.Unlock() + + rec, ok := s.records[id] + if !ok { + rec = &domain.SessionRecord{ID: id, Lifecycle: domain.CanonicalSessionLifecycle{Version: domain.LifecycleVersion}} + s.records[id] = rec + } + l := &rec.Lifecycle + + if p.ExpectedRevision != nil && *p.ExpectedRevision != l.Revision { + return fmt.Errorf("revision mismatch for %s: have %d, expected %d", id, l.Revision, *p.ExpectedRevision) + } + + if p.Session != nil { + l.Session = *p.Session + } + if p.PR != nil { + l.PR = *p.PR + } + if p.Runtime != nil { + l.Runtime = *p.Runtime + } + if p.Activity != nil { + l.Activity = *p.Activity + } + switch { + case p.ClearDetecting: + l.Detecting = nil + case p.Detecting != nil: + d := *p.Detecting + l.Detecting = &d + } + + l.Version = domain.LifecycleVersion + l.Revision++ + rec.UpdatedAt = time.Now() + return nil +} + +func (s *fakeStore) List(_ context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []domain.SessionRecord + for _, rec := range s.records { + if rec.ProjectID == project { + out = append(out, *rec) + } + } + return out, nil +} + +func (s *fakeStore) GetMetadata(_ context.Context, id domain.SessionID) (map[string]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := map[string]string{} + for k, v := range s.metadata[id] { + out[k] = v + } + return out, nil +} + +func (s *fakeStore) PatchMetadata(_ context.Context, id domain.SessionID, kv map[string]string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.metadata[id] == nil { + s.metadata[id] = map[string]string{} + } + for k, v := range kv { + s.metadata[id][k] = v + } + return nil +} + +// recordingNotifier captures emitted events for assertions. +type recordingNotifier struct { + mu sync.Mutex + events []ports.OrchestratorEvent +} + +var _ ports.Notifier = (*recordingNotifier)(nil) + +func (n *recordingNotifier) Notify(_ context.Context, e ports.OrchestratorEvent) error { + n.mu.Lock() + defer n.mu.Unlock() + n.events = append(n.events, e) + return nil +} + +// recordingMessenger captures messages injected into agents. +type recordingMessenger struct { + mu sync.Mutex + sent []struct { + ID domain.SessionID + Message string + } +} + +var _ ports.AgentMessenger = (*recordingMessenger)(nil) + +func (a *recordingMessenger) Send(_ context.Context, id domain.SessionID, message string) error { + a.mu.Lock() + defer a.mu.Unlock() + a.sent = append(a.sent, struct { + ID domain.SessionID + Message string + }{id, message}) + return nil +} diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go new file mode 100644 index 0000000000..55a594bb44 --- /dev/null +++ b/backend/internal/lifecycle/manager.go @@ -0,0 +1,327 @@ +// Package lifecycle implements ports.LifecycleManager: the synchronous +// observe->decide->persist reducer. Every Apply*/On* entrypoint runs the same +// pipeline under a per-session lock — load canonical, run the matching pure +// decider, diff the result into a sparse merge-patch, persist. The LCM never +// polls and never writes the display status (that is derived on read). +// +// Split A scope is the Apply* pipeline only. The reaction table + escalation +// engine (ACT) and the Session Manager land in later splits; TickEscalations is +// a documented no-op here. +package lifecycle + +import ( + "context" + "sync" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/domain/decide" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Metadata keys OnSpawnCompleted records for the spawned session's handles. +const ( + MetaBranch = "branch" + MetaWorkspacePath = "workspacePath" + MetaRuntimeHandleID = "runtimeHandleId" + MetaRuntimeName = "runtimeName" + MetaAgentSessionID = "agentSessionId" +) + +// Manager is the LCM. Notifier/AgentMessenger are held for the ACT lane (split +// B); the Apply* pipeline does not fire reactions yet. +type Manager struct { + store ports.LifecycleStore + notifier ports.Notifier + messenger ports.AgentMessenger + + recentActivityWindow time.Duration + locks keyedMutex +} + +var _ ports.LifecycleManager = (*Manager)(nil) + +func New(store ports.LifecycleStore, notifier ports.Notifier, messenger ports.AgentMessenger) *Manager { + return &Manager{ + store: store, + notifier: notifier, + messenger: messenger, + recentActivityWindow: defaultRecentActivityWindow, + } +} + +// ---- per-session serialisation ---- + +// keyedMutex hands out one lock per session id so the load->decide->persist +// read-modify-write is serial within a session but parallel across sessions. +type keyedMutex struct { + mu sync.Mutex + locks map[domain.SessionID]*sync.Mutex +} + +func (k *keyedMutex) lock(id domain.SessionID) func() { + k.mu.Lock() + if k.locks == nil { + k.locks = make(map[domain.SessionID]*sync.Mutex) + } + m, ok := k.locks[id] + if !ok { + m = &sync.Mutex{} + k.locks[id] = m + } + k.mu.Unlock() + + m.Lock() + return m.Unlock +} + +func (m *Manager) withLock(id domain.SessionID, fn func() error) error { + unlock := m.locks.lock(id) + defer unlock() + return fn() +} + +// mutate runs the shared pipeline: load -> build patch -> persist (only if the +// patch changed something). decideFn returns the diffed patch and whether it +// touches anything; a false "changed" is a clean no-op (no write, no revision +// bump), which is how failed-probe / unknown-fact inputs are dropped. +func (m *Manager) mutate( + ctx context.Context, + id domain.SessionID, + decideFn func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error), +) error { + return m.withLock(id, func() error { + cur, exists, err := m.store.Load(ctx, id) + if err != nil { + return err + } + patch, changed, err := decideFn(cur, exists) + if err != nil { + return err + } + if !changed { + return nil + } + return m.store.PatchLifecycle(ctx, id, patch) + }) +} + +// ---- OBSERVE entrypoints ---- + +// ApplyRuntimeObservation feeds the probe decider. Liveness always writes the +// runtime axis; the session axis follows the #1 composition rule; and a +// non-detecting verdict clears any stale detecting memory (#3) so the next +// probe doesn't read a phantom prior. +func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error { + return m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + if !exists { + return ports.LifecyclePatch{}, false, nil // nothing seeded; ignore stray probe + } + + d := decide.ResolveProbeDecision(runtimeFactsToProbeInput(f, cur, m.recentActivityWindow)) + + var patch ports.LifecyclePatch + changed := false + + if rt := runtimeSubstateFromFacts(f); cur.Runtime != rt { + patch.Runtime = &rt + changed = true + } + if shouldWriteSessionRuntime(d, cur) { + changed = setSessionIfChanged(&patch, cur, d.SessionState, d.SessionReason) || changed + } + changed = setDetecting(&patch, cur, d.Detecting) || changed + + return patch, changed, nil + }) +} + +// ApplySCMObservation maps PR facts onto the PR axis. A failed fetch is dropped +// (failed probe != "no PR"). An open PR writes only the PR sub-state — the +// session axis stays owned by activity, and DeriveLegacyStatus surfaces the PR +// reason for display. A terminal PR (merged/closed) also parks the session. +func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, f ports.SCMFacts) error { + return m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + if !exists || !f.Fetched { + return ports.LifecyclePatch{}, false, nil + } + + switch f.PRState { + case domain.PROpen: + d := decide.ResolveOpenPRDecision(openPRInput(f)) + var patch ports.LifecyclePatch + changed := setPRIfChanged(&patch, cur, d, f) + return patch, changed, nil + + case domain.PRMerged, domain.PRClosed: + d := decide.ResolveTerminalPRStateDecision(f.PRState) + var patch ports.LifecyclePatch + changed := setPRIfChanged(&patch, cur, d, f) + if !isTerminal(cur.Session.State) { + changed = setSessionIfChanged(&patch, cur, d.SessionState, d.SessionReason) || changed + } + return patch, changed, nil + + default: // none / unset: no PR-driven transition in split A + return ports.LifecyclePatch{}, false, nil + } + }) +} + +// ApplyActivitySignal updates the activity axis. Only a valid-confidence signal +// is authoritative (stale/unavailable/probe_failure != idleness). It refreshes +// the persisted activity sub-state (the probe decider's RecentActivity input) +// and maps the classification onto the session axis, subject to the mirror +// composition rule that keeps activity off the death axis. +func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error { + return m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + if !exists || s.State != ports.SignalValid { + return ports.LifecyclePatch{}, false, nil + } + + var patch ports.LifecyclePatch + changed := false + + act := domain.ActivitySubstate{State: s.Activity, LastActivityAt: nowOr(s.Timestamp), Source: s.Source} + if !sameActivity(cur.Activity, act) { + patch.Activity = &act + changed = true + } + if st, rs, ok := activityToSession(s.Activity); ok && shouldWriteSessionActivity(cur) { + changed = setSessionIfChanged(&patch, cur, st, rs) || changed + } + + return patch, changed, nil + }) +} + +// ---- mutation outcomes reported by the Session Manager ---- + +// OnSpawnCompleted records that a spawn finished: the runtime is up and the +// handles are known. Per the agreed rule it flips the runtime axis to alive and +// stores the handles in metadata, but leaves the session at not_started +// (display: spawning) — the agent "acknowledges" via the first activity signal. +func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error { + return m.withLock(id, func() error { + cur, _, err := m.store.Load(ctx, id) + if err != nil { + return err + } + rt := domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning} + if cur.Runtime != rt { + if err := m.store.PatchLifecycle(ctx, id, ports.LifecyclePatch{Runtime: &rt}); err != nil { + return err + } + } + if meta := spawnMetadata(o); len(meta) > 0 { + if err := m.store.PatchMetadata(ctx, id, meta); err != nil { + return err + } + } + return nil + }) +} + +// OnKillRequested is the SM's explicit terminal-write authority (the one +// terminal path that does not go through the inferred-death decider). It writes +// the terminal session/runtime sub-states for the kill kind and clears any +// in-flight detecting memory. +func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, r ports.KillReason) error { + return m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + var patch ports.LifecyclePatch + changed := false + + if sess := killSession(r.Kind); cur.Session != sess { + patch.Session = &sess + changed = true + } + if rt := killRuntime(r.Kind); cur.Runtime != rt { + patch.Runtime = &rt + changed = true + } + if cur.Detecting != nil { + patch.ClearDetecting = true + changed = true + } + return patch, changed, nil + }) +} + +// TickEscalations is a no-op in split A. The reaper will call this to fire +// duration-based escalations the synchronous LCM can't wake itself for, but the +// reaction table + escalation engine that back it land in split B. +func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { + return nil +} + +// ---- patch helpers (diff -> sparse merge-patch) ---- + +// setSessionIfChanged sets patch.Session only when the decided sub-state +// differs from current; an empty decided state means "decider does not address +// the session axis" and is left untouched. +func setSessionIfChanged(patch *ports.LifecyclePatch, cur domain.CanonicalSessionLifecycle, st domain.SessionState, rs domain.SessionReason) bool { + if st == "" { + return false + } + want := domain.SessionSubstate{State: st, Reason: rs} + if cur.Session == want { + return false + } + patch.Session = &want + return true +} + +// setPRIfChanged folds the decided PR sub-state plus the fact-borne PR identity +// (number/url) into the patch when it differs from current. +func setPRIfChanged(patch *ports.LifecyclePatch, cur domain.CanonicalSessionLifecycle, d decide.LifecycleDecision, f ports.SCMFacts) bool { + want := domain.PRSubstate{State: d.PRState, Reason: d.PRReason, Number: f.PRNumber, URL: f.PRURL} + if cur.PR == want { + return false + } + patch.PR = &want + return true +} + +// setDetecting implements the three-way detecting semantics: set/replace when +// the decision carries memory, clear (#3) when it doesn't but canonical still +// holds stale memory, else leave untouched. +func setDetecting(patch *ports.LifecyclePatch, cur domain.CanonicalSessionLifecycle, d *domain.DetectingState) bool { + if d != nil { + if cur.Detecting != nil && *cur.Detecting == *d { + return false + } + patch.Detecting = d + return true + } + if cur.Detecting != nil { + patch.ClearDetecting = true + return true + } + return false +} + +// sameActivity compares activity sub-states with time-aware equality (== on +// time.Time is monotonic-clock sensitive and would spuriously report changes). +func sameActivity(a, b domain.ActivitySubstate) bool { + return a.State == b.State && a.Source == b.Source && a.LastActivityAt.Equal(b.LastActivityAt) +} + +func spawnMetadata(o ports.SpawnOutcome) map[string]string { + meta := map[string]string{} + if o.Branch != "" { + meta[MetaBranch] = o.Branch + } + if o.WorkspacePath != "" { + meta[MetaWorkspacePath] = o.WorkspacePath + } + if o.RuntimeHandle.ID != "" { + meta[MetaRuntimeHandleID] = o.RuntimeHandle.ID + } + if o.RuntimeHandle.RuntimeName != "" { + meta[MetaRuntimeName] = o.RuntimeHandle.RuntimeName + } + if o.AgentSessionID != "" { + meta[MetaAgentSessionID] = o.AgentSessionID + } + return meta +} diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go new file mode 100644 index 0000000000..6f9f7f717b --- /dev/null +++ b/backend/internal/lifecycle/manager_test.go @@ -0,0 +1,388 @@ +package lifecycle + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var t0 = time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) + +const sid domain.SessionID = "s1" + +func newManager() (*Manager, *fakeStore) { + store := newFakeStore() + return New(store, &recordingNotifier{}, &recordingMessenger{}), store +} + +func mustLoad(t *testing.T, store *fakeStore) domain.CanonicalSessionLifecycle { + t.Helper() + l, ok, err := store.Load(context.Background(), sid) + if err != nil || !ok { + t.Fatalf("load: ok=%v err=%v", ok, err) + } + return l +} + +// ---- ApplyRuntimeObservation + #1 composition + #3 detecting clear ---- + +func TestApplyRuntimeObservation(t *testing.T) { + aliveProbe := ports.RuntimeFacts{RuntimeState: ports.RuntimeProbeAlive, ProcessState: ports.ProcessProbeAlive, ObservedAt: t0} + failedProbe := ports.RuntimeFacts{RuntimeState: ports.RuntimeProbeFailed, ProcessState: ports.ProcessProbeAlive, ObservedAt: t0} + deadProbe := ports.RuntimeFacts{RuntimeState: ports.RuntimeProbeDead, ProcessState: ports.ProcessProbeDead, ObservedAt: t0} + + tests := []struct { + name string + seed domain.CanonicalSessionLifecycle + facts ports.RuntimeFacts + wantSession domain.SessionState + wantReason domain.SessionReason + wantRuntime domain.RuntimeState + wantDisplay domain.SessionStatus + wantDetecting bool // expect non-nil detecting memory persisted + }{ + { + name: "healthy probe must not clobber an activity-owned needs_input (#1)", + seed: lc(domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, domain.RuntimeAlive), + facts: aliveProbe, + wantSession: domain.SessionNeedsInput, + wantReason: domain.ReasonAwaitingUserInput, + wantRuntime: domain.RuntimeAlive, + wantDisplay: domain.StatusNeedsInput, + wantDetecting: false, + }, + { + name: "healthy probe recovers a liveness-owned detecting -> working and clears memory (#1 + #3)", + seed: detectingLC(), + facts: aliveProbe, + wantSession: domain.SessionWorking, + wantReason: domain.ReasonTaskInProgress, + wantRuntime: domain.RuntimeAlive, + wantDisplay: domain.StatusWorking, + wantDetecting: false, + }, + { + name: "failed probe routes to detecting and records memory", + seed: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive), + facts: failedProbe, + wantSession: domain.SessionDetecting, + wantReason: domain.ReasonProbeFailure, + wantRuntime: domain.RuntimeProbeFailed, + wantDisplay: domain.StatusDetecting, + wantDetecting: true, + }, + { + name: "dead+dead with no recent activity concludes killed and clears detecting (#3)", + seed: detectingLC(), + facts: deadProbe, + wantSession: domain.SessionTerminated, + wantReason: domain.ReasonRuntimeLost, + wantRuntime: domain.RuntimeExited, + wantDisplay: domain.StatusKilled, + wantDetecting: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mgr, store := newManager() + store.seed(sid, tt.seed) + + if err := mgr.ApplyRuntimeObservation(context.Background(), sid, tt.facts); err != nil { + t.Fatalf("apply: %v", err) + } + + l := mustLoad(t, store) + if l.Session.State != tt.wantSession || l.Session.Reason != tt.wantReason { + t.Errorf("session = %v/%v, want %v/%v", l.Session.State, l.Session.Reason, tt.wantSession, tt.wantReason) + } + if l.Runtime.State != tt.wantRuntime { + t.Errorf("runtime = %v, want %v", l.Runtime.State, tt.wantRuntime) + } + if got := domain.DeriveLegacyStatus(l); got != tt.wantDisplay { + t.Errorf("display = %v, want %v", got, tt.wantDisplay) + } + if (l.Detecting != nil) != tt.wantDetecting { + t.Errorf("detecting present = %v, want %v (%+v)", l.Detecting != nil, tt.wantDetecting, l.Detecting) + } + }) + } +} + +func TestApplyRuntimeObservation_NoRecordIsNoOp(t *testing.T) { + mgr, store := newManager() + if err := mgr.ApplyRuntimeObservation(context.Background(), sid, ports.RuntimeFacts{RuntimeState: ports.RuntimeProbeAlive, ProcessState: ports.ProcessProbeAlive, ObservedAt: t0}); err != nil { + t.Fatalf("apply: %v", err) + } + if _, ok, _ := store.Load(context.Background(), sid); ok { + t.Error("a probe for an unseeded session must not fabricate a record") + } +} + +// ---- ApplyActivitySignal ---- + +func TestApplyActivitySignal(t *testing.T) { + tests := []struct { + name string + seed domain.CanonicalSessionLifecycle + signal ports.ActivitySignal + wantSession domain.SessionState + wantActivity domain.ActivityState + wantChanged bool + }{ + { + name: "valid waiting_input maps to needs_input", + seed: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive), + signal: ports.ActivitySignal{State: ports.SignalValid, Activity: domain.ActivityWaitingInput, Timestamp: t0, Source: domain.SourceHook}, + wantSession: domain.SessionNeedsInput, + wantActivity: domain.ActivityWaitingInput, + wantChanged: true, + }, + { + name: "valid active recovers needs_input -> working", + seed: lc(domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, domain.RuntimeAlive), + signal: ports.ActivitySignal{State: ports.SignalValid, Activity: domain.ActivityActive, Timestamp: t0, Source: domain.SourceHook}, + wantSession: domain.SessionWorking, + wantActivity: domain.ActivityActive, + wantChanged: true, + }, + { + name: "low-confidence signal is dropped (no idleness inferred)", + seed: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive), + signal: ports.ActivitySignal{State: ports.SignalProbeFailure, Activity: domain.ActivityIdle, Timestamp: t0, Source: domain.SourceHook}, + wantSession: domain.SessionWorking, + wantChanged: false, + }, + { + name: "activity does not touch a liveness-owned detecting session", + seed: detectingLC(), + signal: ports.ActivitySignal{State: ports.SignalValid, Activity: domain.ActivityActive, Timestamp: t0, Source: domain.SourceHook}, + wantSession: domain.SessionDetecting, + wantActivity: domain.ActivityActive, + wantChanged: true, // activity sub-state still updates + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mgr, store := newManager() + store.seed(sid, tt.seed) + + if err := mgr.ApplyActivitySignal(context.Background(), sid, tt.signal); err != nil { + t.Fatalf("apply: %v", err) + } + + l := mustLoad(t, store) + if l.Session.State != tt.wantSession { + t.Errorf("session = %v, want %v", l.Session.State, tt.wantSession) + } + if tt.wantChanged && l.Revision != 1 { + t.Errorf("revision = %d, want 1 (expected a write)", l.Revision) + } + if !tt.wantChanged && l.Revision != 0 { + t.Errorf("revision = %d, want 0 (expected a no-op)", l.Revision) + } + if tt.wantChanged && tt.wantActivity != "" && l.Activity.State != tt.wantActivity { + t.Errorf("activity = %v, want %v", l.Activity.State, tt.wantActivity) + } + if tt.name == "activity does not touch a liveness-owned detecting session" && l.Detecting == nil { + t.Error("activity must leave detecting memory for the probe pipeline to resolve") + } + }) + } +} + +// ---- ApplySCMObservation ---- + +func TestApplySCMObservation(t *testing.T) { + t.Run("failed fetch is a no-op (failed probe != no PR)", func(t *testing.T) { + mgr, store := newManager() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + if err := mgr.ApplySCMObservation(context.Background(), sid, ports.SCMFacts{Fetched: false, PRState: domain.PROpen}); err != nil { + t.Fatalf("apply: %v", err) + } + if l := mustLoad(t, store); l.Revision != 0 || l.PR.State != "" { + t.Errorf("expected no-op, got revision=%d pr=%v", l.Revision, l.PR.State) + } + }) + + t.Run("open PR writes only the PR axis; session stays activity-owned", func(t *testing.T) { + mgr, store := newManager() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + f := ports.SCMFacts{Fetched: true, PRState: domain.PROpen, CISummary: ports.CIFailing, PRNumber: 12, PRURL: "https://x/12"} + if err := mgr.ApplySCMObservation(context.Background(), sid, f); err != nil { + t.Fatalf("apply: %v", err) + } + l := mustLoad(t, store) + if l.PR.State != domain.PROpen || l.PR.Reason != domain.PRReasonCIFailing || l.PR.Number != 12 { + t.Errorf("pr = %+v, want open/ci_failing/#12", l.PR) + } + if l.Session.State != domain.SessionWorking { + t.Errorf("session = %v, want working (untouched)", l.Session.State) + } + if got := domain.DeriveLegacyStatus(l); got != domain.StatusCIFailed { + t.Errorf("display = %v, want ci_failed", got) + } + }) + + t.Run("merged PR parks the session and displays merged", func(t *testing.T) { + mgr, store := newManager() + seed := lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive) + seed.PR = domain.PRSubstate{State: domain.PROpen, Reason: domain.PRReasonInProgress, Number: 12} + store.seed(sid, seed) + f := ports.SCMFacts{Fetched: true, PRState: domain.PRMerged, PRNumber: 12} + if err := mgr.ApplySCMObservation(context.Background(), sid, f); err != nil { + t.Fatalf("apply: %v", err) + } + l := mustLoad(t, store) + if l.PR.State != domain.PRMerged || l.Session.Reason != domain.ReasonMergedWaitingDecision { + t.Errorf("got pr=%v session=%v, want merged + merged_waiting_decision", l.PR.State, l.Session.Reason) + } + if got := domain.DeriveLegacyStatus(l); got != domain.StatusMerged { + t.Errorf("display = %v, want merged", got) + } + }) + + t.Run("no PR is a no-op in split A", func(t *testing.T) { + mgr, store := newManager() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + if err := mgr.ApplySCMObservation(context.Background(), sid, ports.SCMFacts{Fetched: true, PRState: domain.PRNone}); err != nil { + t.Fatalf("apply: %v", err) + } + if l := mustLoad(t, store); l.Revision != 0 { + t.Errorf("expected no-op, got revision=%d", l.Revision) + } + }) +} + +// ---- mutation outcomes ---- + +func TestOnSpawnCompleted(t *testing.T) { + mgr, store := newManager() + store.seed(sid, lc(domain.SessionNotStarted, domain.ReasonSpawnRequested, domain.RuntimeUnknown)) + + out := ports.SpawnOutcome{ + Branch: "feat/x", + WorkspacePath: "/w/x", + RuntimeHandle: ports.RuntimeHandle{ID: "tmux:1", RuntimeName: "tmux"}, + AgentSessionID: "agent-1", + } + if err := mgr.OnSpawnCompleted(context.Background(), sid, out); err != nil { + t.Fatalf("apply: %v", err) + } + + l := mustLoad(t, store) + if l.Runtime.State != domain.RuntimeAlive { + t.Errorf("runtime = %v, want alive", l.Runtime.State) + } + if l.Session.State != domain.SessionNotStarted { + t.Errorf("session = %v, want not_started (spawn does not assert acknowledgement)", l.Session.State) + } + if got := domain.DeriveLegacyStatus(l); got != domain.StatusSpawning { + t.Errorf("display = %v, want spawning", got) + } + meta, _ := store.GetMetadata(context.Background(), sid) + if meta[MetaBranch] != "feat/x" || meta[MetaAgentSessionID] != "agent-1" || meta[MetaRuntimeName] != "tmux" { + t.Errorf("metadata not recorded: %+v", meta) + } +} + +func TestOnKillRequested(t *testing.T) { + mgr, store := newManager() + store.seed(sid, detectingLC()) + + if err := mgr.OnKillRequested(context.Background(), sid, ports.KillReason{Kind: ports.KillManual, Detail: "user"}); err != nil { + t.Fatalf("apply: %v", err) + } + + l := mustLoad(t, store) + if l.Session.State != domain.SessionTerminated || l.Session.Reason != domain.ReasonManuallyKilled { + t.Errorf("session = %v/%v, want terminated/manually_killed", l.Session.State, l.Session.Reason) + } + if l.Runtime.Reason != domain.RuntimeReasonManualKillRequested { + t.Errorf("runtime reason = %v, want manual_kill_requested", l.Runtime.Reason) + } + if l.Detecting != nil { + t.Errorf("kill must clear detecting memory, got %+v", l.Detecting) + } + if got := domain.DeriveLegacyStatus(l); got != domain.StatusKilled { + t.Errorf("display = %v, want killed", got) + } +} + +func TestTickEscalationsIsNoOp(t *testing.T) { + mgr, store := newManager() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + if err := mgr.TickEscalations(context.Background(), t0); err != nil { + t.Fatalf("tick: %v", err) + } + if l := mustLoad(t, store); l.Revision != 0 { + t.Errorf("TickEscalations must not write, got revision=%d", l.Revision) + } +} + +// ---- fake store contract ---- + +func TestFakeStoreExpectedRevision(t *testing.T) { + store := newFakeStore() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) // revision 0 + rt := domain.RuntimeSubstate{State: domain.RuntimeExited} + + bad := 99 + if err := store.PatchLifecycle(context.Background(), sid, ports.LifecyclePatch{Runtime: &rt, ExpectedRevision: &bad}); err == nil { + t.Error("stale ExpectedRevision must be rejected") + } + good := 0 + if err := store.PatchLifecycle(context.Background(), sid, ports.LifecyclePatch{Runtime: &rt, ExpectedRevision: &good}); err != nil { + t.Errorf("matching ExpectedRevision must succeed, got %v", err) + } +} + +// ---- per-session serialisation under the race detector ---- + +func TestPerSessionSerialization(t *testing.T) { + mgr, store := newManager() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + + const n = 50 + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + go func(i int) { + defer wg.Done() + _ = mgr.ApplyActivitySignal(context.Background(), sid, ports.ActivitySignal{ + State: ports.SignalValid, + Activity: domain.ActivityActive, + Timestamp: t0.Add(time.Duration(i) * time.Second), + Source: domain.SourceHook, + }) + }(i) + } + wg.Wait() + + // Each goroutine writes a distinct LastActivityAt, so every call is a real + // change; with correct serialisation all n land without a lost update. + if l := mustLoad(t, store); l.Revision != n { + t.Errorf("revision = %d, want %d (lost update under concurrency)", l.Revision, n) + } +} + +// ---- helpers ---- + +func lc(state domain.SessionState, reason domain.SessionReason, rt domain.RuntimeState) domain.CanonicalSessionLifecycle { + return domain.CanonicalSessionLifecycle{ + Version: domain.LifecycleVersion, + Session: domain.SessionSubstate{State: state, Reason: reason}, + Runtime: domain.RuntimeSubstate{State: rt}, + } +} + +func detectingLC() domain.CanonicalSessionLifecycle { + l := lc(domain.SessionDetecting, domain.ReasonRuntimeLost, domain.RuntimeMissing) + l.Detecting = &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: "abc"} + return l +} From 3945b10f2057743919d33799dd9144c4a4de8324 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 01:37:14 +0530 Subject: [PATCH 014/250] fix(lifecycle): address PR #5 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shouldWriteSessionRuntime: never resurrect a terminal session; an observation may refresh the runtime axis but must touch neither the session axis nor the detecting memory (gated in ApplyRuntimeObservation). - OnSpawnCompleted: error on an unseeded session instead of fabricating a partial record (SM must seed first — a missing seed is a contract violation). - OnKillRequested: no-op on an unknown/already-gone session (benign race) instead of fabricating a terminal record. - keyedMutex: reference-count entries and evict on last release so the lock map stays bounded in a long-running daemon. - runtimeSubstateFromFacts: map RuntimeProbeIndeterminate to RuntimeUnknown with a neutral reason, distinct from the probe_error of a failed probe. Adds tests for terminal non-resurrection, unseeded spawn-completed error, and unknown-session kill no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/lifecycle/decide_bridge.go | 16 ++++-- backend/internal/lifecycle/manager.go | 59 +++++++++++++++++---- backend/internal/lifecycle/manager_test.go | 40 ++++++++++++++ 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/backend/internal/lifecycle/decide_bridge.go b/backend/internal/lifecycle/decide_bridge.go index 059ac2eb6d..d1ac7f65fe 100644 --- a/backend/internal/lifecycle/decide_bridge.go +++ b/backend/internal/lifecycle/decide_bridge.go @@ -71,8 +71,12 @@ func runtimeSubstateFromFacts(f ports.RuntimeFacts) domain.RuntimeSubstate { return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonTmuxMissing} case ports.RuntimeProbeFailed: return domain.RuntimeSubstate{State: domain.RuntimeProbeFailed, Reason: domain.RuntimeReasonProbeError} - default: - return domain.RuntimeSubstate{State: domain.RuntimeUnknown, Reason: domain.RuntimeReasonProbeError} + case ports.RuntimeProbeIndeterminate: + // Probe ran but couldn't tell — distinct from a probe error, so no + // probe_error reason; the ambiguity is carried by RuntimeUnknown alone. + return domain.RuntimeSubstate{State: domain.RuntimeUnknown} + default: // unset + return domain.RuntimeSubstate{State: domain.RuntimeUnknown} } } @@ -158,8 +162,14 @@ func isLivenessOwned(s domain.SessionSubstate) bool { // (e.g. detecting -> working); it must NOT clobber an activity-owned // needs_input/blocked/idle the activity axis is responsible for. func shouldWriteSessionRuntime(d decide.LifecycleDecision, cur domain.CanonicalSessionLifecycle) bool { + if isTerminal(cur.Session.State) { + // A terminal session is only reopened by an explicit Restore — never by + // an observation. Even a death-axis verdict (e.g. detecting) must not + // resurrect it; the runtime axis is still patched separately. + return false + } if d.SessionState == domain.SessionWorking { - return !isTerminal(cur.Session.State) && isLivenessOwned(cur.Session) + return isLivenessOwned(cur.Session) } return true } diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 55a594bb44..b7f9d0aaac 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -11,6 +11,7 @@ package lifecycle import ( "context" + "fmt" "sync" "time" @@ -54,25 +55,43 @@ func New(store ports.LifecycleStore, notifier ports.Notifier, messenger ports.Ag // keyedMutex hands out one lock per session id so the load->decide->persist // read-modify-write is serial within a session but parallel across sessions. +// +// Entries are reference-counted and evicted when the last holder releases, so +// the map stays bounded to sessions with in-flight operations rather than +// growing unbounded over the lifetime of a long-running daemon. type keyedMutex struct { mu sync.Mutex - locks map[domain.SessionID]*sync.Mutex + locks map[domain.SessionID]*lockEntry +} + +type lockEntry struct { + mu sync.Mutex + refs int } func (k *keyedMutex) lock(id domain.SessionID) func() { k.mu.Lock() if k.locks == nil { - k.locks = make(map[domain.SessionID]*sync.Mutex) + k.locks = make(map[domain.SessionID]*lockEntry) } - m, ok := k.locks[id] + e, ok := k.locks[id] if !ok { - m = &sync.Mutex{} - k.locks[id] = m + e = &lockEntry{} + k.locks[id] = e } + e.refs++ k.mu.Unlock() - m.Lock() - return m.Unlock + e.mu.Lock() + return func() { + e.mu.Unlock() + k.mu.Lock() + e.refs-- + if e.refs == 0 { + delete(k.locks, id) + } + k.mu.Unlock() + } } func (m *Manager) withLock(id domain.SessionID, fn func() error) error { @@ -127,10 +146,15 @@ func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.Session patch.Runtime = &rt changed = true } - if shouldWriteSessionRuntime(d, cur) { - changed = setSessionIfChanged(&patch, cur, d.SessionState, d.SessionReason) || changed + // A terminal session is reopened only by an explicit Restore: an + // observation may refresh the runtime axis above but must touch neither + // the session axis nor the detecting memory. + if !isTerminal(cur.Session.State) { + if shouldWriteSessionRuntime(d, cur) { + changed = setSessionIfChanged(&patch, cur, d.SessionState, d.SessionReason) || changed + } + changed = setDetecting(&patch, cur, d.Detecting) || changed } - changed = setDetecting(&patch, cur, d.Detecting) || changed return patch, changed, nil }) @@ -203,10 +227,16 @@ func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, // (display: spawning) — the agent "acknowledges" via the first activity signal. func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error { return m.withLock(id, func() error { - cur, _, err := m.store.Load(ctx, id) + cur, exists, err := m.store.Load(ctx, id) if err != nil { return err } + if !exists { + // The SM seeds the initial lifecycle before spawning; a completion + // for an unseeded session is a contract violation, not a stray + // observation, so surface it rather than fabricating a record. + return fmt.Errorf("lifecycle: OnSpawnCompleted for unseeded session %q", id) + } rt := domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning} if cur.Runtime != rt { if err := m.store.PatchLifecycle(ctx, id, ports.LifecyclePatch{Runtime: &rt}); err != nil { @@ -228,6 +258,13 @@ func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o p // in-flight detecting memory. func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, r ports.KillReason) error { return m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + if !exists { + // Killing an unknown/already-gone session is a benign race; no-op + // rather than fabricating a terminal record for a session we never + // knew about. + return ports.LifecyclePatch{}, false, nil + } + var patch ports.LifecyclePatch changed := false diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index 6f9f7f717b..dae266b879 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -123,6 +123,25 @@ func TestApplyRuntimeObservation_NoRecordIsNoOp(t *testing.T) { } } +func TestApplyRuntimeObservation_DoesNotResurrectTerminal(t *testing.T) { + mgr, store := newManager() + store.seed(sid, lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.RuntimeExited)) + + // A failed probe would normally route to detecting, but a terminal session + // must not be reopened by an observation (only an explicit Restore does). + if err := mgr.ApplyRuntimeObservation(context.Background(), sid, ports.RuntimeFacts{RuntimeState: ports.RuntimeProbeFailed, ProcessState: ports.ProcessProbeAlive, ObservedAt: t0}); err != nil { + t.Fatalf("apply: %v", err) + } + + l := mustLoad(t, store) + if l.Session.State != domain.SessionTerminated || l.Session.Reason != domain.ReasonManuallyKilled { + t.Errorf("session = %v/%v, want terminated/manually_killed (no resurrection)", l.Session.State, l.Session.Reason) + } + if l.Detecting != nil { + t.Errorf("terminal session must not gain detecting memory, got %+v", l.Detecting) + } +} + // ---- ApplyActivitySignal ---- func TestApplyActivitySignal(t *testing.T) { @@ -314,6 +333,27 @@ func TestOnKillRequested(t *testing.T) { } } +func TestOnSpawnCompleted_UnseededErrors(t *testing.T) { + mgr, store := newManager() + err := mgr.OnSpawnCompleted(context.Background(), sid, ports.SpawnOutcome{Branch: "x"}) + if err == nil { + t.Error("OnSpawnCompleted for an unseeded session must error, not fabricate a record") + } + if _, ok, _ := store.Load(context.Background(), sid); ok { + t.Error("no record should have been created") + } +} + +func TestOnKillRequested_UnseededIsNoOp(t *testing.T) { + mgr, store := newManager() + if err := mgr.OnKillRequested(context.Background(), sid, ports.KillReason{Kind: ports.KillManual}); err != nil { + t.Fatalf("kill of unknown session should be a benign no-op, got %v", err) + } + if _, ok, _ := store.Load(context.Background(), sid); ok { + t.Error("killing an unknown session must not fabricate a terminal record") + } +} + func TestTickEscalationsIsNoOp(t *testing.T) { mgr, store := newManager() store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) From 9eb5348604402198d9b0337bd4925287c5f7b3ec Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 01:49:28 +0530 Subject: [PATCH 015/250] feat(lifecycle): activity resolves detecting + review polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Harshit's PR #5 review (approve w/ design confirms + polish): - #1 (design decision): a valid activity signal is proof of life, so it now resolves a detecting session — writes the activity-mapped session state and clears the quarantine memory. Scoped to detecting only; a liveness-escalated stuck stays the probe pipeline's to resolve. Terminal still never reopens. - #2: document why a merged/closed PR parks the session axis even over an activity-owned needs_input/blocked (a merge is a milestone), unlike the open-PR path that defers to activity. - #3: map plain idle activity to a neutral session reason instead of the misleading research_complete (kept for ready, which implies completion). - #6: cover all three kill kinds (manual/cleanup/error), the open-PR review branches (changes_requested/mergeable/review_pending), and the neutral idle reason. Coverage 86.5% -> 88.6%. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/lifecycle/decide_bridge.go | 23 ++++- backend/internal/lifecycle/manager.go | 17 +++- backend/internal/lifecycle/manager_test.go | 104 +++++++++++++++----- 3 files changed, 115 insertions(+), 29 deletions(-) diff --git a/backend/internal/lifecycle/decide_bridge.go b/backend/internal/lifecycle/decide_bridge.go index d1ac7f65fe..942fdad419 100644 --- a/backend/internal/lifecycle/decide_bridge.go +++ b/backend/internal/lifecycle/decide_bridge.go @@ -121,8 +121,13 @@ func activityToSession(a domain.ActivityState) (domain.SessionState, domain.Sess switch a { case domain.ActivityActive: return domain.SessionWorking, domain.ReasonTaskInProgress, true - case domain.ActivityReady, domain.ActivityIdle: + case domain.ActivityReady: + // ready = the agent finished a unit and is waiting for more work. return domain.SessionIdle, domain.ReasonResearchComplete, true + case domain.ActivityIdle: + // plain inactivity carries no completion claim, so no specific reason + // (research_complete here would read misleadingly in diagnostics). + return domain.SessionIdle, "", true case domain.ActivityWaitingInput: return domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, true case domain.ActivityBlocked: @@ -175,11 +180,19 @@ func shouldWriteSessionRuntime(d decide.LifecycleDecision, cur domain.CanonicalS } // shouldWriteSessionActivity is the mirror rule for ApplyActivitySignal: the -// activity axis owns working/idle/waiting, but it must not touch the death axis. -// It writes unless the session is terminal or currently liveness-owned (let the -// probe pipeline resolve detecting / death-inferred states instead). +// activity axis owns working/idle/waiting. A valid activity signal is direct +// proof of life, so it is allowed to RESOLVE a detecting session (pull it out of +// the liveness quarantine) — but it must not resurrect a terminal session, and +// it leaves a liveness-escalated stuck state to the probe pipeline (stuck is a +// deliberate human-facing escalation, not a transient quarantine). func shouldWriteSessionActivity(cur domain.CanonicalSessionLifecycle) bool { - return !isTerminal(cur.Session.State) && !isLivenessOwned(cur.Session) + if isTerminal(cur.Session.State) { + return false + } + if cur.Session.State == domain.SessionDetecting { + return true + } + return !isLivenessOwned(cur.Session) } // ---- explicit-kill mapping (SM's terminal-write authority) ---- diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index b7f9d0aaac..eb8538d3e2 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -181,6 +181,11 @@ func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, d := decide.ResolveTerminalPRStateDecision(f.PRState) var patch ports.LifecyclePatch changed := setPRIfChanged(&patch, cur, d, f) + // A merge/close is a milestone that ends the work, so it parks the + // session axis (idle / merged_waiting_decision) even over an + // activity-owned needs_input/blocked — unlike the open-PR path, + // which leaves the session axis to activity. A terminal session is + // still never reopened. if !isTerminal(cur.Session.State) { changed = setSessionIfChanged(&patch, cur, d.SessionState, d.SessionReason) || changed } @@ -195,8 +200,9 @@ func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, // ApplyActivitySignal updates the activity axis. Only a valid-confidence signal // is authoritative (stale/unavailable/probe_failure != idleness). It refreshes // the persisted activity sub-state (the probe decider's RecentActivity input) -// and maps the classification onto the session axis, subject to the mirror -// composition rule that keeps activity off the death axis. +// and maps the classification onto the session axis. A valid signal is proof of +// life, so it may resolve a detecting session — clearing the quarantine memory +// so a later probe doesn't resume counting from a stale prior. func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error { return m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { if !exists || s.State != ports.SignalValid { @@ -213,6 +219,13 @@ func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, } if st, rs, ok := activityToSession(s.Activity); ok && shouldWriteSessionActivity(cur) { changed = setSessionIfChanged(&patch, cur, st, rs) || changed + // Proof of life that pulls the session out of detecting must also + // drop the quarantine memory (detecting memory only exists while + // detecting, so this is a no-op otherwise). + if cur.Detecting != nil { + patch.ClearDetecting = true + changed = true + } } return patch, changed, nil diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index dae266b879..e8c47c0a57 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -150,6 +150,8 @@ func TestApplyActivitySignal(t *testing.T) { seed domain.CanonicalSessionLifecycle signal ports.ActivitySignal wantSession domain.SessionState + wantReason domain.SessionReason + checkReason bool wantActivity domain.ActivityState wantChanged bool }{ @@ -169,6 +171,16 @@ func TestApplyActivitySignal(t *testing.T) { wantActivity: domain.ActivityActive, wantChanged: true, }, + { + name: "valid idle maps to idle with a neutral reason", + seed: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive), + signal: ports.ActivitySignal{State: ports.SignalValid, Activity: domain.ActivityIdle, Timestamp: t0, Source: domain.SourceHook}, + wantSession: domain.SessionIdle, + wantReason: "", + checkReason: true, + wantActivity: domain.ActivityIdle, + wantChanged: true, + }, { name: "low-confidence signal is dropped (no idleness inferred)", seed: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive), @@ -177,12 +189,12 @@ func TestApplyActivitySignal(t *testing.T) { wantChanged: false, }, { - name: "activity does not touch a liveness-owned detecting session", + name: "valid activity resolves a detecting session (proof of life)", seed: detectingLC(), signal: ports.ActivitySignal{State: ports.SignalValid, Activity: domain.ActivityActive, Timestamp: t0, Source: domain.SourceHook}, - wantSession: domain.SessionDetecting, + wantSession: domain.SessionWorking, wantActivity: domain.ActivityActive, - wantChanged: true, // activity sub-state still updates + wantChanged: true, }, } @@ -199,6 +211,9 @@ func TestApplyActivitySignal(t *testing.T) { if l.Session.State != tt.wantSession { t.Errorf("session = %v, want %v", l.Session.State, tt.wantSession) } + if tt.checkReason && l.Session.Reason != tt.wantReason { + t.Errorf("session reason = %q, want %q", l.Session.Reason, tt.wantReason) + } if tt.wantChanged && l.Revision != 1 { t.Errorf("revision = %d, want 1 (expected a write)", l.Revision) } @@ -208,8 +223,8 @@ func TestApplyActivitySignal(t *testing.T) { if tt.wantChanged && tt.wantActivity != "" && l.Activity.State != tt.wantActivity { t.Errorf("activity = %v, want %v", l.Activity.State, tt.wantActivity) } - if tt.name == "activity does not touch a liveness-owned detecting session" && l.Detecting == nil { - t.Error("activity must leave detecting memory for the probe pipeline to resolve") + if tt.name == "valid activity resolves a detecting session (proof of life)" && l.Detecting != nil { + t.Errorf("resolving detecting must clear the quarantine memory, got %+v", l.Detecting) } }) } @@ -266,6 +281,35 @@ func TestApplySCMObservation(t *testing.T) { } }) + t.Run("open-PR review branches map to the PR axis", func(t *testing.T) { + cases := []struct { + name string + facts ports.SCMFacts + wantReason domain.PRReason + wantStatus domain.SessionStatus + }{ + {"changes requested", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewChangesRequested}, domain.PRReasonChangesRequested, domain.StatusChangesRequested}, + {"approved + mergeable", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewApproved, Mergeability: ports.Mergeability{Mergeable: true}}, domain.PRReasonMergeReady, domain.StatusMergeable}, + {"review pending", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewPending}, domain.PRReasonReviewPending, domain.StatusReviewPending}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + mgr, store := newManager() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + if err := mgr.ApplySCMObservation(context.Background(), sid, c.facts); err != nil { + t.Fatalf("apply: %v", err) + } + l := mustLoad(t, store) + if l.PR.State != domain.PROpen || l.PR.Reason != c.wantReason { + t.Errorf("pr = %v/%v, want open/%v", l.PR.State, l.PR.Reason, c.wantReason) + } + if got := domain.DeriveLegacyStatus(l); got != c.wantStatus { + t.Errorf("display = %v, want %v", got, c.wantStatus) + } + }) + } + }) + t.Run("no PR is a no-op in split A", func(t *testing.T) { mgr, store := newManager() store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) @@ -311,25 +355,41 @@ func TestOnSpawnCompleted(t *testing.T) { } func TestOnKillRequested(t *testing.T) { - mgr, store := newManager() - store.seed(sid, detectingLC()) - - if err := mgr.OnKillRequested(context.Background(), sid, ports.KillReason{Kind: ports.KillManual, Detail: "user"}); err != nil { - t.Fatalf("apply: %v", err) + tests := []struct { + name string + kind ports.LifecycleKillReason + wantReason domain.SessionReason + wantRuntime domain.RuntimeReason + wantDisplay domain.SessionStatus + }{ + {"manual", ports.KillManual, domain.ReasonManuallyKilled, domain.RuntimeReasonManualKillRequested, domain.StatusKilled}, + {"cleanup", ports.KillCleanup, domain.ReasonAutoCleanup, domain.RuntimeReasonAutoCleanup, domain.StatusCleanup}, + {"error", ports.KillError, domain.ReasonErrorInProcess, domain.RuntimeReasonProbeError, domain.StatusErrored}, } - l := mustLoad(t, store) - if l.Session.State != domain.SessionTerminated || l.Session.Reason != domain.ReasonManuallyKilled { - t.Errorf("session = %v/%v, want terminated/manually_killed", l.Session.State, l.Session.Reason) - } - if l.Runtime.Reason != domain.RuntimeReasonManualKillRequested { - t.Errorf("runtime reason = %v, want manual_kill_requested", l.Runtime.Reason) - } - if l.Detecting != nil { - t.Errorf("kill must clear detecting memory, got %+v", l.Detecting) - } - if got := domain.DeriveLegacyStatus(l); got != domain.StatusKilled { - t.Errorf("display = %v, want killed", got) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mgr, store := newManager() + store.seed(sid, detectingLC()) + + if err := mgr.OnKillRequested(context.Background(), sid, ports.KillReason{Kind: tt.kind, Detail: "x"}); err != nil { + t.Fatalf("apply: %v", err) + } + + l := mustLoad(t, store) + if l.Session.State != domain.SessionTerminated || l.Session.Reason != tt.wantReason { + t.Errorf("session = %v/%v, want terminated/%v", l.Session.State, l.Session.Reason, tt.wantReason) + } + if l.Runtime.Reason != tt.wantRuntime { + t.Errorf("runtime reason = %v, want %v", l.Runtime.Reason, tt.wantRuntime) + } + if l.Detecting != nil { + t.Errorf("kill must clear detecting memory, got %+v", l.Detecting) + } + if got := domain.DeriveLegacyStatus(l); got != tt.wantDisplay { + t.Errorf("display = %v, want %v", got, tt.wantDisplay) + } + }) } } From 8b8da8e6a4221699a5243d141a4fe665aca6b4c9 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 02:17:51 +0530 Subject: [PATCH 016/250] =?UTF-8?q?feat(lifecycle):=20ACT=20layer=20?= =?UTF-8?q?=E2=80=94=20reaction=20table=20+=20escalation=20engine=20(split?= =?UTF-8?q?=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ACT half of the LCM: map persisted status transitions to reactions (send-to-agent / notify / auto-merge) and drive escalation. - reactions.go: the §4.2 default reaction table, reactionEventFor (mirrors DeriveLegacyStatus for the ACT layer), in-memory per-(session,reaction) escalation trackers, the react() dispatch chokepoint, and a real TickEscalations for duration-based escalations the synchronous LCM can't wake itself for. auto-merge action exists but is off by default; bugbot-comments/merge-conflicts are configured but dormant (no decide-core producer yet). - manager.go: mutate now returns a transition; each Apply* path fires the mapped reaction after persist via the single synchronous react() seam. OnKillRequested intentionally does not react (explicit kill != inferred event). Split-A load->decide->diff->persist behavior is unchanged. ci-failed budget is persistent across fail->pending->fail oscillation; non-persistent trackers reset when the status leaves the triggering state. Escalation silences further auto-dispatch until the condition clears. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/lifecycle/manager.go | 77 ++++- backend/internal/lifecycle/manager_test.go | 11 - backend/internal/lifecycle/reactions.go | 345 +++++++++++++++++++ backend/internal/lifecycle/reactions_test.go | 288 ++++++++++++++++ 4 files changed, 691 insertions(+), 30 deletions(-) create mode 100644 backend/internal/lifecycle/reactions.go create mode 100644 backend/internal/lifecycle/reactions_test.go diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index eb8538d3e2..fd12924962 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -4,9 +4,9 @@ // decider, diff the result into a sparse merge-patch, persist. The LCM never // polls and never writes the display status (that is derived on read). // -// Split A scope is the Apply* pipeline only. The reaction table + escalation -// engine (ACT) and the Session Manager land in later splits; TickEscalations is -// a documented no-op here. +// After a transition is persisted, the Apply* paths fire the mapped reaction +// (the ACT layer: reaction table + escalation engine) via the react() chokepoint +// in reactions.go. The Session Manager lands in a later split. package lifecycle import ( @@ -29,8 +29,8 @@ const ( MetaAgentSessionID = "agentSessionId" ) -// Manager is the LCM. Notifier/AgentMessenger are held for the ACT lane (split -// B); the Apply* pipeline does not fire reactions yet. +// Manager is the LCM. The Apply* pipeline persists a transition and then fires +// the mapped reaction via Notifier/AgentMessenger (see reactions.go). type Manager struct { store ports.LifecycleStore notifier ports.Notifier @@ -38,6 +38,14 @@ type Manager struct { recentActivityWindow time.Duration locks keyedMutex + + // trackers hold per-(session,reaction) escalation budgets (ACT policy, not + // canonical state). trackerMu guards them: react() touches them from the + // caller's goroutine, TickEscalations from the reaper's. clock is the time + // source for escalation stamping (overridable in tests). + trackers map[trackerKey]*reactionTracker + trackerMu sync.Mutex + clock func() time.Time } var _ ports.LifecycleManager = (*Manager)(nil) @@ -48,6 +56,8 @@ func New(store ports.LifecycleStore, notifier ports.Notifier, messenger ports.Ag notifier: notifier, messenger: messenger, recentActivityWindow: defaultRecentActivityWindow, + trackers: map[trackerKey]*reactionTracker{}, + clock: time.Now, } } @@ -100,16 +110,28 @@ func (m *Manager) withLock(id domain.SessionID, fn func() error) error { return fn() } +// transition is what a persisted write produced: the canonical before and after +// the patch. The ACT layer (react) derives the reaction from these. It is nil +// when the pipeline made no write. +type transition struct { + beforeLC domain.CanonicalSessionLifecycle + afterLC domain.CanonicalSessionLifecycle +} + // mutate runs the shared pipeline: load -> build patch -> persist (only if the // patch changed something). decideFn returns the diffed patch and whether it // touches anything; a false "changed" is a clean no-op (no write, no revision // bump), which is how failed-probe / unknown-fact inputs are dropped. +// +// On a write it returns the transition (before/after canonical) so the caller — +// which still holds the originating facts — can fire the mapped reaction. func (m *Manager) mutate( ctx context.Context, id domain.SessionID, decideFn func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error), -) error { - return m.withLock(id, func() error { +) (*transition, error) { + var tr *transition + err := m.withLock(id, func() error { cur, exists, err := m.store.Load(ctx, id) if err != nil { return err @@ -121,8 +143,17 @@ func (m *Manager) mutate( if !changed { return nil } - return m.store.PatchLifecycle(ctx, id, patch) + if err := m.store.PatchLifecycle(ctx, id, patch); err != nil { + return err + } + after, _, err := m.store.Load(ctx, id) + if err != nil { + return err + } + tr = &transition{beforeLC: cur, afterLC: after} + return nil }) + return tr, err } // ---- OBSERVE entrypoints ---- @@ -132,7 +163,7 @@ func (m *Manager) mutate( // non-detecting verdict clears any stale detecting memory (#3) so the next // probe doesn't read a phantom prior. func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error { - return m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { if !exists { return ports.LifecyclePatch{}, false, nil // nothing seeded; ignore stray probe } @@ -158,6 +189,10 @@ func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.Session return patch, changed, nil }) + if err != nil { + return err + } + return m.react(ctx, id, tr, reactionContext{}) } // ApplySCMObservation maps PR facts onto the PR axis. A failed fetch is dropped @@ -165,7 +200,7 @@ func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.Session // session axis stays owned by activity, and DeriveLegacyStatus surfaces the PR // reason for display. A terminal PR (merged/closed) also parks the session. func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, f ports.SCMFacts) error { - return m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { if !exists || !f.Fetched { return ports.LifecyclePatch{}, false, nil } @@ -195,6 +230,10 @@ func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, return ports.LifecyclePatch{}, false, nil } }) + if err != nil { + return err + } + return m.react(ctx, id, tr, reactionContext{ciFailureLogTail: f.CIFailureLogTail}) } // ApplyActivitySignal updates the activity axis. Only a valid-confidence signal @@ -204,7 +243,7 @@ func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, // life, so it may resolve a detecting session — clearing the quarantine memory // so a later probe doesn't resume counting from a stale prior. func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error { - return m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { if !exists || s.State != ports.SignalValid { return ports.LifecyclePatch{}, false, nil } @@ -230,6 +269,10 @@ func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, return patch, changed, nil }) + if err != nil { + return err + } + return m.react(ctx, id, tr, reactionContext{}) } // ---- mutation outcomes reported by the Session Manager ---- @@ -270,7 +313,9 @@ func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o p // the terminal session/runtime sub-states for the kill kind and clears any // in-flight detecting memory. func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, r ports.KillReason) error { - return m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + // An explicit user kill is a human action, not an inferred event, so it + // fires no reaction — the transition is discarded. + _, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { if !exists { // Killing an unknown/already-gone session is a benign race; no-op // rather than fabricating a terminal record for a session we never @@ -295,13 +340,7 @@ func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, r po } return patch, changed, nil }) -} - -// TickEscalations is a no-op in split A. The reaper will call this to fire -// duration-based escalations the synchronous LCM can't wake itself for, but the -// reaction table + escalation engine that back it land in split B. -func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { - return nil + return err } // ---- patch helpers (diff -> sparse merge-patch) ---- diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index e8c47c0a57..d0a97125ae 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -414,17 +414,6 @@ func TestOnKillRequested_UnseededIsNoOp(t *testing.T) { } } -func TestTickEscalationsIsNoOp(t *testing.T) { - mgr, store := newManager() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) - if err := mgr.TickEscalations(context.Background(), t0); err != nil { - t.Fatalf("tick: %v", err) - } - if l := mustLoad(t, store); l.Revision != 0 { - t.Errorf("TickEscalations must not write, got revision=%d", l.Revision) - } -} - // ---- fake store contract ---- func TestFakeStoreExpectedRevision(t *testing.T) { diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go new file mode 100644 index 0000000000..c9ee7f5e8d --- /dev/null +++ b/backend/internal/lifecycle/reactions.go @@ -0,0 +1,345 @@ +package lifecycle + +// reactions.go is the ACT layer: the reaction table, the per-(session,reaction) +// escalation engine, and the duration-driven TickEscalations the synchronous +// LCM can't wake itself for. Reactions fire from react() after a transition is +// persisted by the Apply* pipeline (see manager.go). +// +// Dispatch is synchronous: react() runs Send/Notify inline. It is the single +// dispatch chokepoint, so moving it onto a worker goroutine later (once a daemon +// owns that goroutine's lifecycle) is a change confined to this one function. + +import ( + "context" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// reactionKey names a row in the reaction table and a tracker bucket. +type reactionKey string + +const ( + reactionCIFailed reactionKey = "ci-failed" + reactionChangesRequested reactionKey = "changes-requested" + reactionBugbotComments reactionKey = "bugbot-comments" + reactionMergeConflicts reactionKey = "merge-conflicts" + reactionAgentIdle reactionKey = "agent-idle" + reactionApprovedAndGreen reactionKey = "approved-and-green" + reactionAgentStuck reactionKey = "agent-stuck" + reactionNeedsInput reactionKey = "agent-needs-input" + reactionAgentExited reactionKey = "agent-exited" + reactionPRClosed reactionKey = "pr-closed" + reactionAllComplete reactionKey = "all-complete" +) + +type actionKind string + +const ( + actionSendToAgent actionKind = "send-to-agent" + actionNotify actionKind = "notify" + actionAutoMerge actionKind = "auto-merge" +) + +// reactionConfig is one row of the reaction table (distillation §4.1/§4.2). +// +// - retries numeric escalation cap: escalate once attempts exceed it. +// - escalateAfter duration escalation: escalate once this elapses since the +// first attempt (fired by TickEscalations, since the LCM never polls). +// - persistent the tracker survives the status leaving the triggering +// state; it only resets when the incident is truly over (PR no longer open +// or the session terminal). Only ci-failed is persistent, so a flapping +// CI (fail→pending→fail) keeps draining one shared retry budget. +type reactionConfig struct { + auto bool + action actionKind + message string + priority ports.EventPriority + eventType string + retries int + escalateAfter time.Duration + persistent bool +} + +// defaultReactions is the product's default behaviour (distillation §4.2). +// auto-merge is intentionally absent: approved-and-green is a notify, so the +// human decides to merge. The auto-merge action kind exists for opt-in configs, +// but no default row uses it. +var defaultReactions = map[reactionKey]reactionConfig{ + reactionCIFailed: { + auto: true, action: actionSendToAgent, persistent: true, retries: 2, + message: "CI is failing on your PR. Review the failing output below and push a fix.", + eventType: "reaction.ci-failed", priority: ports.PriorityAction, + }, + reactionChangesRequested: { + auto: true, action: actionSendToAgent, escalateAfter: 30 * time.Minute, + message: "A reviewer requested changes on your PR. Address the comments and push.", + eventType: "reaction.changes-requested", priority: ports.PriorityAction, + }, + reactionBugbotComments: { + auto: true, action: actionSendToAgent, escalateAfter: 30 * time.Minute, + message: "An automated reviewer left comments on your PR. Address them and push.", + eventType: "reaction.bugbot-comments", priority: ports.PriorityAction, + }, + reactionMergeConflicts: { + auto: true, action: actionSendToAgent, escalateAfter: 15 * time.Minute, + message: "Your PR has merge conflicts. Rebase onto the base branch and resolve them.", + eventType: "reaction.merge-conflicts", priority: ports.PriorityAction, + }, + reactionAgentIdle: { + auto: true, action: actionSendToAgent, retries: 2, escalateAfter: 15 * time.Minute, + message: "You appear idle. Continue the task or explain what is blocking you.", + eventType: "reaction.agent-idle", priority: ports.PriorityWarning, + }, + reactionApprovedAndGreen: { + auto: false, action: actionNotify, priority: ports.PriorityAction, + message: "PR is approved and green — ready to merge.", + eventType: "reaction.approved-and-green", + }, + reactionAgentStuck: { + action: actionNotify, priority: ports.PriorityUrgent, + message: "Agent is stuck and needs attention.", + eventType: "reaction.agent-stuck", + }, + reactionNeedsInput: { + action: actionNotify, priority: ports.PriorityUrgent, + message: "Agent needs input to continue.", + eventType: "reaction.agent-needs-input", + }, + reactionAgentExited: { + action: actionNotify, priority: ports.PriorityUrgent, + message: "Agent process exited unexpectedly.", + eventType: "reaction.agent-exited", + }, + reactionPRClosed: { + action: actionNotify, priority: ports.PriorityAction, + message: "PR was closed without merging — decide: resume, learn, or terminate.", + eventType: "reaction.pr-closed", + }, + reactionAllComplete: { + action: actionNotify, priority: ports.PriorityInfo, + message: "PR merged — work complete.", + eventType: "reaction.all-complete", + }, +} + +// reactionEventFor maps a canonical record to the reaction it should drive, +// mirroring DeriveLegacyStatus but for the ACT layer. ok is false when the +// current state has no reaction. +// +// A closed PR derives to the idle display status, so it is detected from the PR +// axis directly before falling through to the status mapping. bugbot-comments +// and merge-conflicts have no producer in the split-A decide core yet, so they +// are dormant: configured but unreachable until DECIDE surfaces them. +func reactionEventFor(l domain.CanonicalSessionLifecycle) (reactionKey, bool) { + if l.PR.State == domain.PRClosed { + return reactionPRClosed, true + } + switch domain.DeriveLegacyStatus(l) { + case domain.StatusCIFailed: + return reactionCIFailed, true + case domain.StatusChangesRequested: + return reactionChangesRequested, true + case domain.StatusApproved, domain.StatusMergeable: + return reactionApprovedAndGreen, true + case domain.StatusIdle: + return reactionAgentIdle, true + case domain.StatusStuck: + return reactionAgentStuck, true + case domain.StatusNeedsInput: + return reactionNeedsInput, true + case domain.StatusKilled: + // Inferred death only — an explicit user kill goes through + // OnKillRequested, which does not react. + return reactionAgentExited, true + case domain.StatusMerged: + return reactionAllComplete, true + } + return "", false +} + +// reactionContext carries fact-derived material the message templates need. The +// SCM path populates it (CI failure log tail); other paths pass the zero value. +type reactionContext struct { + ciFailureLogTail *string +} + +// trackerKey buckets an escalation tracker by session and reaction. +type trackerKey struct { + id domain.SessionID + key reactionKey +} + +// reactionTracker is the per-(session,reaction) escalation budget. It lives in +// memory on the Manager: a daemon restart resets budgets, which only ever costs +// a few extra agent retries before re-escalating — never a missed human +// notification. Keeping it out of the canonical store preserves the +// truth-vs-policy split (the store holds session truth; this is ACT policy). +type reactionTracker struct { + attempts int + escalated bool + firstAttemptAt time.Time +} + +// react fires the ACT layer after a persisted transition: clear the tracker for +// the reaction we left, then dispatch the reaction for the one we entered. It +// fires only on a genuine reaction change, so re-persisting the same state does +// not re-dispatch. Synchronous by design (see file header). +func (m *Manager) react(ctx context.Context, id domain.SessionID, tr *transition, rc reactionContext) error { + if tr == nil { + return nil + } + beforeKey, hadBefore := reactionEventFor(tr.beforeLC) + afterKey, hasAfter := reactionEventFor(tr.afterLC) + + changed := beforeKey != afterKey + + if hadBefore && (!hasAfter || changed) { + // A persistent tracker survives oscillation within an open PR; it only + // resets once the incident is over. + if !defaultReactions[beforeKey].persistent || incidentOver(tr.afterLC) { + m.clearTracker(id, beforeKey) + } + } + if hasAfter && (!hadBefore || changed) { + return m.executeReaction(ctx, id, afterKey, rc) + } + return nil +} + +// incidentOver reports that a PR-pipeline incident has truly ended, so even a +// persistent tracker (ci-failed) may reset. +func incidentOver(l domain.CanonicalSessionLifecycle) bool { + return l.PR.State != domain.PROpen || isTerminal(l.Session.State) +} + +func (m *Manager) executeReaction(ctx context.Context, id domain.SessionID, key reactionKey, rc reactionContext) error { + cfg := defaultReactions[key] + switch cfg.action { + case actionNotify: + // notify reactions are human-attention terminals: fire once on the + // triggering transition, no retry/escalation budget. + return m.notifier.Notify(ctx, ports.OrchestratorEvent{ + Type: cfg.eventType, + Priority: cfg.priority, + SessionID: id, + Message: cfg.message, + }) + case actionAutoMerge: + // Off by default: no default row maps here, and wiring a merge port is a + // later PR. An opt-in config could route a reaction here. + return nil + case actionSendToAgent: + return m.sendToAgent(ctx, id, key, cfg, rc) + } + return nil +} + +// sendToAgent runs the escalation engine for an auto send-to-agent reaction: +// count the attempt, escalate when the numeric cap or duration is exceeded +// (silencing further auto-dispatch), else inject the message via the messenger. +func (m *Manager) sendToAgent(ctx context.Context, id domain.SessionID, key reactionKey, cfg reactionConfig, rc reactionContext) error { + m.trackerMu.Lock() + tk := m.trackerFor(id, key) + if tk.escalated { + m.trackerMu.Unlock() + return nil // silenced until the condition clears the tracker + } + now := m.clock() + if tk.firstAttemptAt.IsZero() { + tk.firstAttemptAt = now + } + tk.attempts++ + if shouldEscalate(tk, cfg, now) { + tk.escalated = true + m.trackerMu.Unlock() + return m.escalate(ctx, id, key) + } + m.trackerMu.Unlock() + + // A delivery failure does not consume escalation budget beyond this attempt: + // the next relevant transition simply tries again (distillation §4.3). + return m.messenger.Send(ctx, id, composeMessage(cfg, rc)) +} + +func shouldEscalate(tk *reactionTracker, cfg reactionConfig, now time.Time) bool { + if cfg.retries > 0 && tk.attempts > cfg.retries { + return true + } + if cfg.escalateAfter > 0 && !tk.firstAttemptAt.IsZero() && now.Sub(tk.firstAttemptAt) > cfg.escalateAfter { + return true + } + return false +} + +// escalate emits reaction.escalated and notifies the human. The caller has +// already set tracker.escalated under the lock, which silences further +// auto-dispatch for this reaction until the tracker clears. +func (m *Manager) escalate(ctx context.Context, id domain.SessionID, key reactionKey) error { + return m.notifier.Notify(ctx, ports.OrchestratorEvent{ + Type: "reaction.escalated", + Priority: ports.PriorityUrgent, + SessionID: id, + Message: fmt.Sprintf("auto-handling of %q is exhausted and needs a human.", key), + Data: map[string]any{"reaction": string(key)}, + }) +} + +func composeMessage(cfg reactionConfig, rc reactionContext) string { + if rc.ciFailureLogTail != nil && *rc.ciFailureLogTail != "" { + return cfg.message + "\n\nFailing output:\n" + *rc.ciFailureLogTail + } + return cfg.message +} + +// trackerFor returns the tracker for (id,key), creating it on first use. The +// caller must hold trackerMu. +func (m *Manager) trackerFor(id domain.SessionID, key reactionKey) *reactionTracker { + k := trackerKey{id: id, key: key} + tk := m.trackers[k] + if tk == nil { + tk = &reactionTracker{} + m.trackers[k] = tk + } + return tk +} + +func (m *Manager) clearTracker(id domain.SessionID, key reactionKey) { + m.trackerMu.Lock() + delete(m.trackers, trackerKey{id: id, key: key}) + m.trackerMu.Unlock() +} + +// TickEscalations fires the duration-based escalations the synchronous LCM +// cannot wake itself for. The reaper calls it on a timer; it escalates any +// not-yet-escalated tracker whose escalateAfter has elapsed. Notifications are +// sent outside the lock so agent/notifier latency never blocks tracker access. +func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { + type due struct { + id domain.SessionID + key reactionKey + } + var fire []due + + m.trackerMu.Lock() + for k, tk := range m.trackers { + if tk.escalated { + continue + } + cfg := defaultReactions[k.key] + if cfg.escalateAfter > 0 && !tk.firstAttemptAt.IsZero() && now.Sub(tk.firstAttemptAt) > cfg.escalateAfter { + tk.escalated = true + fire = append(fire, due{id: k.id, key: k.key}) + } + } + m.trackerMu.Unlock() + + for _, d := range fire { + if err := m.escalate(ctx, d.id, d.key); err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/lifecycle/reactions_test.go b/backend/internal/lifecycle/reactions_test.go new file mode 100644 index 0000000000..cf00309d50 --- /dev/null +++ b/backend/internal/lifecycle/reactions_test.go @@ -0,0 +1,288 @@ +package lifecycle + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// newReactive wires a Manager with handles on the recording fakes so reaction +// tests can assert what was sent/notified. clock is pinned to t0 for +// deterministic escalation stamping. +func newReactive() (*Manager, *fakeStore, *recordingNotifier, *recordingMessenger) { + store := newFakeStore() + notf := &recordingNotifier{} + msgr := &recordingMessenger{} + m := New(store, notf, msgr) + m.clock = func() time.Time { return t0 } + return m, store, notf, msgr +} + +func lcOpenPR(reason domain.PRReason) domain.CanonicalSessionLifecycle { + l := lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive) + l.PR = domain.PRSubstate{State: domain.PROpen, Reason: reason, Number: 7} + return l +} + +func notifyCount(n *recordingNotifier, eventType string) int { + n.mu.Lock() + defer n.mu.Unlock() + c := 0 + for _, e := range n.events { + if e.Type == eventType { + c++ + } + } + return c +} + +func ctx() context.Context { return context.Background() } + +// ---- right reaction per transition ---- + +func TestReaction_CIFailedSendsToAgentWithLogTail(t *testing.T) { + m, store, notf, msgr := newReactive() + store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) + + tail := "build failed\nundefined: foo" + err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, CISummary: ports.CIFailing, + PRNumber: 7, CIFailureLogTail: &tail, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + + if len(msgr.sent) != 1 { + t.Fatalf("want 1 send, got %d", len(msgr.sent)) + } + if got := msgr.sent[0].Message; !strings.Contains(got, "CI is failing") || !strings.Contains(got, tail) { + t.Errorf("message missing base text or log tail: %q", got) + } + if notifyCount(notf, "reaction.escalated") != 0 { + t.Error("a first failure must not escalate") + } +} + +func TestReaction_ApprovedAndGreenNotifiesNeverAutoMerges(t *testing.T) { + m, store, notf, msgr := newReactive() + store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) + + err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewApproved, + Mergeability: ports.Mergeability{Mergeable: true}, PRNumber: 7, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + + // approved-and-green is notify (human decides to merge); the agent is never + // messaged and no auto-merge fires. + if len(msgr.sent) != 0 { + t.Errorf("approved-and-green must not message the agent, got %d sends", len(msgr.sent)) + } + if notifyCount(notf, "reaction.approved-and-green") != 1 { + t.Errorf("want one approved-and-green notify, got events %+v", notf.events) + } +} + +func TestReaction_NotifyEventsForHardStates(t *testing.T) { + tests := []struct { + name string + apply func(m *Manager) + eventType string + }{ + { + name: "waiting_input -> agent-needs-input", + apply: func(m *Manager) { applyActivity(m, domain.ActivityWaitingInput) }, + eventType: "reaction.agent-needs-input", + }, + { + name: "blocked -> agent-stuck", + apply: func(m *Manager) { applyActivity(m, domain.ActivityBlocked) }, + eventType: "reaction.agent-stuck", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, store, notf, msgr := newReactive() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + tc.apply(m) + if notifyCount(notf, tc.eventType) != 1 { + t.Errorf("want one %s, got events %+v", tc.eventType, notf.events) + } + if len(msgr.sent) != 0 { + t.Errorf("notify reaction must not message the agent, got %d", len(msgr.sent)) + } + }) + } +} + +func TestReaction_InferredDeathNotifiesAgentExited(t *testing.T) { + m, store, notf, _ := newReactive() + store.seed(sid, detectingLC()) + + err := m.ApplyRuntimeObservation(ctx(), sid, ports.RuntimeFacts{ + RuntimeState: ports.RuntimeProbeDead, ProcessState: ports.ProcessProbeDead, ObservedAt: t0, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if l := mustLoad(t, store); domain.DeriveLegacyStatus(l) != domain.StatusKilled { + t.Fatalf("precondition: want killed, got %s", domain.DeriveLegacyStatus(l)) + } + if notifyCount(notf, "reaction.agent-exited") != 1 { + t.Errorf("want one agent-exited, got events %+v", notf.events) + } +} + +func TestReaction_PRClosedAndMerged(t *testing.T) { + tests := []struct { + name string + prState domain.PRState + eventType string + }{ + {"closed -> pr-closed", domain.PRClosed, "reaction.pr-closed"}, + {"merged -> all-complete", domain.PRMerged, "reaction.all-complete"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, store, notf, _ := newReactive() + store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) + err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: tc.prState, PRNumber: 7, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if notifyCount(notf, tc.eventType) != 1 { + t.Errorf("want one %s, got events %+v", tc.eventType, notf.events) + } + }) + } +} + +func TestReaction_OnKillRequestedDoesNotReact(t *testing.T) { + m, store, notf, msgr := newReactive() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + + if err := m.OnKillRequested(ctx(), sid, ports.KillReason{Kind: ports.KillManual}); err != nil { + t.Fatalf("kill: %v", err) + } + // An explicit human kill is not an inferred event: no agent-exited, no send. + if len(notf.events) != 0 || len(msgr.sent) != 0 { + t.Errorf("explicit kill must fire no reaction: notifies=%+v sends=%+v", notf.events, msgr.sent) + } +} + +// ---- escalation engine ---- + +func TestReaction_CIFailedNumericEscalation(t *testing.T) { + m, store, notf, msgr := newReactive() + store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) + + // ci-failed has retries 2 and is persistent, so the budget is shared across + // fail->pending->fail oscillations and escalates on the third failure. + failN := 4 + for i := 0; i < failN; i++ { + failCI(t, m) + pendingCI(t, m) // oscillate out (persistent tracker must NOT reset) + } + + if len(msgr.sent) != 2 { + t.Errorf("want 2 auto-sends before escalation, got %d", len(msgr.sent)) + } + if c := notifyCount(notf, "reaction.escalated"); c != 1 { + t.Errorf("want exactly one escalation, got %d", c) + } +} + +func TestReaction_DurationEscalationFiresOnTick(t *testing.T) { + m, store, notf, msgr := newReactive() + store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) + + // changes-requested: send once now, then escalate by duration (30m) — which + // only the reaper's TickEscalations can fire (the LCM never polls). + err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewChangesRequested, PRNumber: 7, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if len(msgr.sent) != 1 { + t.Fatalf("want one send on transition, got %d", len(msgr.sent)) + } + + if err := m.TickEscalations(ctx(), t0.Add(10*time.Minute)); err != nil { + t.Fatalf("tick: %v", err) + } + if notifyCount(notf, "reaction.escalated") != 0 { + t.Error("must not escalate before escalateAfter elapses") + } + + if err := m.TickEscalations(ctx(), t0.Add(31*time.Minute)); err != nil { + t.Fatalf("tick: %v", err) + } + if notifyCount(notf, "reaction.escalated") != 1 { + t.Errorf("want one duration escalation, got events %+v", notf.events) + } +} + +func TestReaction_NonPersistentTrackerClearsOnLeave(t *testing.T) { + m, store, _, msgr := newReactive() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + + // agent-idle has retries 2 but is NOT persistent: leaving idle clears the + // tracker, so three idle incidents each send fresh and none escalate. + for i := 0; i < 3; i++ { + applyActivity(m, domain.ActivityIdle) + applyActivity(m, domain.ActivityActive) + } + if len(msgr.sent) != 3 { + t.Errorf("want 3 idle sends (budget reset each incident), got %d", len(msgr.sent)) + } +} + +// ---- TickEscalations never writes canonical state ---- + +func TestTickEscalations_DoesNotPersist(t *testing.T) { + m, store, _, _ := newReactive() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + if err := m.TickEscalations(ctx(), t0); err != nil { + t.Fatalf("tick: %v", err) + } + if l := mustLoad(t, store); l.Revision != 0 { + t.Errorf("TickEscalations must not write canonical state, got revision=%d", l.Revision) + } +} + +// ---- helpers ---- + +func applyActivity(m *Manager, a domain.ActivityState) { + _ = m.ApplyActivitySignal(ctx(), sid, ports.ActivitySignal{ + State: ports.SignalValid, Activity: a, Timestamp: t0, Source: domain.SourceHook, + }) +} + +func failCI(t *testing.T, m *Manager) { + t.Helper() + tail := "fail" + if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, CISummary: ports.CIFailing, PRNumber: 7, CIFailureLogTail: &tail, + }); err != nil { + t.Fatalf("failCI: %v", err) + } +} + +func pendingCI(t *testing.T, m *Manager) { + t.Helper() + if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, CISummary: ports.CIPending, ReviewDecision: ports.ReviewPending, PRNumber: 7, + }); err != nil { + t.Fatalf("pendingCI: %v", err) + } +} From b2161d5582bfae0e477045bc191ea67ae314b797 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 02:26:45 +0530 Subject: [PATCH 017/250] fix(lifecycle): clear ci-failed tracker on recovery/incident-over (PR #6 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review finding #1: the persistent ci-failed tracker leaked and could stale-silence a future regression. It was only cleared when leaving the ci-failed reaction AND incidentOver held at that moment — so a recovery to another open-PR state (ci-failed -> approved -> merged) never cleared it. - react() now clears ALL of a session's trackers when the state REACHED is incident-over (PR resolved / session terminal) OR a genuine recovery (approved/mergeable, which the open-PR ladder guarantees means CI is no longer failing). Keyed on the state reached, not the one left, since the recovery transition is typically review_pending->approved (empty beforeKey). - Persistent ci-failed still survives the ambiguous review_pending limbo, so fail->pending->fail keeps one shared budget (§4.2). - Document the out-of-lock react() dispatch caveat for the daemon integration step (review #2) and the intentionally-skipped agent-stuck 10m threshold. Tests: re-arm after a genuine recovery (regression re-nudges, not silenced); all session trackers cleared once the incident is over. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/lifecycle/reactions.go | 68 ++++++++++++++++++-- backend/internal/lifecycle/reactions_test.go | 62 ++++++++++++++++++ 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index c9ee7f5e8d..df9904f3ba 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -99,6 +99,10 @@ var defaultReactions = map[reactionKey]reactionConfig{ eventType: "reaction.approved-and-green", }, reactionAgentStuck: { + // §4.2 lists a threshold: 10m here; it is intentionally not gated — entry + // into stuck is already debounced upstream by the detecting->stuck + // quarantine (DETECTING_MAX_ATTEMPTS/DURATION), so a second timer would be + // redundant. action: actionNotify, priority: ports.PriorityUrgent, message: "Agent is stuck and needs attention.", eventType: "reaction.agent-stuck", @@ -187,6 +191,15 @@ type reactionTracker struct { // the reaction we left, then dispatch the reaction for the one we entered. It // fires only on a genuine reaction change, so re-persisting the same state does // not re-dispatch. Synchronous by design (see file header). +// +// Integration-time caveat: react runs AFTER withLock releases (deliberately, so +// a busy-waiting send-to-agent never holds the per-session mutex). Under a live +// daemon with concurrent observers (SCM poller + reaper + activity ingest) the +// afterLC snapshot can be stale by dispatch time — e.g. a ci-failed send firing +// after the session already moved to approved. Tests are single-threaded so it +// is not observable yet; when the daemon lands, give react a per-session +// ordering (a small react queue) or re-check the triggering state before +// dispatching. func (m *Manager) react(ctx context.Context, id domain.SessionID, tr *transition, rc reactionContext) error { if tr == nil { return nil @@ -196,25 +209,55 @@ func (m *Manager) react(ctx context.Context, id domain.SessionID, tr *transition changed := beforeKey != afterKey - if hadBefore && (!hasAfter || changed) { - // A persistent tracker survives oscillation within an open PR; it only - // resets once the incident is over. - if !defaultReactions[beforeKey].persistent || incidentOver(tr.afterLC) { + switch { + case incidentOver(tr.afterLC) || recovered(tr.afterLC): + // The PR-pipeline incident has ended — the PR resolved (merged/closed), + // the session went terminal, or it reached an approved/green state. Every + // tracker for this session is now stale, including a persistent ci-failed + // one. This is keyed on the state REACHED, not the one left: the recovery + // transition is typically review_pending->approved (beforeKey empty), so + // clearing only beforeKey would leak the ci-failed tracker and leave its + // escalated=true to silence a future regression. Clear them all. + m.clearSessionTrackers(id) + case hadBefore && (!hasAfter || changed): + // Within an unresolved open PR: a normal tracker resets when its state is + // left. A persistent one (ci-failed) is NOT cleared here — it must survive + // the ambiguous review_pending limbo (the fail->pending->fail flap, §4.2); + // it only resets via the recovery/incident-over branch above. + if !defaultReactions[beforeKey].persistent { m.clearTracker(id, beforeKey) } } + if hasAfter && (!hadBefore || changed) { return m.executeReaction(ctx, id, afterKey, rc) } return nil } -// incidentOver reports that a PR-pipeline incident has truly ended, so even a -// persistent tracker (ci-failed) may reset. +// incidentOver reports that a PR-pipeline incident has truly ended (PR no longer +// open, or the session terminal), so all trackers for the session may reset. func incidentOver(l domain.CanonicalSessionLifecycle) bool { return l.PR.State != domain.PROpen || isTerminal(l.Session.State) } +// recovered reports a genuinely-green open PR: an approved/mergeable state, which +// unambiguously means CI is no longer failing (the open-PR ladder ranks ci_failing +// above approved, so an approved display cannot coexist with failing CI). Unlike +// the ambiguous review_pending state — which may just be CI re-running — reaching +// this ends a ci-failed incident and re-arms its budget. +func recovered(l domain.CanonicalSessionLifecycle) bool { + if l.PR.State != domain.PROpen { + return false + } + switch l.PR.Reason { + case domain.PRReasonApproved, domain.PRReasonMergeReady: + return true + default: + return false + } +} + func (m *Manager) executeReaction(ctx context.Context, id domain.SessionID, key reactionKey, rc reactionContext) error { cfg := defaultReactions[key] switch cfg.action { @@ -312,6 +355,19 @@ func (m *Manager) clearTracker(id domain.SessionID, key reactionKey) { m.trackerMu.Unlock() } +// clearSessionTrackers drops every tracker for a session — used when its +// incident is over, so no budget (and no stale escalated=true) survives into a +// later unrelated incident. +func (m *Manager) clearSessionTrackers(id domain.SessionID) { + m.trackerMu.Lock() + for k := range m.trackers { + if k.id == id { + delete(m.trackers, k) + } + } + m.trackerMu.Unlock() +} + // TickEscalations fires the duration-based escalations the synchronous LCM // cannot wake itself for. The reaper calls it on a timer; it escalates any // not-yet-escalated tracker whose escalateAfter has elapsed. Notifications are diff --git a/backend/internal/lifecycle/reactions_test.go b/backend/internal/lifecycle/reactions_test.go index cf00309d50..a1bbc3c652 100644 --- a/backend/internal/lifecycle/reactions_test.go +++ b/backend/internal/lifecycle/reactions_test.go @@ -247,6 +247,68 @@ func TestReaction_NonPersistentTrackerClearsOnLeave(t *testing.T) { } } +func TestReaction_CIFailedRearmsOnGenuineRecovery(t *testing.T) { + m, store, notf, msgr := newReactive() + store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) + + // Drain the ci-failed budget to escalation (silenced thereafter). + for i := 0; i < 4; i++ { + failCI(t, m) + pendingCI(t, m) + } + if notifyCount(notf, "reaction.escalated") != 1 { + t.Fatalf("precondition: want one escalation, got %d", notifyCount(notf, "reaction.escalated")) + } + sentBefore := len(msgr.sent) + + // A genuine recovery (approved + green) ends the incident and re-arms the + // budget; a later regression must re-nudge the agent, not stay silenced. + if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewApproved, + Mergeability: ports.Mergeability{Mergeable: true}, PRNumber: 7, + }); err != nil { + t.Fatalf("recover: %v", err) + } + failCI(t, m) + + if len(msgr.sent) != sentBefore+1 { + t.Errorf("regression after recovery must re-nudge the agent: sends %d -> %d", sentBefore, len(msgr.sent)) + } +} + +func TestReaction_IncidentOverClearsAllSessionTrackers(t *testing.T) { + m, store, _, _ := newReactive() + store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) + + failCI(t, m) // creates a persistent ci-failed tracker + if sessionTrackerCount(m, sid) == 0 { + t.Fatalf("precondition: expected a ci-failed tracker") + } + + // Merging ends the incident; no tracker (and no stale escalated=true) may + // survive for the session. + if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PRMerged, PRNumber: 7, + }); err != nil { + t.Fatalf("merge: %v", err) + } + if n := sessionTrackerCount(m, sid); n != 0 { + t.Errorf("incident over must clear all trackers, %d left", n) + } +} + +func sessionTrackerCount(m *Manager, id domain.SessionID) int { + m.trackerMu.Lock() + defer m.trackerMu.Unlock() + c := 0 + for k := range m.trackers { + if k.id == id { + c++ + } + } + return c +} + // ---- TickEscalations never writes canonical state ---- func TestTickEscalations_DoesNotPersist(t *testing.T) { From 5081cf794be51379e1840aa7516e0b5bf7ec2508 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 13:11:00 +0530 Subject: [PATCH 018/250] fix(lifecycle): kill clears trackers, send-failure budget, inclusive escalation (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OnKillRequested now clears the session's escalation trackers after a successful kill, so a later duration-based TickEscalations can't emit reaction.escalated for a dead session (dispatch is still skipped). - sendToAgent rolls back the attempt (and firstAttemptAt when it set it) on a messenger.Send error, so undelivered messages don't march a reaction toward escalation — honoring "send failures retry next tick" (§4.3). - Duration escalation now uses an inclusive boundary (>=) in both shouldEscalate and TickEscalations, so a 30m reaction escalates at exactly 30m instead of waiting for the next tick. Tests: kill clears trackers + no post-kill escalation; repeated failed delivery never escalates; duration escalation fires at exactly escalateAfter. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/lifecycle/manager.go | 9 ++- backend/internal/lifecycle/reactions.go | 27 ++++++-- backend/internal/lifecycle/reactions_test.go | 70 +++++++++++++++++++- 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index fd12924962..2581fea0ed 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -340,7 +340,14 @@ func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, r po } return patch, changed, nil }) - return err + if err != nil { + return err + } + // A kill is terminal but bypasses react()'s incident-over cleanup (it fires + // no reaction). Drop any escalation trackers here so a later duration-based + // TickEscalations can't emit reaction.escalated for a dead session. + m.clearSessionTrackers(id) + return nil } // ---- patch helpers (diff -> sparse merge-patch) ---- diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index df9904f3ba..544f152f58 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -291,7 +291,8 @@ func (m *Manager) sendToAgent(ctx context.Context, id domain.SessionID, key reac return nil // silenced until the condition clears the tracker } now := m.clock() - if tk.firstAttemptAt.IsZero() { + freshFirst := tk.firstAttemptAt.IsZero() + if freshFirst { tk.firstAttemptAt = now } tk.attempts++ @@ -302,16 +303,30 @@ func (m *Manager) sendToAgent(ctx context.Context, id domain.SessionID, key reac } m.trackerMu.Unlock() - // A delivery failure does not consume escalation budget beyond this attempt: - // the next relevant transition simply tries again (distillation §4.3). - return m.messenger.Send(ctx, id, composeMessage(cfg, rc)) + if err := m.messenger.Send(ctx, id, composeMessage(cfg, rc)); err != nil { + // A delivery failure must not consume escalation budget: roll this + // attempt back so the next relevant transition retries from the same + // point rather than marching toward escalation on undelivered messages + // (distillation §4.3). + m.trackerMu.Lock() + tk.attempts-- + if freshFirst { + tk.firstAttemptAt = time.Time{} + } + m.trackerMu.Unlock() + return err + } + return nil } +// shouldEscalate uses inclusive boundaries: escalate once the numeric cap is +// exceeded or once exactly escalateAfter has elapsed (don't wait for the next +// tick to cross a strict threshold). func shouldEscalate(tk *reactionTracker, cfg reactionConfig, now time.Time) bool { if cfg.retries > 0 && tk.attempts > cfg.retries { return true } - if cfg.escalateAfter > 0 && !tk.firstAttemptAt.IsZero() && now.Sub(tk.firstAttemptAt) > cfg.escalateAfter { + if cfg.escalateAfter > 0 && !tk.firstAttemptAt.IsZero() && now.Sub(tk.firstAttemptAt) >= cfg.escalateAfter { return true } return false @@ -385,7 +400,7 @@ func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { continue } cfg := defaultReactions[k.key] - if cfg.escalateAfter > 0 && !tk.firstAttemptAt.IsZero() && now.Sub(tk.firstAttemptAt) > cfg.escalateAfter { + if cfg.escalateAfter > 0 && !tk.firstAttemptAt.IsZero() && now.Sub(tk.firstAttemptAt) >= cfg.escalateAfter { tk.escalated = true fire = append(fire, due{id: k.id, key: k.key}) } diff --git a/backend/internal/lifecycle/reactions_test.go b/backend/internal/lifecycle/reactions_test.go index a1bbc3c652..e90e8881e8 100644 --- a/backend/internal/lifecycle/reactions_test.go +++ b/backend/internal/lifecycle/reactions_test.go @@ -2,6 +2,7 @@ package lifecycle import ( "context" + "fmt" "strings" "testing" "time" @@ -10,6 +11,15 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) +// failingMessenger always fails delivery, counting attempts — used to assert a +// send failure does not consume escalation budget. +type failingMessenger struct{ attempts int } + +func (f *failingMessenger) Send(_ context.Context, _ domain.SessionID, _ string) error { + f.attempts++ + return fmt.Errorf("messenger unavailable") +} + // newReactive wires a Manager with handles on the recording fakes so reaction // tests can assert what was sent/notified. clock is pinned to t0 for // deterministic escalation stamping. @@ -224,11 +234,67 @@ func TestReaction_DurationEscalationFiresOnTick(t *testing.T) { t.Error("must not escalate before escalateAfter elapses") } - if err := m.TickEscalations(ctx(), t0.Add(31*time.Minute)); err != nil { + // Inclusive boundary: escalate at exactly escalateAfter (30m), not only past it. + if err := m.TickEscalations(ctx(), t0.Add(30*time.Minute)); err != nil { t.Fatalf("tick: %v", err) } if notifyCount(notf, "reaction.escalated") != 1 { - t.Errorf("want one duration escalation, got events %+v", notf.events) + t.Errorf("want one duration escalation at exactly 30m, got events %+v", notf.events) + } +} + +func TestReaction_KillClearsEscalationTrackers(t *testing.T) { + m, store, notf, _ := newReactive() + store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) + + // changes-requested creates a duration-based tracker. + if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewChangesRequested, PRNumber: 7, + }); err != nil { + t.Fatalf("apply: %v", err) + } + if sessionTrackerCount(m, sid) == 0 { + t.Fatalf("precondition: expected a tracker") + } + + if err := m.OnKillRequested(ctx(), sid, ports.KillReason{Kind: ports.KillManual}); err != nil { + t.Fatalf("kill: %v", err) + } + if n := sessionTrackerCount(m, sid); n != 0 { + t.Errorf("kill must clear trackers, %d left", n) + } + // A later duration tick must not escalate a dead session. + if err := m.TickEscalations(ctx(), t0.Add(time.Hour)); err != nil { + t.Fatalf("tick: %v", err) + } + if c := notifyCount(notf, "reaction.escalated"); c != 0 { + t.Errorf("killed session must not escalate, got %d", c) + } +} + +func TestReaction_SendFailureDoesNotBurnBudget(t *testing.T) { + store := newFakeStore() + notf := &recordingNotifier{} + fm := &failingMessenger{} + m := New(store, notf, fm) + m.clock = func() time.Time { return t0 } + store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) + + tail := "fail" + failing := ports.SCMFacts{Fetched: true, PRState: domain.PROpen, CISummary: ports.CIFailing, PRNumber: 7, CIFailureLogTail: &tail} + pending := ports.SCMFacts{Fetched: true, PRState: domain.PROpen, CISummary: ports.CIPending, ReviewDecision: ports.ReviewPending, PRNumber: 7} + + // ci-failed has retries 2; with every delivery failing, the budget is rolled + // back each time, so even 5 failures never escalate. + for i := 0; i < 5; i++ { + _ = m.ApplySCMObservation(ctx(), sid, failing) // returns the delivery error + _ = m.ApplySCMObservation(ctx(), sid, pending) + } + if fm.attempts < 5 { + t.Errorf("expected at least 5 send attempts, got %d", fm.attempts) + } + if c := notifyCount(notf, "reaction.escalated"); c != 0 { + t.Errorf("undelivered messages must not escalate, got %d", c) } } From 538210844edb167220a038a8c031a71603ed56f9 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 13:43:40 +0530 Subject: [PATCH 019/250] feat(session): implement Session Manager (spawn/kill/list/get/send/restore/cleanup) Implements ports.SessionManager against fakes for the outbound ports. The SM is the explicit-mutation half of the lane: it drives Runtime/Agent/Workspace, seeds the initial lifecycle, and routes outcomes to the LCM (OnSpawnCompleted / OnKillRequested). It never derives observed state and is the single producer of the derived display status (attached on read, never persisted). - Spawn: Workspace.Create -> Runtime.Create (AO_* identity env) -> Seed -> OnSpawnCompleted, with eager rollback of completed steps on failure. - Kill: OnKillRequested first -> Runtime.Destroy -> Workspace.Destroy, honoring the worktree-remove safety (refusal surfaced, never forced). - List/Get: derive status via DeriveLegacyStatus. Send: via AgentMessenger. Restore: re-seed (reopen) + relaunch via GetRestoreCommand. Cleanup: reclaim terminal sessions, skip worktrees holding uncommitted work. Store-contract additions (co-owned with Tom's persistence layer, flagged for review): LifecycleStore.Seed (explicit create-with-identity; OnSpawnCompleted requires a seeded record) and LifecycleStore.Get (single record-with-identity read; Load is lifecycle-only). Lifecycle test fake updated to satisfy both. Tests route through the real LCM Manager (wrapped to record call order). Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/lifecycle/fakes_test.go | 24 ++ backend/internal/ports/outbound.go | 18 + backend/internal/session/fakes_test.go | 399 ++++++++++++++++++++++ backend/internal/session/manager.go | 401 +++++++++++++++++++++++ backend/internal/session/manager_test.go | 388 ++++++++++++++++++++++ 5 files changed, 1230 insertions(+) create mode 100644 backend/internal/session/fakes_test.go create mode 100644 backend/internal/session/manager.go create mode 100644 backend/internal/session/manager_test.go diff --git a/backend/internal/lifecycle/fakes_test.go b/backend/internal/lifecycle/fakes_test.go index 904693aa44..cc47ad847d 100644 --- a/backend/internal/lifecycle/fakes_test.go +++ b/backend/internal/lifecycle/fakes_test.go @@ -90,6 +90,30 @@ func (s *fakeStore) PatchLifecycle(_ context.Context, id domain.SessionID, p por return nil } +func (s *fakeStore) Seed(_ context.Context, rec domain.SessionRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.records[rec.ID]; ok { + return fmt.Errorf("seed: session %s already exists", rec.ID) + } + if rec.Lifecycle.Version == 0 { + rec.Lifecycle.Version = domain.LifecycleVersion + } + r := rec + s.records[rec.ID] = &r + return nil +} + +func (s *fakeStore) Get(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + rec, ok := s.records[id] + if !ok { + return domain.SessionRecord{}, false, nil + } + return *rec, true, nil +} + func (s *fakeStore) List(_ context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 7a3649ae53..a9c03e22ae 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -12,12 +12,30 @@ import ( // // List returns persistence records (no derived status); the Session Manager // turns those into domain.Session by attaching the derived display status. +// +// Seed and Get are the two record-with-identity methods the Session Manager +// needs that the LCM does not: Load returns lifecycle only (all the decider +// needs), so the SM read-model and explicit-create path would otherwise have no +// way to write or read a record's identity (ID/ProjectID/IssueID/Kind/CreatedAt) +// by id. (Co-owned with Tom's persistence layer — added here to close that gap.) type LifecycleStore interface { Load(ctx context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) PatchLifecycle(ctx context.Context, id domain.SessionID, patch LifecyclePatch) error List(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) GetMetadata(ctx context.Context, id domain.SessionID) (map[string]string, error) PatchMetadata(ctx context.Context, id domain.SessionID, kv map[string]string) error + + // Seed creates a new record with its identity and initial lifecycle. It is + // the SM's explicit-create path (the LCM only ever patches existing records); + // OnSpawnCompleted requires a seeded record, so Spawn calls this first. It + // must reject a seed for an id that already exists rather than overwrite — + // re-seeding an existing session (e.g. Restore) goes through PatchLifecycle. + Seed(ctx context.Context, rec domain.SessionRecord) error + + // Get returns a single full record (with identity) by id. Load is + // lifecycle-only, so the SM uses this to build the read-model and to + // reconstruct teardown handles for Kill/Restore on one id. + Get(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) } // LifecyclePatch is a sparse merge-patch: a nil field is left untouched, a diff --git a/backend/internal/session/fakes_test.go b/backend/internal/session/fakes_test.go new file mode 100644 index 0000000000..d8e4b2483b --- /dev/null +++ b/backend/internal/session/fakes_test.go @@ -0,0 +1,399 @@ +package session + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// callLog records the cross-fake call order so tests can assert pipeline +// sequencing (e.g. OnKillRequested before Runtime.Destroy before Workspace.Destroy). +type callLog struct { + mu sync.Mutex + calls []string +} + +func (c *callLog) add(s string) { + c.mu.Lock() + defer c.mu.Unlock() + c.calls = append(c.calls, s) +} + +func (c *callLog) snapshot() []string { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]string, len(c.calls)) + copy(out, c.calls) + return out +} + +// indexOf returns the position of the first call equal to name, or -1. +func (c *callLog) indexOf(name string) int { + for i, s := range c.snapshot() { + if s == name { + return i + } + } + return -1 +} + +// ---- fakeStore: in-memory LifecycleStore with faithful merge-patch + Seed/Get ---- + +type fakeStore struct { + mu sync.Mutex + records map[domain.SessionID]*domain.SessionRecord + metadata map[domain.SessionID]map[string]string +} + +var _ ports.LifecycleStore = (*fakeStore)(nil) + +func newFakeStore() *fakeStore { + return &fakeStore{ + records: map[domain.SessionID]*domain.SessionRecord{}, + metadata: map[domain.SessionID]map[string]string{}, + } +} + +func (s *fakeStore) Seed(_ context.Context, rec domain.SessionRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.records[rec.ID]; ok { + return fmt.Errorf("seed: session %s already exists", rec.ID) + } + if rec.Lifecycle.Version == 0 { + rec.Lifecycle.Version = domain.LifecycleVersion + } + r := rec + s.records[rec.ID] = &r + return nil +} + +func (s *fakeStore) Get(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + rec, ok := s.records[id] + if !ok { + return domain.SessionRecord{}, false, nil + } + return s.withMetadata(*rec), true, nil +} + +func (s *fakeStore) Load(_ context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + rec, ok := s.records[id] + if !ok { + return domain.CanonicalSessionLifecycle{}, false, nil + } + return rec.Lifecycle, true, nil +} + +func (s *fakeStore) PatchLifecycle(_ context.Context, id domain.SessionID, p ports.LifecyclePatch) error { + s.mu.Lock() + defer s.mu.Unlock() + + rec, ok := s.records[id] + if !ok { + rec = &domain.SessionRecord{ID: id, Lifecycle: domain.CanonicalSessionLifecycle{Version: domain.LifecycleVersion}} + s.records[id] = rec + } + l := &rec.Lifecycle + + if p.ExpectedRevision != nil && *p.ExpectedRevision != l.Revision { + return fmt.Errorf("revision mismatch for %s: have %d, expected %d", id, l.Revision, *p.ExpectedRevision) + } + + if p.Session != nil { + l.Session = *p.Session + } + if p.PR != nil { + l.PR = *p.PR + } + if p.Runtime != nil { + l.Runtime = *p.Runtime + } + if p.Activity != nil { + l.Activity = *p.Activity + } + switch { + case p.ClearDetecting: + l.Detecting = nil + case p.Detecting != nil: + d := *p.Detecting + l.Detecting = &d + } + + l.Version = domain.LifecycleVersion + l.Revision++ + rec.UpdatedAt = time.Now() + return nil +} + +func (s *fakeStore) List(_ context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []domain.SessionRecord + for _, rec := range s.records { + if rec.ProjectID == project { + out = append(out, s.withMetadata(*rec)) + } + } + return out, nil +} + +func (s *fakeStore) GetMetadata(_ context.Context, id domain.SessionID) (map[string]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return cloneMap(s.metadata[id]), nil +} + +func (s *fakeStore) PatchMetadata(_ context.Context, id domain.SessionID, kv map[string]string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.metadata[id] == nil { + s.metadata[id] = map[string]string{} + } + for k, v := range kv { + s.metadata[id][k] = v + } + return nil +} + +// withMetadata attaches the separately-stored metadata to a record copy (a real +// store would return them together). Caller holds s.mu. +func (s *fakeStore) withMetadata(rec domain.SessionRecord) domain.SessionRecord { + if md := s.metadata[rec.ID]; len(md) > 0 { + rec.Metadata = cloneMap(md) + } + return rec +} + +// ---- fakeRuntime ---- + +type fakeRuntime struct { + log *callLog + createErr error + alive bool + + created []ports.RuntimeConfig + destroyed []ports.RuntimeHandle + sent []string +} + +var _ ports.Runtime = (*fakeRuntime)(nil) + +func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { + r.log.add("Runtime.Create") + if r.createErr != nil { + return ports.RuntimeHandle{}, r.createErr + } + r.created = append(r.created, cfg) + return ports.RuntimeHandle{ID: "rt-" + string(cfg.SessionID), RuntimeName: "tmux"}, nil +} + +func (r *fakeRuntime) Destroy(_ context.Context, h ports.RuntimeHandle) error { + r.log.add("Runtime.Destroy") + r.destroyed = append(r.destroyed, h) + return nil +} + +func (r *fakeRuntime) SendMessage(_ context.Context, _ ports.RuntimeHandle, message string) error { + r.sent = append(r.sent, message) + return nil +} + +func (r *fakeRuntime) GetOutput(_ context.Context, _ ports.RuntimeHandle, _ int) (string, error) { + return "", nil +} + +func (r *fakeRuntime) IsAlive(_ context.Context, _ ports.RuntimeHandle) (bool, error) { + return r.alive, nil +} + +// ---- fakeAgent ---- + +type fakeAgent struct { + env map[string]string +} + +var _ ports.Agent = (*fakeAgent)(nil) + +func (a *fakeAgent) GetLaunchCommand(_ ports.AgentConfig) string { return "claude" } + +func (a *fakeAgent) GetEnvironment(_ ports.AgentConfig) map[string]string { return cloneMap(a.env) } + +func (a *fakeAgent) ProbeProcess(_ context.Context, _ ports.RuntimeHandle) (ports.ProcessProbe, error) { + return ports.ProcessProbeAlive, nil +} + +func (a *fakeAgent) GetRestoreCommand(agentSessionID string) string { + return "claude --resume " + agentSessionID +} + +// ---- fakeWorkspace (with worktree-remove refusal mode) ---- + +type fakeWorkspace struct { + log *callLog + createErr error + refuse map[string]bool // path -> still registered after prune (uncommitted work) + created []ports.WorkspaceConfig + destroyed []ports.WorkspaceInfo + restoredID []domain.SessionID +} + +var _ ports.Workspace = (*fakeWorkspace)(nil) + +func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + w.log.add("Workspace.Create") + if w.createErr != nil { + return ports.WorkspaceInfo{}, w.createErr + } + w.created = append(w.created, cfg) + return workspaceFor(cfg), nil +} + +func (w *fakeWorkspace) Destroy(_ context.Context, info ports.WorkspaceInfo) error { + w.log.add("Workspace.Destroy") + if w.refuse[info.Path] { + // Worktree-remove safety: after `git worktree prune` the path is still + // registered, so it may hold the agent's uncommitted work — refuse. + return fmt.Errorf("workspace: refusing to rm -rf %s: still registered after prune", info.Path) + } + w.destroyed = append(w.destroyed, info) + return nil +} + +func (w *fakeWorkspace) List(_ context.Context, _ domain.ProjectID) ([]ports.WorkspaceInfo, error) { + return nil, nil +} + +func (w *fakeWorkspace) Restore(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + w.log.add("Workspace.Restore") + w.restoredID = append(w.restoredID, cfg.SessionID) + return workspaceFor(cfg), nil +} + +func workspaceFor(cfg ports.WorkspaceConfig) ports.WorkspaceInfo { + return ports.WorkspaceInfo{ + Path: "/tmp/ws/" + string(cfg.SessionID), + Branch: cfg.Branch, + SessionID: cfg.SessionID, + ProjectID: cfg.ProjectID, + } +} + +// ---- recordingMessenger ---- + +type recordingMessenger struct { + sent []struct { + ID domain.SessionID + Message string + } +} + +var _ ports.AgentMessenger = (*recordingMessenger)(nil) + +func (m *recordingMessenger) Send(_ context.Context, id domain.SessionID, message string) error { + m.sent = append(m.sent, struct { + ID domain.SessionID + Message string + }{id, message}) + return nil +} + +// ---- noopNotifier ---- + +type noopNotifier struct{} + +var _ ports.Notifier = (*noopNotifier)(nil) + +func (noopNotifier) Notify(_ context.Context, _ ports.OrchestratorEvent) error { return nil } + +// ---- recordingLCM: wraps the REAL lifecycle.Manager and logs SM-facing calls ---- + +type recordingLCM struct { + log *callLog + inner ports.LifecycleManager +} + +var _ ports.LifecycleManager = (*recordingLCM)(nil) + +func (l *recordingLCM) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error { + l.log.add("OnSpawnCompleted") + return l.inner.OnSpawnCompleted(ctx, id, o) +} + +func (l *recordingLCM) OnKillRequested(ctx context.Context, id domain.SessionID, r ports.KillReason) error { + l.log.add("OnKillRequested") + return l.inner.OnKillRequested(ctx, id, r) +} + +func (l *recordingLCM) ApplySCMObservation(ctx context.Context, id domain.SessionID, f ports.SCMFacts) error { + return l.inner.ApplySCMObservation(ctx, id, f) +} + +func (l *recordingLCM) ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error { + return l.inner.ApplyRuntimeObservation(ctx, id, f) +} + +func (l *recordingLCM) ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error { + return l.inner.ApplyActivitySignal(ctx, id, s) +} + +func (l *recordingLCM) TickEscalations(ctx context.Context, now time.Time) error { + return l.inner.TickEscalations(ctx, now) +} + +// ---- harness: wires the SM against the fakes + the real LCM ---- + +type harness struct { + sm *Manager + store *fakeStore + runtime *fakeRuntime + agent *fakeAgent + workspace *fakeWorkspace + messenger *recordingMessenger + log *callLog +} + +var fixedTime = time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + +func newHarness(id domain.SessionID) *harness { + log := &callLog{} + store := newFakeStore() + rt := &fakeRuntime{log: log, alive: true} + ag := &fakeAgent{env: map[string]string{"BASE": "1"}} + ws := &fakeWorkspace{log: log, refuse: map[string]bool{}} + msg := &recordingMessenger{} + + lcm := &recordingLCM{log: log, inner: lifecycle.New(store, noopNotifier{}, msg)} + + sm := New(Deps{ + Runtime: rt, + Agent: ag, + Workspace: ws, + Store: store, + Messenger: msg, + Lifecycle: lcm, + Clock: func() time.Time { return fixedTime }, + NewID: func(ports.SpawnConfig) domain.SessionID { return id }, + }) + + return &harness{sm: sm, store: store, runtime: rt, agent: ag, workspace: ws, messenger: msg, log: log} +} + +func cloneMap(in map[string]string) map[string]string { + if in == nil { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go new file mode 100644 index 0000000000..61bb92744f --- /dev/null +++ b/backend/internal/session/manager.go @@ -0,0 +1,401 @@ +// Package session implements ports.SessionManager: the explicit-mutation half +// of the lane. The SM is impure plumbing — it drives the Runtime/Agent/Workspace +// plugins to create and tear down sessions, seeds the initial lifecycle record, +// and routes mutation outcomes to the LCM (OnSpawnCompleted / OnKillRequested). +// +// It NEVER derives or observes lifecycle state: observed transitions are the +// LCM's job. The SM's only canonical writes are the explicit ones — seeding a +// new record on Spawn and re-seeding (reopening) on Restore — and it is the +// single producer of the derived display status, attached on read in List/Get +// and never persisted. +package session + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strconv" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// ErrNotFound is returned by Get/Restore when no record exists for the id. +var ErrNotFound = errors.New("session: not found") + +// Env vars a spawned process reads to learn who it is (distillation §5.4). +const ( + EnvSessionID = "AO_SESSION_ID" + EnvProjectID = "AO_PROJECT_ID" + EnvIssueID = "AO_ISSUE_ID" +) + +// Manager implements ports.SessionManager against the outbound ports. Every +// dependency is an interface so the SM runs entirely against fakes in tests. +type Manager struct { + runtime ports.Runtime + agent ports.Agent + workspace ports.Workspace + store ports.LifecycleStore + messenger ports.AgentMessenger + lcm ports.LifecycleManager + + clock func() time.Time + newID func(ports.SpawnConfig) domain.SessionID +} + +var _ ports.SessionManager = (*Manager)(nil) + +// Deps groups the SM's collaborators. Clock and NewID are optional (defaulted) +// so production wiring only supplies the ports. +type Deps struct { + Runtime ports.Runtime + Agent ports.Agent + Workspace ports.Workspace + Store ports.LifecycleStore + Messenger ports.AgentMessenger + Lifecycle ports.LifecycleManager + + Clock func() time.Time + NewID func(ports.SpawnConfig) domain.SessionID +} + +func New(d Deps) *Manager { + m := &Manager{ + runtime: d.Runtime, + agent: d.Agent, + workspace: d.Workspace, + store: d.Store, + messenger: d.Messenger, + lcm: d.Lifecycle, + clock: d.Clock, + newID: d.NewID, + } + if m.clock == nil { + m.clock = time.Now + } + if m.newID == nil { + m.newID = defaultNewID + } + return m +} + +// ---- Spawn ---- + +// Spawn runs the create pipeline in spec order: workspace -> runtime -> seed -> +// report to the LCM. The record is seeded LATE (after the runtime is up), so a +// failure before the seed leaves no record for Cleanup to reclaim — hence each +// step eagerly rolls back the steps that already succeeded. +func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { + id := m.newID(cfg) + + ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ + ProjectID: cfg.ProjectID, + SessionID: id, + Branch: cfg.Branch, + }) + if err != nil { + return domain.Session{}, fmt.Errorf("spawn %s: workspace create: %w", id, err) + } + + agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: buildPrompt(cfg)} + handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ + SessionID: id, + WorkspacePath: ws.Path, + LaunchCommand: m.agent.GetLaunchCommand(agentCfg), + Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, cfg.ProjectID, cfg.IssueID), + }) + if err != nil { + m.rollbackWorkspace(ctx, ws) // nothing seeded yet + return domain.Session{}, fmt.Errorf("spawn %s: runtime create: %w", id, err) + } + + if err := m.store.Seed(ctx, seedRecord(id, cfg, m.clock())); err != nil { + m.rollbackRuntime(ctx, handle) + m.rollbackWorkspace(ctx, ws) + return domain.Session{}, fmt.Errorf("spawn %s: seed: %w", id, err) + } + + outcome := ports.SpawnOutcome{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle} + if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { + // The record is seeded but the runtime/workspace are about to be torn + // down. The store has no delete, so route the orphan to a terminal + // errored state (best effort) rather than strand a phantom "spawning". + _ = m.lcm.OnKillRequested(ctx, id, ports.KillReason{Kind: ports.KillError, Detail: "spawn completion failed"}) + m.rollbackRuntime(ctx, handle) + m.rollbackWorkspace(ctx, ws) + return domain.Session{}, fmt.Errorf("spawn %s: on spawn completed: %w", id, err) + } + + return m.Get(ctx, id) +} + +// rollback* are best-effort: the caller already has the originating failure, and +// there is no logger at this layer, so a secondary teardown error is dropped +// rather than masking the real cause. +func (m *Manager) rollbackWorkspace(ctx context.Context, ws ports.WorkspaceInfo) { + _ = m.workspace.Destroy(ctx, ws) +} + +func (m *Manager) rollbackRuntime(ctx context.Context, h ports.RuntimeHandle) { + _ = m.runtime.Destroy(ctx, h) +} + +// ---- Kill ---- + +// Kill records terminal intent with the LCM FIRST, then tears down the runtime +// and workspace. There is no separate Agent stop: the agent runs inside the +// runtime, so Runtime.Destroy stops it. The workspace teardown honors the +// worktree-remove safety — a refusal (path still registered after prune, so it +// may hold uncommitted work) surfaces as an error with WorkspaceFreed=false and +// is never forced. +func (m *Manager) Kill(ctx context.Context, id domain.SessionID, opts ports.KillOptions) (ports.KillResult, error) { + rec, ok, err := m.store.Get(ctx, id) + if err != nil { + return ports.KillResult{ID: id}, fmt.Errorf("kill %s: %w", id, err) + } + if !ok { + // Already gone: benign race, mirrors LCM.OnKillRequested's no-op. + return ports.KillResult{ID: id}, nil + } + meta, err := m.store.GetMetadata(ctx, id) + if err != nil { + return ports.KillResult{ID: id}, fmt.Errorf("kill %s: metadata: %w", id, err) + } + + if err := m.lcm.OnKillRequested(ctx, id, ports.KillReason{Kind: opts.Reason, Detail: opts.Detail}); err != nil { + return ports.KillResult{ID: id}, fmt.Errorf("kill %s: on kill requested: %w", id, err) + } + if err := m.runtime.Destroy(ctx, runtimeHandle(meta)); err != nil { + return ports.KillResult{ID: id}, fmt.Errorf("kill %s: runtime destroy: %w", id, err) + } + if err := m.workspace.Destroy(ctx, workspaceInfo(rec, meta)); err != nil { + return ports.KillResult{ID: id, WorkspaceFreed: false}, fmt.Errorf("kill %s: workspace destroy: %w", id, err) + } + return ports.KillResult{ID: id, WorkspaceFreed: true}, nil +} + +// ---- read-model ---- + +// List builds the read-model for a project: stored records with the display +// status derived on read. The SM is the single producer of that status. +func (m *Manager) List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) { + recs, err := m.store.List(ctx, project) + if err != nil { + return nil, fmt.Errorf("list %s: %w", project, err) + } + out := make([]domain.Session, 0, len(recs)) + for _, rec := range recs { + out = append(out, toSession(rec)) + } + return out, nil +} + +func (m *Manager) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { + rec, ok, err := m.store.Get(ctx, id) + if err != nil { + return domain.Session{}, fmt.Errorf("get %s: %w", id, err) + } + if !ok { + return domain.Session{}, fmt.Errorf("get %s: %w", id, ErrNotFound) + } + return toSession(rec), nil +} + +// ---- Send ---- + +// Send routes a message to the running agent through the AgentMessenger, which +// busy-detects and verifies delivery. +func (m *Manager) Send(ctx context.Context, id domain.SessionID, message string) error { + if err := m.messenger.Send(ctx, id, message); err != nil { + return fmt.Errorf("send %s: %w", id, err) + } + return nil +} + +// ---- Restore ---- + +// Restore relaunches a previously torn-down session in its workspace. The +// fallible I/O (workspace restore + runtime create) runs first so a failure +// touches no canonical state and never destroys the worktree (it may hold the +// agent's prior work). Only once the runtime is up do we reopen the lifecycle: +// resetting a terminal session is an explicit mutation (the SM's authority; the +// LCM's observe path would never resurrect a terminal session), and the PR axis +// is cleared. OnSpawnCompleted then flips the runtime to alive. +func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { + rec, ok, err := m.store.Get(ctx, id) + if err != nil { + return domain.Session{}, fmt.Errorf("restore %s: %w", id, err) + } + if !ok { + return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotFound) + } + meta, err := m.store.GetMetadata(ctx, id) + if err != nil { + return domain.Session{}, fmt.Errorf("restore %s: metadata: %w", id, err) + } + + ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ + ProjectID: rec.ProjectID, + SessionID: id, + Branch: meta[lifecycle.MetaBranch], + }) + if err != nil { + return domain.Session{}, fmt.Errorf("restore %s: workspace restore: %w", id, err) + } + + agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path} + handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ + SessionID: id, + WorkspacePath: ws.Path, + LaunchCommand: m.agent.GetRestoreCommand(meta[lifecycle.MetaAgentSessionID]), + Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, rec.ProjectID, rec.IssueID), + }) + if err != nil { + return domain.Session{}, fmt.Errorf("restore %s: runtime create: %w", id, err) + } + + reopen := ports.LifecyclePatch{ + Session: &domain.SessionSubstate{State: domain.SessionNotStarted, Reason: domain.ReasonSpawnRequested}, + PR: &domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonClearedOnRestore}, + } + if err := m.store.PatchLifecycle(ctx, id, reopen); err != nil { + return domain.Session{}, fmt.Errorf("restore %s: reopen: %w", id, err) + } + + outcome := ports.SpawnOutcome{ + Branch: ws.Branch, + WorkspacePath: ws.Path, + RuntimeHandle: handle, + AgentSessionID: meta[lifecycle.MetaAgentSessionID], + } + if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { + return domain.Session{}, fmt.Errorf("restore %s: on spawn completed: %w", id, err) + } + return m.Get(ctx, id) +} + +// ---- Cleanup ---- + +// Cleanup reclaims the workspaces of terminal sessions in a project. A workspace +// whose teardown is refused by the worktree-remove safety (uncommitted work) is +// skipped, never forced. Runtime teardown is best-effort (a terminal session's +// runtime is usually already gone); the workspace result decides cleaned/skipped. +func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) (ports.CleanupResult, error) { + recs, err := m.store.List(ctx, project) + if err != nil { + return ports.CleanupResult{}, fmt.Errorf("cleanup %s: %w", project, err) + } + var res ports.CleanupResult + for _, rec := range recs { + if !isTerminalSession(rec.Lifecycle.Session.State) { + continue + } + meta, err := m.store.GetMetadata(ctx, rec.ID) + if err != nil { + return res, fmt.Errorf("cleanup %s: metadata %s: %w", project, rec.ID, err) + } + _ = m.runtime.Destroy(ctx, runtimeHandle(meta)) // best effort; usually already gone + if err := m.workspace.Destroy(ctx, workspaceInfo(rec, meta)); err != nil { + res.Skipped = append(res.Skipped, rec.ID) + continue + } + res.Cleaned = append(res.Cleaned, rec.ID) + } + return res, nil +} + +// ---- helpers ---- + +func toSession(rec domain.SessionRecord) domain.Session { + return domain.Session{SessionRecord: rec, Status: domain.DeriveLegacyStatus(rec.Lifecycle)} +} + +func isTerminalSession(s domain.SessionState) bool { + return s == domain.SessionDone || s == domain.SessionTerminated +} + +// buildPrompt assembles the spawn prompt from the explicit config only; the full +// 3-layer assembly (base protocol + config-derived + user rules) lands later. +func buildPrompt(cfg ports.SpawnConfig) string { + switch { + case cfg.AgentRules == "": + return cfg.Prompt + case cfg.Prompt == "": + return cfg.AgentRules + default: + return cfg.Prompt + "\n\n" + cfg.AgentRules + } +} + +// spawnEnv overlays the AO_* identity vars onto the agent's environment without +// mutating the map the agent returned. +func spawnEnv(base map[string]string, id domain.SessionID, project domain.ProjectID, issue domain.IssueID) map[string]string { + env := make(map[string]string, len(base)+3) + for k, v := range base { + env[k] = v + } + env[EnvSessionID] = string(id) + env[EnvProjectID] = string(project) + env[EnvIssueID] = string(issue) + return env +} + +func seedRecord(id domain.SessionID, cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { + return domain.SessionRecord{ + ID: id, + ProjectID: cfg.ProjectID, + IssueID: cfg.IssueID, + Kind: cfg.Kind, + CreatedAt: now, + UpdatedAt: now, + Lifecycle: domain.CanonicalSessionLifecycle{ + Version: domain.LifecycleVersion, + Session: domain.SessionSubstate{State: domain.SessionNotStarted, Reason: domain.ReasonSpawnRequested}, + Runtime: domain.RuntimeSubstate{State: domain.RuntimeUnknown, Reason: domain.RuntimeReasonSpawnIncomplete}, + PR: domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonNotCreated}, + }, + } +} + +// runtimeHandle / workspaceInfo reconstruct teardown handles from the metadata +// the LCM persisted in OnSpawnCompleted (the metadata-key contract is shared +// with the lifecycle package). +func runtimeHandle(meta map[string]string) ports.RuntimeHandle { + return ports.RuntimeHandle{ + ID: meta[lifecycle.MetaRuntimeHandleID], + RuntimeName: meta[lifecycle.MetaRuntimeName], + } +} + +func workspaceInfo(rec domain.SessionRecord, meta map[string]string) ports.WorkspaceInfo { + return ports.WorkspaceInfo{ + Path: meta[lifecycle.MetaWorkspacePath], + Branch: meta[lifecycle.MetaBranch], + SessionID: rec.ID, + ProjectID: rec.ProjectID, + } +} + +func defaultNewID(cfg ports.SpawnConfig) domain.SessionID { + base := string(cfg.IssueID) + if base == "" { + base = string(cfg.Kind) + } + if base == "" { + base = "session" + } + return domain.SessionID(base + "-" + randHex(4)) +} + +func randHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 16) + } + return hex.EncodeToString(b) +} diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go new file mode 100644 index 0000000000..e28eded193 --- /dev/null +++ b/backend/internal/session/manager_test.go @@ -0,0 +1,388 @@ +package session + +import ( + "context" + "errors" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + testProject = domain.ProjectID("proj") + testIssue = domain.IssueID("42") +) + +func spawnCfg() ports.SpawnConfig { + return ports.SpawnConfig{ + ProjectID: testProject, + IssueID: testIssue, + Kind: domain.KindWorker, + Branch: "feat/42", + Prompt: "do the thing", + AgentRules: "be careful", + } +} + +func TestSpawn_HappyPath(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + + sess, err := h.sm.Spawn(ctx, spawnCfg()) + if err != nil { + t.Fatalf("spawn: %v", err) + } + + // Display status is derived (single producer) — a freshly spawned, not_started + // session shows as spawning. + if sess.Status != domain.StatusSpawning { + t.Errorf("status = %q, want %q", sess.Status, domain.StatusSpawning) + } + + // Record seeded with identity + initial lifecycle, then OnSpawnCompleted flipped + // the runtime axis to alive. + rec, ok, err := h.store.Get(ctx, "sess-1") + if err != nil || !ok { + t.Fatalf("get seeded record: ok=%v err=%v", ok, err) + } + if rec.ProjectID != testProject || rec.IssueID != testIssue || rec.Kind != domain.KindWorker { + t.Errorf("identity = %+v, want proj/42/worker", rec) + } + if !rec.CreatedAt.Equal(fixedTime) { + t.Errorf("createdAt = %v, want %v", rec.CreatedAt, fixedTime) + } + if got := rec.Lifecycle.Session; got.State != domain.SessionNotStarted || got.Reason != domain.ReasonSpawnRequested { + t.Errorf("session substate = %+v, want not_started/spawn_requested", got) + } + if got := rec.Lifecycle.Runtime; got.State != domain.RuntimeAlive || got.Reason != domain.RuntimeReasonProcessRunning { + t.Errorf("runtime substate = %+v, want alive/process_running", got) + } + + // Pipeline order: workspace -> runtime -> (seed) -> LCM. + wantOrder := []string{"Workspace.Create", "Runtime.Create", "OnSpawnCompleted"} + if got := h.log.snapshot(); !equalStrings(got, wantOrder) { + t.Errorf("call order = %v, want %v", got, wantOrder) + } + + // Identity env wired onto the runtime config, layered over the agent's env. + if len(h.runtime.created) != 1 { + t.Fatalf("runtime.created = %d, want 1", len(h.runtime.created)) + } + env := h.runtime.created[0].Env + for k, want := range map[string]string{ + EnvSessionID: "sess-1", + EnvProjectID: "proj", + EnvIssueID: "42", + "BASE": "1", + } { + if env[k] != want { + t.Errorf("env[%q] = %q, want %q", k, env[k], want) + } + } + + // Handles persisted to metadata for later teardown/restore. + meta, _ := h.store.GetMetadata(ctx, "sess-1") + for k, want := range map[string]string{ + lifecycle.MetaBranch: "feat/42", + lifecycle.MetaWorkspacePath: "/tmp/ws/sess-1", + lifecycle.MetaRuntimeHandleID: "rt-sess-1", + lifecycle.MetaRuntimeName: "tmux", + } { + if meta[k] != want { + t.Errorf("meta[%q] = %q, want %q", k, meta[k], want) + } + } +} + +func TestSpawn_RuntimeCreateFailure_RollsBack(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + h.runtime.createErr = errors.New("boom") + + _, err := h.sm.Spawn(ctx, spawnCfg()) + if err == nil { + t.Fatal("spawn: want error, got nil") + } + + // No record seeded for a spawn that never completed. + if _, ok, _ := h.store.Get(ctx, "sess-1"); ok { + t.Error("record was seeded despite runtime-create failure") + } + // The already-created workspace was rolled back (eager rollback), since a + // late-seeded record means Cleanup could never find this orphan. + if len(h.workspace.destroyed) != 1 || h.workspace.destroyed[0].Path != "/tmp/ws/sess-1" { + t.Errorf("workspace.destroyed = %+v, want the created worktree", h.workspace.destroyed) + } + // LCM never told a spawn completed. + if h.log.indexOf("OnSpawnCompleted") != -1 { + t.Error("OnSpawnCompleted should not fire on a failed spawn") + } +} + +func TestKill_OrderingAndTerminalState(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { + t.Fatalf("spawn: %v", err) + } + + res, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}) + if err != nil { + t.Fatalf("kill: %v", err) + } + if !res.WorkspaceFreed { + t.Error("WorkspaceFreed = false, want true") + } + + // Intent recorded with the LCM BEFORE any teardown, runtime before workspace. + iKill := h.log.indexOf("OnKillRequested") + iRT := h.log.indexOf("Runtime.Destroy") + iWS := h.log.indexOf("Workspace.Destroy") + if !(iKill >= 0 && iKill < iRT && iRT < iWS) { + t.Errorf("kill order indices: OnKillRequested=%d Runtime.Destroy=%d Workspace.Destroy=%d (want ascending)", iKill, iRT, iWS) + } + + // Terminal canonical written by the LCM; display derives to killed. + rec, _, _ := h.store.Get(ctx, "sess-1") + if got := rec.Lifecycle.Session; got.State != domain.SessionTerminated || got.Reason != domain.ReasonManuallyKilled { + t.Errorf("session substate = %+v, want terminated/manually_killed", got) + } + if status := domain.DeriveLegacyStatus(rec.Lifecycle); status != domain.StatusKilled { + t.Errorf("status = %q, want killed", status) + } +} + +func TestKill_WorktreeRemoveRefusalSurfaced(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { + t.Fatalf("spawn: %v", err) + } + // The worktree path is still registered after prune (uncommitted work). + h.workspace.refuse["/tmp/ws/sess-1"] = true + + res, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}) + if err == nil { + t.Fatal("kill: want refusal error, got nil") + } + if res.WorkspaceFreed { + t.Error("WorkspaceFreed = true, want false on refusal") + } + // The refusal must be honored — the path is never force-deleted. + if len(h.workspace.destroyed) != 0 { + t.Errorf("workspace.destroyed = %+v, want none (refused)", h.workspace.destroyed) + } + // Runtime still torn down and intent still recorded — only the worktree is spared. + if h.log.indexOf("Runtime.Destroy") == -1 || h.log.indexOf("OnKillRequested") == -1 { + t.Error("runtime teardown / kill intent should still happen on a workspace refusal") + } +} + +func TestListAndGet_DeriveStatus(t *testing.T) { + cases := []struct { + name string + lc domain.CanonicalSessionLifecycle + want domain.SessionStatus + }{ + {"not_started", lc(domain.SessionNotStarted, domain.ReasonSpawnRequested, domain.PRNone, ""), domain.StatusSpawning}, + {"working", lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), domain.StatusWorking}, + {"idle", lc(domain.SessionIdle, domain.ReasonResearchComplete, domain.PRNone, ""), domain.StatusIdle}, + {"needs_input", lc(domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, domain.PRNone, ""), domain.StatusNeedsInput}, + {"pr_ci_failed", lc(domain.SessionWorking, domain.ReasonFixingCI, domain.PROpen, domain.PRReasonCIFailing), domain.StatusCIFailed}, + {"pr_merged", lc(domain.SessionIdle, domain.ReasonMergedWaitingDecision, domain.PRMerged, domain.PRReasonMerged), domain.StatusMerged}, + {"killed", lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), domain.StatusKilled}, + } + + h := newHarness("unused") + ctx := context.Background() + for _, c := range cases { + if err := h.store.Seed(ctx, domain.SessionRecord{ID: domain.SessionID(c.name), ProjectID: testProject, Lifecycle: c.lc}); err != nil { + t.Fatalf("seed %s: %v", c.name, err) + } + } + + // Get derives per-record. + for _, c := range cases { + got, err := h.sm.Get(ctx, domain.SessionID(c.name)) + if err != nil { + t.Fatalf("get %s: %v", c.name, err) + } + if got.Status != c.want { + t.Errorf("get %s: status = %q, want %q", c.name, got.Status, c.want) + } + } + + // List derives for every record in the project. + got, err := h.sm.List(ctx, testProject) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != len(cases) { + t.Fatalf("list len = %d, want %d", len(got), len(cases)) + } + byID := map[domain.SessionID]domain.SessionStatus{} + for _, s := range got { + byID[s.ID] = s.Status + } + for _, c := range cases { + if byID[domain.SessionID(c.name)] != c.want { + t.Errorf("list %s: status = %q, want %q", c.name, byID[domain.SessionID(c.name)], c.want) + } + } +} + +func TestGet_NotFound(t *testing.T) { + h := newHarness("sess-1") + if _, err := h.sm.Get(context.Background(), "missing"); !errors.Is(err, ErrNotFound) { + t.Errorf("get missing: err = %v, want ErrNotFound", err) + } +} + +func TestSend_RoutesToMessenger(t *testing.T) { + h := newHarness("sess-1") + if err := h.sm.Send(context.Background(), "sess-1", "hello"); err != nil { + t.Fatalf("send: %v", err) + } + if len(h.messenger.sent) != 1 || h.messenger.sent[0].ID != "sess-1" || h.messenger.sent[0].Message != "hello" { + t.Errorf("messenger.sent = %+v, want one {sess-1, hello}", h.messenger.sent) + } +} + +func TestRestore_RelaunchesWithResumeCommand(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { + t.Fatalf("spawn: %v", err) + } + if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); err != nil { + t.Fatalf("kill: %v", err) + } + // The agent's resume id is captured in metadata (here set explicitly). + if err := h.store.PatchMetadata(ctx, "sess-1", map[string]string{lifecycle.MetaAgentSessionID: "agent-xyz"}); err != nil { + t.Fatalf("patch metadata: %v", err) + } + + sess, err := h.sm.Restore(ctx, "sess-1") + if err != nil { + t.Fatalf("restore: %v", err) + } + + // Reopened: terminal session reset to a fresh spawn, PR cleared, runtime alive. + if sess.Status != domain.StatusSpawning { + t.Errorf("status = %q, want spawning", sess.Status) + } + rec, _, _ := h.store.Get(ctx, "sess-1") + if got := rec.Lifecycle.Session; got.State != domain.SessionNotStarted || got.Reason != domain.ReasonSpawnRequested { + t.Errorf("session substate = %+v, want not_started/spawn_requested", got) + } + if got := rec.Lifecycle.PR; got.State != domain.PRNone || got.Reason != domain.PRReasonClearedOnRestore { + t.Errorf("pr substate = %+v, want none/cleared_on_restore", got) + } + if rec.Lifecycle.Runtime.State != domain.RuntimeAlive { + t.Errorf("runtime state = %q, want alive", rec.Lifecycle.Runtime.State) + } + + // Relaunched via the agent's resume command (created[0] is the original spawn). + if len(h.runtime.created) != 2 { + t.Fatalf("runtime.created = %d, want 2 (spawn + restore)", len(h.runtime.created)) + } + if got := h.runtime.created[1].LaunchCommand; got != "claude --resume agent-xyz" { + t.Errorf("restore launch command = %q, want resume", got) + } + if h.log.indexOf("Workspace.Restore") == -1 { + t.Error("Workspace.Restore was not called") + } +} + +func TestCleanup_SkipsUncommittedWork(t *testing.T) { + h := newHarness("unused") + ctx := context.Background() + + // Two terminal sessions (reclaimable) + one working session (must be ignored). + seedTerminal(t, h, "done-1", "/tmp/ws/done-1") + seedTerminal(t, h, "dirty-1", "/tmp/ws/dirty-1") + if err := h.store.Seed(ctx, domain.SessionRecord{ + ID: "live-1", ProjectID: testProject, + Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), + }); err != nil { + t.Fatalf("seed live: %v", err) + } + // dirty-1's worktree still holds uncommitted work — Destroy refuses it. + h.workspace.refuse["/tmp/ws/dirty-1"] = true + + res, err := h.sm.Cleanup(ctx, testProject) + if err != nil { + t.Fatalf("cleanup: %v", err) + } + + if !equalIDSet(res.Cleaned, []domain.SessionID{"done-1"}) { + t.Errorf("cleaned = %v, want [done-1]", res.Cleaned) + } + if !equalIDSet(res.Skipped, []domain.SessionID{"dirty-1"}) { + t.Errorf("skipped = %v, want [dirty-1]", res.Skipped) + } + // The live session was never a candidate. + if contains(res.Cleaned, "live-1") || contains(res.Skipped, "live-1") { + t.Error("non-terminal session must not be cleaned or skipped") + } +} + +// ---- test helpers ---- + +func lc(s domain.SessionState, r domain.SessionReason, prs domain.PRState, prr domain.PRReason) domain.CanonicalSessionLifecycle { + return domain.CanonicalSessionLifecycle{ + Version: domain.LifecycleVersion, + Session: domain.SessionSubstate{State: s, Reason: r}, + PR: domain.PRSubstate{State: prs, Reason: prr}, + Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, + } +} + +func seedTerminal(t *testing.T, h *harness, id domain.SessionID, wsPath string) { + t.Helper() + ctx := context.Background() + if err := h.store.Seed(ctx, domain.SessionRecord{ + ID: id, ProjectID: testProject, + Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), + }); err != nil { + t.Fatalf("seed %s: %v", id, err) + } + if err := h.store.PatchMetadata(ctx, id, map[string]string{lifecycle.MetaWorkspacePath: wsPath}); err != nil { + t.Fatalf("patch metadata %s: %v", id, err) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func contains(ids []domain.SessionID, id domain.SessionID) bool { + for _, x := range ids { + if x == id { + return true + } + } + return false +} + +func equalIDSet(got, want []domain.SessionID) bool { + if len(got) != len(want) { + return false + } + for _, w := range want { + if !contains(got, w) { + return false + } + } + return true +} From 162881d2bda4f8f34dad5b450576a727ffe71db0 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 13:50:24 +0530 Subject: [PATCH 020/250] =?UTF-8?q?fix(session):=20harden=20Restore=20?= =?UTF-8?q?=E2=80=94=20require=20agent=20session=20id,=20roll=20back=20run?= =?UTF-8?q?time=20on=20post-create=20failure=20(PR=20#7=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore now fails early with a clear error if MetaAgentSessionID is missing, rather than emitting an ambiguous "resume nothing" launch command (no stored prompt means a fresh-launch fallback isn't possible). - On a post-runtime-create failure (reopen patch or OnSpawnCompleted), best-effort destroy the newly created runtime (never the workspace, which holds prior work) so we don't strand a live process while parking the session terminal. - Added a test for Restore with a missing agent session id: errors early, touches no workspace/runtime, leaves the session terminal. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/session/manager.go | 18 ++++++++++++-- backend/internal/session/manager_test.go | 30 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index 61bb92744f..6273673970 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -239,6 +239,15 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess return domain.Session{}, fmt.Errorf("restore %s: metadata: %w", id, err) } + // Resume is only possible with the agent's captured session id. Without it, + // GetRestoreCommand would produce an ambiguous "resume nothing" launch, and + // we have no stored prompt to fall back to a fresh launch — so fail early, + // before any I/O. + agentSessionID := meta[lifecycle.MetaAgentSessionID] + if agentSessionID == "" { + return domain.Session{}, fmt.Errorf("restore %s: missing agent session id (cannot resume)", id) + } + ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ ProjectID: rec.ProjectID, SessionID: id, @@ -252,18 +261,22 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ SessionID: id, WorkspacePath: ws.Path, - LaunchCommand: m.agent.GetRestoreCommand(meta[lifecycle.MetaAgentSessionID]), + LaunchCommand: m.agent.GetRestoreCommand(agentSessionID), Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, rec.ProjectID, rec.IssueID), }) if err != nil { return domain.Session{}, fmt.Errorf("restore %s: runtime create: %w", id, err) } + // Past this point the runtime is live: a failure must tear it back down (but + // never the workspace, which holds the agent's prior work) so we don't strand + // a process while parking the session in a terminal lifecycle. reopen := ports.LifecyclePatch{ Session: &domain.SessionSubstate{State: domain.SessionNotStarted, Reason: domain.ReasonSpawnRequested}, PR: &domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonClearedOnRestore}, } if err := m.store.PatchLifecycle(ctx, id, reopen); err != nil { + m.rollbackRuntime(ctx, handle) return domain.Session{}, fmt.Errorf("restore %s: reopen: %w", id, err) } @@ -271,9 +284,10 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle, - AgentSessionID: meta[lifecycle.MetaAgentSessionID], + AgentSessionID: agentSessionID, } if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { + m.rollbackRuntime(ctx, handle) return domain.Session{}, fmt.Errorf("restore %s: on spawn completed: %w", id, err) } return m.Get(ctx, id) diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index e28eded193..85f9297c34 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -296,6 +296,36 @@ func TestRestore_RelaunchesWithResumeCommand(t *testing.T) { } } +func TestRestore_MissingAgentSessionID_Errors(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { + t.Fatalf("spawn: %v", err) + } + if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); err != nil { + t.Fatalf("kill: %v", err) + } + // No agent session id was ever captured (spawn leaves it empty) — resume is + // impossible, so Restore must fail early without touching workspace/runtime. + beforeRestores := len(h.workspace.restoredID) + beforeCreated := len(h.runtime.created) + + if _, err := h.sm.Restore(ctx, "sess-1"); err == nil { + t.Fatal("restore: want error for missing agent session id, got nil") + } + if len(h.workspace.restoredID) != beforeRestores { + t.Error("workspace was touched despite a doomed restore") + } + if len(h.runtime.created) != beforeCreated { + t.Error("runtime was created despite a doomed restore") + } + // The session stays terminal — a failed restore does not reopen it. + rec, _, _ := h.store.Get(ctx, "sess-1") + if rec.Lifecycle.Session.State != domain.SessionTerminated { + t.Errorf("session state = %q, want terminated (unchanged)", rec.Lifecycle.Session.State) + } +} + func TestCleanup_SkipsUncommittedWork(t *testing.T) { h := newHarness("unused") ctx := context.Background() From 4c331d907e034fbb8d71f5d25ccc0a6d56672f8c Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 14:00:44 +0530 Subject: [PATCH 021/250] test(session): cover spawn orphan-to-errored route and restore runtime rollback (review follow-ups) Adds an injectable OnSpawnCompleted failure to the recording LCM and two tests: - Spawn: when OnSpawnCompleted fails, the seeded record is parked terminal/errored (via OnKillRequested(KillError)) and runtime+workspace are torn down. - Restore: when OnSpawnCompleted fails post-create, the new runtime is destroyed while the workspace is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/session/fakes_test.go | 10 +++- backend/internal/session/manager_test.go | 64 ++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/backend/internal/session/fakes_test.go b/backend/internal/session/fakes_test.go index d8e4b2483b..648172dee7 100644 --- a/backend/internal/session/fakes_test.go +++ b/backend/internal/session/fakes_test.go @@ -319,12 +319,19 @@ func (noopNotifier) Notify(_ context.Context, _ ports.OrchestratorEvent) error { type recordingLCM struct { log *callLog inner ports.LifecycleManager + + // onSpawnErr, when set, makes OnSpawnCompleted fail (without touching the + // inner manager) so tests can exercise the SM's post-spawn failure paths. + onSpawnErr error } var _ ports.LifecycleManager = (*recordingLCM)(nil) func (l *recordingLCM) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error { l.log.add("OnSpawnCompleted") + if l.onSpawnErr != nil { + return l.onSpawnErr + } return l.inner.OnSpawnCompleted(ctx, id, o) } @@ -358,6 +365,7 @@ type harness struct { agent *fakeAgent workspace *fakeWorkspace messenger *recordingMessenger + lcm *recordingLCM log *callLog } @@ -384,7 +392,7 @@ func newHarness(id domain.SessionID) *harness { NewID: func(ports.SpawnConfig) domain.SessionID { return id }, }) - return &harness{sm: sm, store: store, runtime: rt, agent: ag, workspace: ws, messenger: msg, log: log} + return &harness{sm: sm, store: store, runtime: rt, agent: ag, workspace: ws, messenger: msg, lcm: lcm, log: log} } func cloneMap(in map[string]string) map[string]string { diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index 85f9297c34..bda7e9883b 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -121,6 +121,38 @@ func TestSpawn_RuntimeCreateFailure_RollsBack(t *testing.T) { } } +func TestSpawn_OnSpawnCompletedFailure_RoutesOrphanToErrored(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + h.lcm.onSpawnErr = errors.New("lcm boom") + + _, err := h.sm.Spawn(ctx, spawnCfg()) + if err == nil { + t.Fatal("spawn: want error, got nil") + } + + // Runtime + workspace are torn down on the failure path. + if len(h.runtime.destroyed) != 1 { + t.Errorf("runtime.destroyed = %d, want 1", len(h.runtime.destroyed)) + } + if len(h.workspace.destroyed) != 1 { + t.Errorf("workspace.destroyed = %d, want 1", len(h.workspace.destroyed)) + } + // The record was already seeded and the store has no delete, so the orphan is + // routed to a terminal errored state (via OnKillRequested(KillError)) rather + // than stranded forever as "spawning". + rec, ok, _ := h.store.Get(ctx, "sess-1") + if !ok { + t.Fatal("seeded record vanished; expected it parked as errored") + } + if got := rec.Lifecycle.Session; got.State != domain.SessionTerminated || got.Reason != domain.ReasonErrorInProcess { + t.Errorf("session substate = %+v, want terminated/error_in_process", got) + } + if status := domain.DeriveLegacyStatus(rec.Lifecycle); status != domain.StatusErrored { + t.Errorf("status = %q, want errored", status) + } +} + func TestKill_OrderingAndTerminalState(t *testing.T) { h := newHarness("sess-1") ctx := context.Background() @@ -326,6 +358,38 @@ func TestRestore_MissingAgentSessionID_Errors(t *testing.T) { } } +func TestRestore_OnSpawnCompletedFailure_RollsBackRuntime(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { + t.Fatalf("spawn: %v", err) + } + if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); err != nil { + t.Fatalf("kill: %v", err) + } + if err := h.store.PatchMetadata(ctx, "sess-1", map[string]string{lifecycle.MetaAgentSessionID: "agent-xyz"}); err != nil { + t.Fatalf("patch metadata: %v", err) + } + + // Fail the post-create LCM call; capture teardown counts just before restore. + h.lcm.onSpawnErr = errors.New("lcm boom") + destroyedBefore := len(h.runtime.destroyed) + wsDestroyedBefore := len(h.workspace.destroyed) + + if _, err := h.sm.Restore(ctx, "sess-1"); err == nil { + t.Fatal("restore: want error, got nil") + } + + // The runtime created during restore is torn back down so no process is + // stranded; the workspace is left intact (it holds the agent's prior work). + if len(h.runtime.destroyed) != destroyedBefore+1 { + t.Errorf("runtime.destroyed grew by %d, want 1 (restore rollback)", len(h.runtime.destroyed)-destroyedBefore) + } + if len(h.workspace.destroyed) != wsDestroyedBefore { + t.Errorf("workspace was destroyed on restore rollback; it must be preserved") + } +} + func TestCleanup_SkipsUncommittedWork(t *testing.T) { h := newHarness("unused") ctx := context.Background() From 1258a3ef1436d86cc954b31aed362cf54561bb9d Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 14:08:13 +0530 Subject: [PATCH 022/250] docs: document the LCM + Session Manager lane (architecture + status) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/ for newcomers: an index, an architecture deep-dive (the OBSERVE→DECIDE→ACT loop, the canonical state model, the package layout, every component, and the load-bearing invariants), and a status/roadmap (what's done PR-by-PR, what's left, the integration to-dos + carried-forward items, the open cross-lane contract questions, and where to plug in). Link them from the README. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 3 + docs/README.md | 34 ++++++++ docs/architecture.md | 187 +++++++++++++++++++++++++++++++++++++++++++ docs/status.md | 98 +++++++++++++++++++++++ 4 files changed, 322 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/status.md diff --git a/README.md b/README.md index 0f28a2e3ec..353d12001d 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,6 @@ Rewrite of the agent-orchestrator: a long-running Go backend daemon (`backend/`) paired with an Electron + TypeScript frontend (`frontend/`). + +See [`docs/`](docs/README.md) for architecture and status — start with the +Lifecycle Manager + Session Manager lane in [`docs/architecture.md`](docs/architecture.md). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000..f42f222fa5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,34 @@ +# agent-orchestrator (rewrite) — docs + +The agent-orchestrator is being rebuilt as a long-running **Go backend daemon** +(`backend/`) plus an **Electron + TypeScript frontend** (`frontend/`). The +backend supervises a fleet of coding-agent sessions and keeps one true status +per session. + +This folder documents the **Lifecycle Manager (LCM) + Session Manager (SM) +lane** — the deterministic core of the backend that is now implemented (behind +fakes) on the `feat/lcm-sm-contracts` integration branch. + +## Start here + +| Doc | What it covers | +|-----|----------------| +| [architecture.md](architecture.md) | How the lane works: the OBSERVE→DECIDE→ACT loop, the canonical state model, the package layout, every component, and the load-bearing invariants. Read this first. | +| [status.md](status.md) | What's done (PR by PR), what's left, the integration to-dos, the open cross-lane contract questions, and how to build/test. | + +## The one-paragraph mental model + +The backend is a **stateless supervisor over external ground truth**: git/GitHub +own PR/CI/review truth, the agent's own files own its activity, and the backend +owns no agent state. Its whole job is, per session: **OBSERVE** raw facts → +**DECIDE** one canonical status via pure, deterministic functions → **ACT** +(persist + fire reactions). The LCM is that reducer; the SM is the +explicit-mutation plumbing (spawn/kill/restore/cleanup) that feeds it. + +## Where this lane fits + +Other lanes (built by other people, in parallel) provide the real adapters this +lane depends on through narrow interfaces: the **persistence layer + CDC**, the +**SCM poller**, the **runtime/agent/workspace plugins**, the **backend API + +OpenAPI**, and the **frontend store**. See [status.md](status.md#integration) +for the hand-off points. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000000..9673142c75 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,187 @@ +# LCM + Session Manager — architecture + +This is the deterministic core of the backend daemon. It supervises agent +sessions and keeps exactly one true status per session. + +## 1. Mental model: OBSERVE → DECIDE → ACT + +The backend owns no agent state. git/GitHub own PR/CI/review truth; the agent's +own files own its activity. The job, per session, is one loop: + +``` +OBSERVE → DECIDE → ACT +(impure, external) (pure, total) (impure) +raw facts one canonical status persist + react +``` + +In the rewrite the **OBSERVE** step lives *outside* the LCM (separate owners), +and the LCM is a **synchronous reducer** invoked with facts: + +``` +SCM poller ─ ApplySCMObservation ──┐ +reaper ─ ApplyRuntimeObservation┤ +activity hooks ─ ApplyActivitySignal ───┼─▶ LCM: load canonical +Session Mgr ─ OnSpawnCompleted ──────┘ → pure DECIDE + ─ OnKillRequested → diff → persist (merge-patch) +reaper tick ─ TickEscalations → if transition: react (ACT) +``` + +The LCM **never polls**. The reaper (a timer, owned elsewhere) drives liveness +sampling and duration-based escalation by calling in. + +## 2. Canonical state model — the crown jewel + +The **only** thing persisted per session is `CanonicalSessionLifecycle` +(`backend/internal/domain/lifecycle.go`). The single-word display status is +**derived on read and never stored** — this is the most important invariant; it +prevents canonical truth and display from drifting. + +``` +CanonicalSessionLifecycle + Version schema version of the record shape + Revision monotonic write counter (optimistic-concurrency token) + Session (state, reason) working/idle/needs_input/stuck/detecting/done/terminated + PR (state, reason) none/open/merged/closed + Runtime (state, reason) unknown/alive/exited/missing/probe_failed + Activity last-known agent activity (+ timestamp, source) ← decider input + Detecting anti-flap quarantine memory (nil unless quarantined) ← decider input +``` + +`DeriveLegacyStatus` (`domain/status.go`) is the **sole producer** of the +display `SessionStatus`. Precedence: terminal/hard session states map directly +(they outrank PR facts) → a merged PR wins → an open PR maps by reason → else the +soft session state. So an idle worker with a CI-failing open PR displays +`ci_failed`, but a `needs_input` session shows `needs_input` regardless of the PR. + +`Session` (`domain/session.go`) is the read-model: a `SessionRecord` +(persistence shape, identity + lifecycle + metadata) plus the derived `Status`. +The **Session Manager is the single producer of `Status`** — it attaches it on +read; the store and API never recompute or persist it. + +## 3. Package layout (`backend/internal/`) + +``` +domain/ the vocabulary (imports only the std lib → no cycles) + lifecycle.go CanonicalSessionLifecycle + all sub-states/enums + status.go SessionStatus + DeriveLegacyStatus (sole display producer) + session.go SessionRecord (persisted) + Session (read-model) + id types + decide/ the PURE core — total, deterministic, zero I/O + types.go LifecycleDecision + Probe/OpenPR/Detecting inputs + tuning consts + decide.go the deciders + the anti-flap quarantine + HashEvidence +ports/ the boundaries (interfaces + DTOs) + inbound.go LifecycleManager, SessionManager (we implement) + outbound.go LifecycleStore, Notifier, AgentMessenger, Runtime/Agent/Workspace + facts.go SCMFacts, RuntimeFacts, ActivitySignal, SpawnOutcome, KillReason +lifecycle/ the LCM implementation (DECIDE + ACT) + manager.go the Apply* pipeline, per-session lock, patch diffing + decide_bridge.go fact→decide-input translation + the composition rules + reactions.go the reaction table + escalation engine + TickEscalations +session/ the SM implementation (explicit mutations) + manager.go Spawn/Kill/Restore/Cleanup/List/Get/Send + rollback +``` + +`domain` + `ports` are the committed, stabilized **integration boundary**. +Everything else implements behind it. + +## 4. The pure DECIDE core (`domain/decide`) + +Total, deterministic, side-effect-free functions — the highest-value test +surface (table-tested to 100%). Key ones: + +- `ResolveProbeDecision` — runtime/process liveness. An explicit kill + short-circuits to terminal; a **failed probe is never read as death** (routes + to `detecting`), as does any probe disagreement; only runtime-dead + + process-dead + no-recent-activity reaches `killed`. +- `ResolveOpenPRDecision` — the PR ladder: `ci_failing` → `changes_requested` → + `mergeable` → `approved` → `review_pending` → idle-beyond → else `pr_open`. +- `ResolveTerminalPRStateDecision` — merged → `merged` (park idle awaiting a + human decision); closed → `idle`. +- `CreateDetectingDecision` — the **anti-flap quarantine**. Counts attempts and + hashes the *timestamp-stripped* evidence; escalates to `stuck` only after 3 + consecutive unchanged-evidence ticks **or** 5 minutes since first entering + detecting (`StartedAt` is preserved across the whole episode). Changing + evidence resets the counter. + +## 5. The LCM (`lifecycle`) + +Implements `ports.LifecycleManager`. Every `Apply*`/`On*` entrypoint runs the +same pipeline (`manager.go`): + +``` +withLock(session): ← per-session serialization + load canonical → decideFn (build sparse patch) → if changed: persist → load after +return transition (before, after) +``` +then, **after the lock releases**, `react()` fires the mapped reaction. + +- **Per-session serialization** — `keyedMutex` hands out one lock per session id + (parallel across sessions, serial within one). Entries are reference-counted + and evicted when the last holder releases, so the map stays bounded. +- **Composition rules** (`decide_bridge.go`) — two observers must not fight over + the session axis. Liveness (runtime probes) owns the runtime + death/detecting + axis; activity owns working/idle/waiting. `isLivenessOwned` decides when a + healthy probe may *recover* a state (e.g. `detecting → working`) vs. when it + must not clobber an activity-owned `needs_input`/`blocked`. A high-confidence + activity signal may resolve a `detecting` session; an open PR writes only the + PR axis and lets `DeriveLegacyStatus` surface it. +- **Detecting-memory lifecycle** — a decision with `Detecting == nil` clears the + persisted quarantine memory (`LifecyclePatch.ClearDetecting`) so a stale prior + can't leak into a later episode. +- **ACT — reactions + escalation** (`reactions.go`) — on a genuine status + transition, `react()` maps it to a reaction (`send-to-agent` / `notify`; + `auto-merge` exists but is off by default) and dispatches it. A + per-`(session,reaction)` escalation tracker counts attempts; it escalates + (notifies a human and silences further auto-dispatch) when a numeric cap or a + duration is exceeded. The `ci-failed` budget is persistent across CI + oscillation within an open PR and re-arms on genuine recovery. `TickEscalations` + (called by the reaper) fires the duration-based escalations the synchronous + LCM can't wake itself for; it notifies outside the lock. + +## 6. The Session Manager (`session`) + +Implements `ports.SessionManager` — the explicit-mutation plumbing. It never +derives/observes lifecycle state; it routes outcomes to the LCM. + +- **Spawn** — `Workspace.Create` → build prompt → `Runtime.Create` (env + `AO_SESSION_ID`/`AO_PROJECT_ID`/`AO_ISSUE_ID`) → **seed** the initial record + (`not_started`/`spawn_requested`) via the store → `LCM.OnSpawnCompleted`. + Eager rollback unwinds prior steps on failure; an `OnSpawnCompleted` failure + routes the seeded orphan to terminal-errored (the store has no delete; a later + `Cleanup` reclaims it). +- **Kill** — `LCM.OnKillRequested` → `Runtime.Destroy` → `Workspace.Destroy`, + honoring the **worktree-remove safety**: after `git worktree prune`, a still- + registered path is never `rm -rf`'d (it may hold the agent's uncommitted work) + — the refusal is surfaced, not forced. +- **Restore** — reopen via `PatchLifecycle` (not re-seed): session → + `not_started`, PR → `cleared_on_restore`; relaunch with the agent's resume + command; runtime is rolled back on a post-create failure. +- **List/Get** — read records and attach the derived `Status`. **Send** — via + `AgentMessenger`. **Cleanup** — tear down terminal/stale sessions, skipping + paths with uncommitted work. + +## 7. Load-bearing invariants + +1. **Persist canonical; derive display.** Never store the display status. +2. **One authority for death.** Only the DECIDE pipeline (via `detecting`) writes + inferred terminal states; the SM's explicit-kill path goes through + `OnKillRequested`. Everything else that notices a dead runtime persists + `detecting`, never `terminated`. +3. **Failed probe ≠ dead.** Timed-out/errored probes route to `detecting`. +4. **Evidence-hash debounce** prevents flapping signals from terminating live + work; the 5-minute cap is a whole-episode wall-clock safety net. +5. **PR facts dominate** the soft session states once a PR exists. +6. **Merge-patch persistence** — writes touch only changed keys; the store is the + single disk writer (atomic write + lock + CDC). +7. **Sticky activity states** (`waiting_input`/`blocked`) do not decay by clock. +8. **Worktree-remove safety** on teardown. + +## 8. Concurrency & testing + +- Within a session, the per-session lock serializes the load→decide→persist + read-modify-write. `react()` runs *outside* the lock (so a busy-waiting + send-to-agent never holds the session mutex) — see `status.md` for the + integration-time follow-up this implies. +- Tests use **in-memory fakes** for every outbound port, so the LCM and SM are + fully testable with no real adapters. The SM tests drive the **real** + `lifecycle.Manager` for spawn/kill round-trips, so the SM↔LCM contract is + genuinely exercised. The `decide` package is table-tested in isolation. diff --git a/docs/status.md b/docs/status.md new file mode 100644 index 0000000000..9bb79cdb19 --- /dev/null +++ b/docs/status.md @@ -0,0 +1,98 @@ +# LCM + Session Manager — status & roadmap + +Where the lane stands, what's left, and where to plug in. + +## Branch model + +`feat/lcm-sm-contracts` is the **lane integration branch**: each sub-PR below +branched off it and merged **into** it. The whole lane lands on `main` as one +unit once it's ready. Sub-PRs were reviewed against the integration branch; +the eventual lane→main merge is a single cumulative review. + +## Done — implementation complete (behind fakes) + +| Area | What landed | PR | +|------|-------------|----| +| Skeleton | `backend/` (Go) + `frontend/` (Electron/TS) | #1 (on `main`) | +| Contracts + CI | `domain/` + `ports/`; Go + gitleaks workflows | #2 | +| Pure DECIDE core | the deciders + anti-flap quarantine + exhaustive truth-table tests | #4 | +| LCM — pipeline | `Apply*` pipeline, per-session serialization, store integration, composition rules, detecting-memory lifecycle | #5 | +| LCM — reactions | reaction table + escalation engine + real `TickEscalations` | #6 | +| Session Manager | spawn / kill / restore / cleanup / list, eager rollback, worktree-remove safety | #7 | + +`gofmt` / `go build` / `go vet` / `go test -race` all green across `domain`, +`domain/decide`, `lifecycle`, and `session`. The `decide` core is at 100% +statement coverage; the impl packages cover the load-bearing logic including the +error/rollback paths. + +### Build & test + +``` +cd backend +gofmt -l . # must print nothing +go build ./... +go vet ./... +go test -race ./... +go test -cover ./... +``` + +## Not done — the integration phase + +Everything above runs against **in-memory fakes**. Making it a live system means +swapping fakes for real adapters (built by other lanes) behind the existing +ports, and resolving the carried-forward items below. + +### Carried-forward items (must be addressed as real adapters land) + +- **`react()` out-of-lock dispatch.** Reactions fire after the per-session lock + releases (deliberate, so a busy-waiting send-to-agent doesn't hold the mutex). + Under a live daemon with concurrent observers this can dispatch on a stale + snapshot / out of order. Give `react()` a per-session ordering (a small react + queue) or re-check the triggering state before dispatching. Documented in + `lifecycle/reactions.go`. +- **`ExpectedRevision` optimistic-concurrency is unused.** The in-process + per-session mutex covers a single daemon. Multi-writer or CDC-driven setups + must use the `LifecyclePatch.ExpectedRevision` CAS the contract already exposes. +- **Store `Seed` + `Get` need a real implementation.** The Session Manager added + two record-with-identity methods to `LifecycleStore`; the real persistence + layer must implement them (create-with-identity that rejects an existing id; + full-record read by id). Documented in `ports/outbound.go`. + +### Real adapters needed (other lanes) + +| Port | Real adapter | Owning lane | +|------|--------------|-------------| +| `LifecycleStore` | persistence layer (flat-file/KV + atomic write + lock + CDC) | persistence | +| `SCMFacts` producer | SCM poller (batch PR/CI/review enrichment) | SCM | +| `Runtime` / `Agent` / `Workspace` | tmux runtime, claude-code/codex agent, git-worktree workspace | coding-agents | +| `Notifier` | desktop/Slack notifier | notifications | +| `AgentMessenger` | tmux inject with busy-detect + delivery verify | coding-agents | +| `SessionManager` consumer | backend API (routes/controllers) + OpenAPI | API | + +### Open cross-lane contract questions + +- **SCM facts** — does `SCMFacts` match what the poller can cheaply produce + (batch enrichment, CI log tail as a pointer)? +- **Persistence** — is `LifecycleStore` + `LifecyclePatch` the right boundary? + Per-session lock vs. the `ExpectedRevision` CAS? +- **API** — is the `SessionManager` interface + the `Session` read-model + OpenAPI-friendly? + +### Land the lane → `main` + +A final cumulative review of `feat/lcm-sm-contracts` vs. `main`, then merge the +complete lane in one unit. + +## Where to plug in (for someone picking this up) + +- **Implementing a real adapter?** Write it to satisfy the matching interface in + `ports/`, then construct the `lifecycle.Manager` / `session.Manager` with it in + place of the fake. Nothing in `domain`/`lifecycle`/`session` should need to + change. +- **Changing decision behavior?** It lives in `domain/decide` (pure) — add a + truth-table case first; nothing there does I/O. +- **Adding a reaction?** Extend the table in `lifecycle/reactions.go` and map the + triggering status in `reactionEventFor`. +- **Don't** persist the display status, conclude death outside the probe + pipeline, or `rm -rf` a still-registered worktree — see the invariants in + [architecture.md](architecture.md#7-load-bearing-invariants). From f03c7c8c8866acdaf5f9cea3ffd898908e7f82fe Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 14:21:47 +0530 Subject: [PATCH 023/250] fix(session): harden teardown/restore safety + drop dead reaction flag Address PR #2 Copilot review comments on the merged LCM+SM lane: - session: validate runtime handle + workspace path before Kill/Cleanup teardown; refuse (ErrIncompleteTeardownMetadata) or skip rather than hand empty args to a real adapter's Destroy (unsafe delete). - session: reject Restore unless the session is terminal (ErrNotRestorable) so a live session can't spawn a duplicate runtime/workspace. - ports: document SpawnConfig.OpenTerminal as reserved/not yet honored. - lifecycle: remove the unread reactionConfig.auto field; note approved-and-green is notify-only (human decides to merge). Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/lifecycle/reactions.go | 15 ++--- backend/internal/ports/inbound.go | 14 +++-- backend/internal/session/manager.go | 57 ++++++++++++++++-- backend/internal/session/manager_test.go | 77 ++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 17 deletions(-) diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index 544f152f58..7284151041 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -53,7 +53,6 @@ const ( // or the session terminal). Only ci-failed is persistent, so a flapping // CI (fail→pending→fail) keeps draining one shared retry budget. type reactionConfig struct { - auto bool action actionKind message string priority ports.EventPriority @@ -69,32 +68,34 @@ type reactionConfig struct { // but no default row uses it. var defaultReactions = map[reactionKey]reactionConfig{ reactionCIFailed: { - auto: true, action: actionSendToAgent, persistent: true, retries: 2, + action: actionSendToAgent, persistent: true, retries: 2, message: "CI is failing on your PR. Review the failing output below and push a fix.", eventType: "reaction.ci-failed", priority: ports.PriorityAction, }, reactionChangesRequested: { - auto: true, action: actionSendToAgent, escalateAfter: 30 * time.Minute, + action: actionSendToAgent, escalateAfter: 30 * time.Minute, message: "A reviewer requested changes on your PR. Address the comments and push.", eventType: "reaction.changes-requested", priority: ports.PriorityAction, }, reactionBugbotComments: { - auto: true, action: actionSendToAgent, escalateAfter: 30 * time.Minute, + action: actionSendToAgent, escalateAfter: 30 * time.Minute, message: "An automated reviewer left comments on your PR. Address them and push.", eventType: "reaction.bugbot-comments", priority: ports.PriorityAction, }, reactionMergeConflicts: { - auto: true, action: actionSendToAgent, escalateAfter: 15 * time.Minute, + action: actionSendToAgent, escalateAfter: 15 * time.Minute, message: "Your PR has merge conflicts. Rebase onto the base branch and resolve them.", eventType: "reaction.merge-conflicts", priority: ports.PriorityAction, }, reactionAgentIdle: { - auto: true, action: actionSendToAgent, retries: 2, escalateAfter: 15 * time.Minute, + action: actionSendToAgent, retries: 2, escalateAfter: 15 * time.Minute, message: "You appear idle. Continue the task or explain what is blocking you.", eventType: "reaction.agent-idle", priority: ports.PriorityWarning, }, reactionApprovedAndGreen: { - auto: false, action: actionNotify, priority: ports.PriorityAction, + // notify-only: a green, approved PR is the human-decision path — the human + // decides to merge (no auto-merge by default). + action: actionNotify, priority: ports.PriorityAction, message: "PR is approved and green — ready to merge.", eventType: "reaction.approved-and-green", }, diff --git a/backend/internal/ports/inbound.go b/backend/internal/ports/inbound.go index 9f2e2bb42b..30ab755954 100644 --- a/backend/internal/ports/inbound.go +++ b/backend/internal/ports/inbound.go @@ -43,12 +43,14 @@ type SessionManager interface { } type SpawnConfig struct { - ProjectID domain.ProjectID - IssueID domain.IssueID - Kind domain.SessionKind - Branch string - Prompt string - AgentRules string + ProjectID domain.ProjectID + IssueID domain.IssueID + Kind domain.SessionKind + Branch string + Prompt string + AgentRules string + // OpenTerminal is reserved for a later lane (open a terminal tab on spawn). + // Spawn does NOT honor it yet — setting it has no effect. OpenTerminal bool } diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index 6273673970..e2723d26a8 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -27,6 +27,16 @@ import ( // ErrNotFound is returned by Get/Restore when no record exists for the id. var ErrNotFound = errors.New("session: not found") +// ErrNotRestorable is returned by Restore when the session is not torn down. +// Restoring a live session would spin up a second runtime/workspace for the same +// id, duplicating the agent and risking data loss. +var ErrNotRestorable = errors.New("session: not restorable (not terminal)") + +// ErrIncompleteTeardownMetadata is returned when a record's teardown handles are +// missing (empty workspace path or runtime handle), so calling a real adapter's +// Destroy could act on empty args — an unsafe delete. The teardown is skipped. +var ErrIncompleteTeardownMetadata = errors.New("session: incomplete teardown metadata") + // Env vars a spawned process reads to learn who it is (distillation §5.4). const ( EnvSessionID = "AO_SESSION_ID" @@ -167,13 +177,25 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID, opts ports.Kill return ports.KillResult{ID: id}, fmt.Errorf("kill %s: metadata: %w", id, err) } + // Validate the teardown handles BEFORE recording intent or touching an + // adapter: a corrupted/partially-seeded record with empty handles must never + // reach Destroy (empty path / handle could be an unsafe delete). + rtHandle := runtimeHandle(meta) + wsInfo := workspaceInfo(rec, meta) + if !validRuntimeHandle(rtHandle) { + return ports.KillResult{ID: id}, fmt.Errorf("kill %s: %w: runtime handle", id, ErrIncompleteTeardownMetadata) + } + if !validWorkspaceInfo(wsInfo) { + return ports.KillResult{ID: id}, fmt.Errorf("kill %s: %w: workspace path", id, ErrIncompleteTeardownMetadata) + } + if err := m.lcm.OnKillRequested(ctx, id, ports.KillReason{Kind: opts.Reason, Detail: opts.Detail}); err != nil { return ports.KillResult{ID: id}, fmt.Errorf("kill %s: on kill requested: %w", id, err) } - if err := m.runtime.Destroy(ctx, runtimeHandle(meta)); err != nil { + if err := m.runtime.Destroy(ctx, rtHandle); err != nil { return ports.KillResult{ID: id}, fmt.Errorf("kill %s: runtime destroy: %w", id, err) } - if err := m.workspace.Destroy(ctx, workspaceInfo(rec, meta)); err != nil { + if err := m.workspace.Destroy(ctx, wsInfo); err != nil { return ports.KillResult{ID: id, WorkspaceFreed: false}, fmt.Errorf("kill %s: workspace destroy: %w", id, err) } return ports.KillResult{ID: id, WorkspaceFreed: true}, nil @@ -234,6 +256,11 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess if !ok { return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotFound) } + // Only a torn-down session may be restored. Reopening a live one would spawn a + // duplicate runtime/workspace for the same id and reset its lifecycle. + if !isTerminalSession(rec.Lifecycle.Session.State) { + return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotRestorable) + } meta, err := m.store.GetMetadata(ctx, id) if err != nil { return domain.Session{}, fmt.Errorf("restore %s: metadata: %w", id, err) @@ -313,8 +340,17 @@ func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) (ports. if err != nil { return res, fmt.Errorf("cleanup %s: metadata %s: %w", project, rec.ID, err) } - _ = m.runtime.Destroy(ctx, runtimeHandle(meta)) // best effort; usually already gone - if err := m.workspace.Destroy(ctx, workspaceInfo(rec, meta)); err != nil { + wsInfo := workspaceInfo(rec, meta) + if !validWorkspaceInfo(wsInfo) { + // No workspace path to reclaim — skip rather than hand empty args to a + // real adapter's Destroy (an unsafe delete). + res.Skipped = append(res.Skipped, rec.ID) + continue + } + if rtHandle := runtimeHandle(meta); validRuntimeHandle(rtHandle) { + _ = m.runtime.Destroy(ctx, rtHandle) // best effort; usually already gone + } + if err := m.workspace.Destroy(ctx, wsInfo); err != nil { res.Skipped = append(res.Skipped, rec.ID) continue } @@ -395,6 +431,19 @@ func workspaceInfo(rec domain.SessionRecord, meta map[string]string) ports.Works } } +// validRuntimeHandle reports whether the handle identifies a runtime to destroy. +// An adapter needs the handle id to target the right process; an empty handle +// would be ambiguous, so we refuse to call Destroy with one. +func validRuntimeHandle(h ports.RuntimeHandle) bool { + return h.ID != "" +} + +// validWorkspaceInfo reports whether there is a concrete path to reclaim. An +// empty path handed to a worktree-remove could resolve to an unsafe target. +func validWorkspaceInfo(w ports.WorkspaceInfo) bool { + return w.Path != "" +} + func defaultNewID(cfg ports.SpawnConfig) domain.SessionID { base := string(cfg.IssueID) if base == "" { diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index bda7e9883b..702a735e43 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -212,6 +212,83 @@ func TestKill_WorktreeRemoveRefusalSurfaced(t *testing.T) { } } +func TestKill_IncompleteMetadata_RefusesTeardown(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + // A record with no teardown metadata (empty runtime handle + workspace path), + // e.g. a partially-seeded or corrupted record. + if err := h.store.Seed(ctx, domain.SessionRecord{ + ID: "sess-1", ProjectID: testProject, + Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), + }); err != nil { + t.Fatalf("seed: %v", err) + } + + if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); !errors.Is(err, ErrIncompleteTeardownMetadata) { + t.Fatalf("kill: err = %v, want ErrIncompleteTeardownMetadata", err) + } + // Nothing destroyed with empty args, and no intent recorded. + if len(h.runtime.destroyed) != 0 || len(h.workspace.destroyed) != 0 { + t.Errorf("teardown ran despite incomplete metadata: rt=%v ws=%v", h.runtime.destroyed, h.workspace.destroyed) + } + if h.log.indexOf("OnKillRequested") != -1 { + t.Error("kill intent recorded despite incomplete metadata") + } +} + +func TestCleanup_IncompleteMetadata_Skipped(t *testing.T) { + h := newHarness("unused") + ctx := context.Background() + // Terminal session but no workspace path persisted — must be skipped, never + // handed to Destroy with an empty path. + if err := h.store.Seed(ctx, domain.SessionRecord{ + ID: "orphan-1", ProjectID: testProject, + Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), + }); err != nil { + t.Fatalf("seed: %v", err) + } + + res, err := h.sm.Cleanup(ctx, testProject) + if err != nil { + t.Fatalf("cleanup: %v", err) + } + if !equalIDSet(res.Skipped, []domain.SessionID{"orphan-1"}) { + t.Errorf("skipped = %v, want [orphan-1]", res.Skipped) + } + if len(res.Cleaned) != 0 { + t.Errorf("cleaned = %v, want none", res.Cleaned) + } + if len(h.workspace.destroyed) != 0 { + t.Errorf("workspace.destroyed = %v, want none (empty path must not reach Destroy)", h.workspace.destroyed) + } +} + +func TestRestore_LiveSession_Rejected(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { + t.Fatalf("spawn: %v", err) + } + // The session is live (never torn down). Capture an agent id so the only thing + // blocking restore is the non-terminal lifecycle, not missing metadata. + if err := h.store.PatchMetadata(ctx, "sess-1", map[string]string{lifecycle.MetaAgentSessionID: "agent-xyz"}); err != nil { + t.Fatalf("patch metadata: %v", err) + } + createdBefore := len(h.runtime.created) + restoresBefore := len(h.workspace.restoredID) + + if _, err := h.sm.Restore(ctx, "sess-1"); !errors.Is(err, ErrNotRestorable) { + t.Fatalf("restore: err = %v, want ErrNotRestorable", err) + } + // No second runtime/workspace spun up for the still-live session. + if len(h.runtime.created) != createdBefore { + t.Error("runtime created for a live-session restore") + } + if len(h.workspace.restoredID) != restoresBefore { + t.Error("workspace restored for a live-session restore") + } +} + func TestListAndGet_DeriveStatus(t *testing.T) { cases := []struct { name string From f975ca24c910dd90344a26c2670173d737b7e2c7 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 16:58:03 +0530 Subject: [PATCH 024/250] feat: add tmux runtime adapter --- .../adapters/runtime/tmux/commands.go | 89 +++++++ .../internal/adapters/runtime/tmux/tmux.go | 244 ++++++++++++++++++ .../runtime/tmux/tmux_integration_test.go | 71 +++++ .../adapters/runtime/tmux/tmux_test.go | 199 ++++++++++++++ 4 files changed, 603 insertions(+) create mode 100644 backend/internal/adapters/runtime/tmux/commands.go create mode 100644 backend/internal/adapters/runtime/tmux/tmux.go create mode 100644 backend/internal/adapters/runtime/tmux/tmux_integration_test.go create mode 100644 backend/internal/adapters/runtime/tmux/tmux_test.go diff --git a/backend/internal/adapters/runtime/tmux/commands.go b/backend/internal/adapters/runtime/tmux/commands.go new file mode 100644 index 0000000000..700d369cf2 --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/commands.go @@ -0,0 +1,89 @@ +package tmux + +import ( + "fmt" + "sort" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const runtimeName = "tmux" + +func newSessionArgs(id, workspacePath, shellPath, script string) []string { + return []string{"new-session", "-d", "-s", id, "-c", workspacePath, shellPath, "-lc", script} +} + +func setStatusOffArgs(id string) []string { + return []string{"set-option", "-t", id, "status", "off"} +} + +func hasSessionArgs(id string) []string { + return []string{"has-session", "-t", id} +} + +func killSessionArgs(id string) []string { + return []string{"kill-session", "-t", id} +} + +func capturePaneArgs(id string, lines int) []string { + return []string{"capture-pane", "-p", "-t", id, "-S", fmt.Sprintf("-%d", lines)} +} + +func sendLiteralArgs(id, message string) []string { + return []string{"send-keys", "-t", id, "-l", message} +} + +func sendEnterArgs(id string) []string { + return []string{"send-keys", "-t", id, "C-m"} +} + +func loadBufferArgs(bufferName, path string) []string { + return []string{"load-buffer", "-b", bufferName, path} +} + +func pasteBufferArgs(id, bufferName string) []string { + return []string{"paste-buffer", "-d", "-t", id, "-b", bufferName} +} + +func wrapLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { + path := cfg.Env["PATH"] + if path == "" { + path = getenv("PATH") + } + + var b strings.Builder + for _, key := range sortedKeys(cfg.Env) { + if key == "PATH" { + continue + } + b.WriteString("export ") + b.WriteString(key) + b.WriteString("=") + b.WriteString(shellQuote(cfg.Env[key])) + b.WriteString("; ") + } + if path != "" { + b.WriteString("export PATH=") + b.WriteString(shellQuote(path)) + b.WriteString("; ") + } + b.WriteString(cfg.LaunchCommand) + b.WriteString("; exec ") + b.WriteString(shellQuote(shellPath)) + b.WriteString(" -i") + return b.String() +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go new file mode 100644 index 0000000000..ac0fc6287e --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -0,0 +1,244 @@ +// Package tmux implements ports.Runtime using tmux sessions. +package tmux + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const defaultTimeout = 5 * time.Second +const longMessageThreshold = 512 + +var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +var getenv = os.Getenv + +type Options struct { + Binary string + Timeout time.Duration + Shell string +} + +type Runtime struct { + binary string + timeout time.Duration + shell string + runner runner +} + +var _ ports.Runtime = (*Runtime)(nil) + +type runner interface { + Run(ctx context.Context, name string, args ...string) ([]byte, error) +} + +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).CombinedOutput() +} + +func New(opts Options) *Runtime { + binary := opts.Binary + if binary == "" { + binary = "tmux" + } + timeout := opts.Timeout + if timeout == 0 { + timeout = defaultTimeout + } + shellPath := opts.Shell + if shellPath == "" { + shellPath = os.Getenv("SHELL") + } + if shellPath == "" { + shellPath = "/bin/zsh" + } + return &Runtime{binary: binary, timeout: timeout, shell: shellPath, runner: execRunner{}} +} + +func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { + id := string(cfg.SessionID) + if err := validateSessionID(id); err != nil { + return ports.RuntimeHandle{}, err + } + if cfg.WorkspacePath == "" { + return ports.RuntimeHandle{}, errors.New("tmux runtime: workspace path is required") + } + if cfg.LaunchCommand == "" { + return ports.RuntimeHandle{}, errors.New("tmux runtime: launch command is required") + } + + script := wrapLaunchCommand(cfg, r.shell) + if _, err := r.run(ctx, newSessionArgs(id, cfg.WorkspacePath, r.shell, script)...); err != nil { + return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: create session %s: %w", id, err) + } + if _, err := r.run(ctx, setStatusOffArgs(id)...); err != nil { + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}) + return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: disable status %s: %w", id, err) + } + return ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}, nil +} + +func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error { + id, err := handleID(handle) + if err != nil { + return err + } + alive, err := r.IsAlive(ctx, handle) + if err != nil { + return err + } + if !alive { + return nil + } + if _, err := r.run(ctx, killSessionArgs(id)...); err != nil { + return fmt.Errorf("tmux runtime: destroy session %s: %w", id, err) + } + return nil +} + +func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { + id, err := handleID(handle) + if err != nil { + return err + } + if useBuffer(message) { + return r.sendViaBuffer(ctx, id, message) + } + if _, err := r.run(ctx, sendLiteralArgs(id, message)...); err != nil { + return fmt.Errorf("tmux runtime: send message %s: %w", id, err) + } + if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil { + return fmt.Errorf("tmux runtime: send enter %s: %w", id, err) + } + return nil +} + +func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { + id, err := handleID(handle) + if err != nil { + return "", err + } + if lines <= 0 { + return "", errors.New("tmux runtime: lines must be positive") + } + out, err := r.run(ctx, capturePaneArgs(id, lines)...) + if err != nil { + return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err) + } + return string(out), nil +} + +func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { + id, err := handleID(handle) + if err != nil { + return false, err + } + _, err = r.run(ctx, hasSessionArgs(id)...) + if err == nil { + return true, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return false, nil + } + return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err) +} + +func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { + id, err := handleID(handle) + if err != nil { + return nil, err + } + return append([]string{r.binary}, "attach", "-t", id), nil +} + +func (r *Runtime) sendViaBuffer(ctx context.Context, id, message string) error { + dir := os.TempDir() + file, err := os.CreateTemp(dir, "ao-tmux-message-*") + if err != nil { + return fmt.Errorf("tmux runtime: create message temp file: %w", err) + } + path := file.Name() + defer os.Remove(path) + if _, err := file.WriteString(message); err != nil { + _ = file.Close() + return fmt.Errorf("tmux runtime: write message temp file: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("tmux runtime: close message temp file: %w", err) + } + + bufferName := "ao-" + filepath.Base(path) + if _, err := r.run(ctx, loadBufferArgs(bufferName, path)...); err != nil { + return fmt.Errorf("tmux runtime: load buffer %s: %w", id, err) + } + if _, err := r.run(ctx, pasteBufferArgs(id, bufferName)...); err != nil { + return fmt.Errorf("tmux runtime: paste buffer %s: %w", id, err) + } + if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil { + return fmt.Errorf("tmux runtime: send enter %s: %w", id, err) + } + return nil +} + +func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { + cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + out, err := r.runner.Run(cmdCtx, r.binary, args...) + if cmdCtx.Err() != nil { + return out, cmdCtx.Err() + } + if err != nil { + return out, commandError{err: err, output: strings.TrimSpace(string(out))} + } + return out, nil +} + +func validateSessionID(id string) error { + if id == "" { + return errors.New("tmux runtime: session id is required") + } + if !sessionIDPattern.MatchString(id) { + return fmt.Errorf("tmux runtime: invalid session id %q", id) + } + return nil +} + +func handleID(handle ports.RuntimeHandle) (string, error) { + if handle.RuntimeName != "" && handle.RuntimeName != runtimeName { + return "", fmt.Errorf("tmux runtime: wrong runtime %q", handle.RuntimeName) + } + if err := validateSessionID(handle.ID); err != nil { + return "", err + } + return handle.ID, nil +} + +func useBuffer(message string) bool { + return strings.Contains(message, "\n") || len(message) > longMessageThreshold +} + +type commandError struct { + err error + output string +} + +func (e commandError) Error() string { + if e.output == "" { + return e.err.Error() + } + return e.err.Error() + ": " + e.output +} + +func (e commandError) Unwrap() error { return e.err } diff --git a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go new file mode 100644 index 0000000000..7d6d813778 --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go @@ -0,0 +1,71 @@ +package tmux + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestRuntimeIntegration(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux unavailable") + } + + r := New(Options{Timeout: 5 * time.Second}) + ctx := context.Background() + id := "ao_itest_tmux" + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}) + + h, err := r.Create(ctx, ports.RuntimeConfig{ + SessionID: "ao_itest_tmux", + WorkspacePath: t.TempDir(), + LaunchCommand: "printf ready\\n", + Env: map[string]string{"AO_SESSION_ID": id}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer r.Destroy(ctx, h) + + alive, err := r.IsAlive(ctx, h) + if err != nil { + t.Fatalf("IsAlive: %v", err) + } + if !alive { + t.Fatal("alive = false, want true") + } + + if err := r.SendMessage(ctx, h, "printf hello-from-tmux"); err != nil { + t.Fatalf("SendMessage: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + var out string + for time.Now().Before(deadline) { + out, err = r.GetOutput(ctx, h, 20) + if err != nil { + t.Fatalf("GetOutput: %v", err) + } + if strings.Contains(out, "hello-from-tmux") { + break + } + time.Sleep(100 * time.Millisecond) + } + if !strings.Contains(out, "hello-from-tmux") { + t.Fatalf("output = %q, want sent command output", out) + } + + if err := r.Destroy(ctx, h); err != nil { + t.Fatalf("Destroy: %v", err) + } + alive, err = r.IsAlive(ctx, h) + if err != nil { + t.Fatalf("IsAlive after destroy: %v", err) + } + if alive { + t.Fatal("alive after destroy = true, want false") + } +} diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go new file mode 100644 index 0000000000..baa1d7fc78 --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -0,0 +1,199 @@ +package tmux + +import ( + "context" + "errors" + "os/exec" + "reflect" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCommandBuilders(t *testing.T) { + if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/zsh", "echo hi"), []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws", "/bin/zsh", "-lc", "echo hi"}; !reflect.DeepEqual(got, want) { + t.Fatalf("newSessionArgs = %#v, want %#v", got, want) + } + if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "sess-1", "status", "off"}; !reflect.DeepEqual(got, want) { + t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want) + } + if got, want := capturePaneArgs("sess-1", 42), []string{"capture-pane", "-p", "-t", "sess-1", "-S", "-42"}; !reflect.DeepEqual(got, want) { + t.Fatalf("capturePaneArgs = %#v, want %#v", got, want) + } +} + +func TestValidateSessionID(t *testing.T) { + valid := []string{"sess-1", "S_2", "abc123"} + for _, id := range valid { + if err := validateSessionID(id); err != nil { + t.Fatalf("validateSessionID(%q): %v", id, err) + } + } + invalid := []string{"", "sess.1", "sess/1", "$(boom)", "with space"} + for _, id := range invalid { + if err := validateSessionID(id); err == nil { + t.Fatalf("validateSessionID(%q): got nil, want error", id) + } + } +} + +func TestWrapLaunchCommandExportsEnvAndKeepsPaneAlive(t *testing.T) { + oldGetenv := getenv + getenv = func(key string) string { + if key == "PATH" { + return "/usr/bin:/bin" + } + return "" + } + defer func() { getenv = oldGetenv }() + + got := wrapLaunchCommand(ports.RuntimeConfig{LaunchCommand: "ao run", Env: map[string]string{ + "AO_SESSION_ID": "sess-1", + "ODD": "can't", + "PATH": "/custom/bin:/usr/bin", + }}, "/bin/zsh") + + for _, want := range []string{ + "export AO_SESSION_ID='sess-1';", + "export ODD='can'\\''t';", + "export PATH='/custom/bin:/usr/bin';", + "ao run; exec '/bin/zsh' -i", + } { + if !strings.Contains(got, want) { + t.Fatalf("wrapped command missing %q in %q", want, got) + } + } +} + +func TestCreateRunsNewSessionAndDisablesStatus(t *testing.T) { + fr := &fakeRunner{} + r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/zsh"}) + r.runner = fr + + handle, err := r.Create(context.Background(), ports.RuntimeConfig{ + SessionID: "sess-1", + WorkspacePath: "/tmp/ws", + LaunchCommand: "echo ready", + Env: map[string]string{"AO_SESSION_ID": "sess-1"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if handle != (ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}) { + t.Fatalf("handle = %+v, want tmux handle", handle) + } + if len(fr.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(fr.calls)) + } + if got, want := fr.calls[0].args[:6], []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws"}; !reflect.DeepEqual(got, want) { + t.Fatalf("create args prefix = %#v, want %#v", got, want) + } + if got, want := fr.calls[1].args, setStatusOffArgs("sess-1"); !reflect.DeepEqual(got, want) { + t.Fatalf("status args = %#v, want %#v", got, want) + } +} + +func TestSendMessageUsesLiteralForShortInput(t *testing.T) { + fr := &fakeRunner{} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, "hello"); err != nil { + t.Fatalf("SendMessage: %v", err) + } + if got, want := fr.calls[0].args, sendLiteralArgs("sess-1", "hello"); !reflect.DeepEqual(got, want) { + t.Fatalf("literal args = %#v, want %#v", got, want) + } + if got, want := fr.calls[1].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) { + t.Fatalf("enter args = %#v, want %#v", got, want) + } +} + +func TestSendMessageUsesBufferForMultilineInput(t *testing.T) { + fr := &fakeRunner{} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, "hello\nworld"); err != nil { + t.Fatalf("SendMessage: %v", err) + } + if len(fr.calls) != 3 { + t.Fatalf("calls = %d, want 3", len(fr.calls)) + } + if fr.calls[0].args[0] != "load-buffer" { + t.Fatalf("first command = %#v, want load-buffer", fr.calls[0].args) + } + if got := fr.calls[1].args; !reflect.DeepEqual(got[:4], []string{"paste-buffer", "-d", "-t", "sess-1"}) { + t.Fatalf("paste args = %#v", got) + } + if got, want := fr.calls[2].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) { + t.Fatalf("enter args = %#v, want %#v", got, want) + } +} + +func TestIsAliveTreatsExitStatusAsNotAlive(t *testing.T) { + fr := &fakeRunner{err: &exec.ExitError{}} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}) + if err != nil { + t.Fatalf("IsAlive: %v", err) + } + if alive { + t.Fatal("alive = true, want false") + } +} + +func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { + fr := &fakeRunner{err: &exec.ExitError{}} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}); err != nil { + t.Fatalf("Destroy: %v", err) + } + if len(fr.calls) != 1 || fr.calls[0].args[0] != "has-session" { + t.Fatalf("calls = %#v, want only has-session", fr.calls) + } +} + +func TestGetOutputValidatesLines(t *testing.T) { + r := New(Options{Timeout: time.Second}) + _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, 0) + if err == nil { + t.Fatal("GetOutput lines=0: got nil, want error") + } +} + +type fakeRunner struct { + calls []runnerCall + out []byte + err error +} + +type runnerCall struct { + name string + args []string +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, runnerCall{name: name, args: append([]string(nil), args...)}) + if f.err != nil { + return f.out, f.err + } + return f.out, nil +} + +func TestCommandErrorUnwraps(t *testing.T) { + base := errors.New("base") + err := commandError{err: base, output: "details"} + if !errors.Is(err, base) { + t.Fatal("commandError should unwrap base error") + } + if !strings.Contains(err.Error(), "details") { + t.Fatalf("error = %q, want output details", err.Error()) + } +} From 9df3fb59bf06f35e90fe92a78e060df4b16d82ba Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 17:12:40 +0530 Subject: [PATCH 025/250] feat: add git worktree workspace adapter --- .../workspace/gitworktree/commands.go | 40 ++ .../adapters/workspace/gitworktree/parse.go | 75 +++ .../workspace/gitworktree/workspace.go | 426 ++++++++++++++++++ .../gitworktree/workspace_integration_test.go | 146 ++++++ .../workspace/gitworktree/workspace_test.go | 187 ++++++++ 5 files changed, 874 insertions(+) create mode 100644 backend/internal/adapters/workspace/gitworktree/commands.go create mode 100644 backend/internal/adapters/workspace/gitworktree/parse.go create mode 100644 backend/internal/adapters/workspace/gitworktree/workspace.go create mode 100644 backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go create mode 100644 backend/internal/adapters/workspace/gitworktree/workspace_test.go diff --git a/backend/internal/adapters/workspace/gitworktree/commands.go b/backend/internal/adapters/workspace/gitworktree/commands.go new file mode 100644 index 0000000000..739616c94f --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/commands.go @@ -0,0 +1,40 @@ +package gitworktree + +func checkRefFormatBranchArgs(repo, branch string) []string { + return []string{"-C", repo, "check-ref-format", "--branch", branch} +} + +func revParseVerifyArgs(repo, ref string) []string { + return []string{"-C", repo, "rev-parse", "--verify", "--quiet", ref} +} + +func worktreeAddBranchArgs(repo, path, branch string) []string { + return []string{"-C", repo, "worktree", "add", path, branch} +} + +func worktreeAddNewBranchArgs(repo, branch, path, baseRef string) []string { + return []string{"-C", repo, "worktree", "add", "-b", branch, path, baseRef} +} + +func worktreeRemoveForceArgs(repo, path string) []string { + return []string{"-C", repo, "worktree", "remove", "--force", path} +} + +func worktreePruneArgs(repo string) []string { + return []string{"-C", repo, "worktree", "prune"} +} + +func worktreeListPorcelainArgs(repo string) []string { + return []string{"-C", repo, "worktree", "list", "--porcelain"} +} + +func baseRefCandidates(branch, defaultBranch string) []string { + return []string{"origin/" + branch, "origin/" + defaultBranch, branch} +} + +func chooseWorktreeAddArgs(repo, path, branch, baseRef string, localBranchExists bool) []string { + if localBranchExists { + return worktreeAddBranchArgs(repo, path, branch) + } + return worktreeAddNewBranchArgs(repo, branch, path, baseRef) +} diff --git a/backend/internal/adapters/workspace/gitworktree/parse.go b/backend/internal/adapters/workspace/gitworktree/parse.go new file mode 100644 index 0000000000..5b2947ba9d --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/parse.go @@ -0,0 +1,75 @@ +package gitworktree + +import ( + "bufio" + "strings" +) + +type worktreeRecord struct { + Path string + Branch string + Head string + Bare bool + Detached bool + Locked bool + Prunable bool +} + +func parseWorktreePorcelain(out string) ([]worktreeRecord, error) { + var records []worktreeRecord + var cur *worktreeRecord + + flush := func() { + if cur != nil && cur.Path != "" { + records = append(records, *cur) + } + cur = nil + } + + s := bufio.NewScanner(strings.NewReader(out)) + for s.Scan() { + line := strings.TrimRight(s.Text(), "\r") + if line == "" { + flush() + continue + } + key, val, hasValue := strings.Cut(line, " ") + switch key { + case "worktree": + flush() + cur = &worktreeRecord{} + if hasValue { + cur.Path = val + } + case "HEAD": + if cur != nil && hasValue { + cur.Head = val + } + case "branch": + if cur != nil && hasValue { + cur.Branch = strings.TrimPrefix(val, "refs/heads/") + } + case "bare": + if cur != nil { + cur.Bare = true + } + case "detached": + if cur != nil { + cur.Detached = true + } + case "locked": + if cur != nil { + cur.Locked = true + } + case "prunable": + if cur != nil { + cur.Prunable = true + } + } + } + if err := s.Err(); err != nil { + return nil, err + } + flush() + return records, nil +} diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go new file mode 100644 index 0000000000..e90db12c9a --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -0,0 +1,426 @@ +package gitworktree + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + defaultGitBinary = "git" + defaultBranch = "main" +) + +var ( + ErrUnsafePath = errors.New("gitworktree: unsafe workspace path") +) + +type RepoResolver interface { + RepoPath(projectID domain.ProjectID) (string, error) +} + +type StaticRepoResolver map[domain.ProjectID]string + +func (r StaticRepoResolver) RepoPath(projectID domain.ProjectID) (string, error) { + path := r[projectID] + if path == "" { + return "", fmt.Errorf("gitworktree: no repo configured for project %q", projectID) + } + return path, nil +} + +type Options struct { + Binary string + ManagedRoot string + DefaultBranch string + RepoResolver RepoResolver +} + +type Workspace struct { + binary string + managedRoot string + defaultBranch string + repos RepoResolver + run commandRunner +} + +type commandRunner func(ctx context.Context, binary string, args ...string) ([]byte, error) + +var _ ports.Workspace = (*Workspace)(nil) + +func New(opts Options) (*Workspace, error) { + binary := opts.Binary + if binary == "" { + binary = defaultGitBinary + } + branch := opts.DefaultBranch + if branch == "" { + branch = defaultBranch + } + if opts.ManagedRoot == "" { + return nil, errors.New("gitworktree: ManagedRoot is required") + } + if opts.RepoResolver == nil { + return nil, errors.New("gitworktree: RepoResolver is required") + } + root, err := physicalAbs(opts.ManagedRoot) + if err != nil { + return nil, fmt.Errorf("gitworktree: managed root: %w", err) + } + return &Workspace{ + binary: binary, + managedRoot: filepath.Clean(root), + defaultBranch: branch, + repos: opts.RepoResolver, + run: runCommand, + }, nil +} + +func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + if err := validateConfig(cfg); err != nil { + return ports.WorkspaceInfo{}, err + } + repo, err := w.repoPath(cfg.ProjectID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + if err := w.validateBranch(ctx, repo, cfg.Branch); err != nil { + return ports.WorkspaceInfo{}, err + } + path, err := w.managedPath(cfg.ProjectID, cfg.SessionID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + if err := w.addWorktree(ctx, repo, path, cfg.Branch); err != nil { + return ports.WorkspaceInfo{}, err + } + return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil +} + +func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error { + if info.ProjectID == "" { + return errors.New("gitworktree: project id is required") + } + if info.Path == "" { + return fmt.Errorf("%w: empty path", ErrUnsafePath) + } + repo, err := w.repoPath(info.ProjectID) + if err != nil { + return err + } + path, err := w.validateManagedPath(info.Path) + if err != nil { + return err + } + _, removeErr := w.run(ctx, w.binary, worktreeRemoveForceArgs(repo, path)...) + if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil { + return fmt.Errorf("gitworktree: worktree prune: %w", err) + } + records, err := w.listRecords(ctx, repo) + if err != nil { + return err + } + if worktreeRegistered(records, path) { + if removeErr != nil { + return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune (worktree remove: %w)", path, removeErr) + } + return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune", path) + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("gitworktree: remove unregistered path %q: %w", path, err) + } + return nil +} + +func (w *Workspace) List(ctx context.Context, project domain.ProjectID) ([]ports.WorkspaceInfo, error) { + if project == "" { + return nil, errors.New("gitworktree: project id is required") + } + repo, err := w.repoPath(project) + if err != nil { + return nil, err + } + records, err := w.listRecords(ctx, repo) + if err != nil { + return nil, err + } + projectRoot, err := w.projectRoot(project) + if err != nil { + return nil, err + } + return filterProjectWorktrees(records, projectRoot, project), nil +} + +func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + if err := validateConfig(cfg); err != nil { + return ports.WorkspaceInfo{}, err + } + repo, err := w.repoPath(cfg.ProjectID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + path, err := w.managedPath(cfg.ProjectID, cfg.SessionID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + records, err := w.listRecords(ctx, repo) + if err != nil { + return ports.WorkspaceInfo{}, err + } + if rec, ok := findWorktree(records, path); ok { + branch := rec.Branch + if branch == "" { + branch = cfg.Branch + } + return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil + } + if nonEmpty, err := pathExistsNonEmpty(path); err != nil { + return ports.WorkspaceInfo{}, err + } else if nonEmpty { + return ports.WorkspaceInfo{}, fmt.Errorf("gitworktree: refusing to restore %q: path exists and is not a registered worktree", path) + } + if err := w.validateBranch(ctx, repo, cfg.Branch); err != nil { + return ports.WorkspaceInfo{}, err + } + if err := w.addWorktree(ctx, repo, path, cfg.Branch); err != nil { + return ports.WorkspaceInfo{}, err + } + return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil +} + +func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string) error { + localBranch, err := w.refExists(ctx, repo, "refs/heads/"+branch) + if err != nil { + return err + } + if localBranch { + if _, err := w.run(ctx, w.binary, chooseWorktreeAddArgs(repo, path, branch, "", true)...); err != nil { + return fmt.Errorf("gitworktree: worktree add existing branch %q: %w", branch, err) + } + return nil + } + baseRef, err := w.resolveBaseRef(ctx, repo, branch) + if err != nil { + return err + } + if _, err := w.run(ctx, w.binary, chooseWorktreeAddArgs(repo, path, branch, baseRef, false)...); err != nil { + return fmt.Errorf("gitworktree: worktree add branch %q from %q: %w", branch, baseRef, err) + } + return nil +} + +func (w *Workspace) validateBranch(ctx context.Context, repo, branch string) error { + if _, err := w.run(ctx, w.binary, checkRefFormatBranchArgs(repo, branch)...); err != nil { + return fmt.Errorf("gitworktree: invalid branch %q: %w", branch, err) + } + return nil +} + +func (w *Workspace) resolveBaseRef(ctx context.Context, repo, branch string) (string, error) { + candidates := baseRefCandidates(branch, w.defaultBranch) + for _, ref := range candidates { + exists, err := w.refExists(ctx, repo, ref) + if err != nil { + return "", err + } + if exists { + return ref, nil + } + } + return "", fmt.Errorf("gitworktree: no base ref found for branch %q (tried %s)", branch, strings.Join(candidates, ", ")) +} + +func (w *Workspace) refExists(ctx context.Context, repo, ref string) (bool, error) { + _, err := w.run(ctx, w.binary, revParseVerifyArgs(repo, ref)...) + if err == nil { + return true, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("gitworktree: verify ref %q: %w", ref, err) +} + +func (w *Workspace) listRecords(ctx context.Context, repo string) ([]worktreeRecord, error) { + out, err := w.run(ctx, w.binary, worktreeListPorcelainArgs(repo)...) + if err != nil { + return nil, fmt.Errorf("gitworktree: worktree list: %w", err) + } + records, err := parseWorktreePorcelain(string(out)) + if err != nil { + return nil, fmt.Errorf("gitworktree: parse worktree list: %w", err) + } + return records, nil +} + +func (w *Workspace) repoPath(project domain.ProjectID) (string, error) { + repo, err := w.repos.RepoPath(project) + if err != nil { + return "", err + } + if repo == "" { + return "", fmt.Errorf("gitworktree: no repo configured for project %q", project) + } + abs, err := physicalAbs(repo) + if err != nil { + return "", fmt.Errorf("gitworktree: repo path: %w", err) + } + return abs, nil +} + +func physicalAbs(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + abs = filepath.Clean(abs) + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return filepath.Clean(resolved), nil + } + parent := filepath.Dir(abs) + base := filepath.Base(abs) + for parent != "." && parent != string(os.PathSeparator) { + if resolved, err := filepath.EvalSymlinks(parent); err == nil { + return filepath.Join(resolved, base), nil + } + base = filepath.Join(filepath.Base(parent), base) + parent = filepath.Dir(parent) + } + if resolved, err := filepath.EvalSymlinks(parent); err == nil { + return filepath.Join(resolved, base), nil + } + return abs, nil +} + +func validateConfig(cfg ports.WorkspaceConfig) error { + if cfg.ProjectID == "" { + return errors.New("gitworktree: project id is required") + } + if cfg.SessionID == "" { + return errors.New("gitworktree: session id is required") + } + if cfg.Branch == "" { + return errors.New("gitworktree: branch is required") + } + return nil +} + +func (w *Workspace) managedPath(project domain.ProjectID, session domain.SessionID) (string, error) { + path := filepath.Join(w.managedRoot, string(project), string(session)) + return w.validateManagedPath(path) +} + +func (w *Workspace) projectRoot(project domain.ProjectID) (string, error) { + path := filepath.Join(w.managedRoot, string(project)) + return w.validateManagedPath(path) +} + +func (w *Workspace) validateManagedPath(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("%w: empty path", ErrUnsafePath) + } + if !filepath.IsAbs(path) { + return "", fmt.Errorf("%w: %q is not absolute", ErrUnsafePath, path) + } + clean := filepath.Clean(path) + if clean != path { + return "", fmt.Errorf("%w: %q is not clean", ErrUnsafePath, path) + } + physical, err := physicalAbs(clean) + if err != nil { + return "", fmt.Errorf("gitworktree: resolve path %q: %w", path, err) + } + clean = physical + inside, err := pathWithin(w.managedRoot, clean) + if err != nil { + return "", err + } + if !inside || clean == w.managedRoot { + return "", fmt.Errorf("%w: %q is outside managed root %q", ErrUnsafePath, clean, w.managedRoot) + } + return clean, nil +} + +func pathWithin(root, path string) (bool, error) { + rel, err := filepath.Rel(root, path) + if err != nil { + return false, fmt.Errorf("gitworktree: compare paths: %w", err) + } + return rel == "." || (rel != "" && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))), nil +} + +func filterProjectWorktrees(records []worktreeRecord, projectRoot string, project domain.ProjectID) []ports.WorkspaceInfo { + out := make([]ports.WorkspaceInfo, 0, len(records)) + for _, rec := range records { + path := filepath.Clean(rec.Path) + inside, err := pathWithin(projectRoot, path) + if err != nil || !inside || path == projectRoot { + continue + } + out = append(out, ports.WorkspaceInfo{ + Path: path, + Branch: rec.Branch, + SessionID: domain.SessionID(filepath.Base(path)), + ProjectID: project, + }) + } + return out +} + +func worktreeRegistered(records []worktreeRecord, path string) bool { + _, ok := findWorktree(records, path) + return ok +} + +func findWorktree(records []worktreeRecord, path string) (worktreeRecord, bool) { + clean := filepath.Clean(path) + for _, rec := range records { + if filepath.Clean(rec.Path) == clean { + return rec, true + } + } + return worktreeRecord{}, false +} + +func pathExistsNonEmpty(path string) (bool, error) { + entries, err := os.ReadDir(path) + if err == nil { + return len(entries) > 0, nil + } + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("gitworktree: inspect path %q: %w", path, err) +} + +func runCommand(ctx context.Context, binary string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, binary, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return out, commandError{args: append([]string{binary}, args...), output: string(out), err: err} + } + return out, nil +} + +type commandError struct { + args []string + output string + err error +} + +func (e commandError) Error() string { + if strings.TrimSpace(e.output) == "" { + return fmt.Sprintf("%s: %v", strings.Join(e.args, " "), e.err) + } + return fmt.Sprintf("%s: %v: %s", strings.Join(e.args, " "), e.err, strings.TrimSpace(e.output)) +} + +func (e commandError) Unwrap() error { return e.err } diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go new file mode 100644 index 0000000000..2b435c8558 --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go @@ -0,0 +1,146 @@ +package gitworktree + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestWorkspaceIntegrationCreateListRestoreDestroy(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := setupOriginClone(t, git, tmp) + root := filepath.Join(tmp, "managed") + ws, err := New(Options{Binary: git, ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ctx := context.Background() + cfg := ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "feature/one"} + + info, err := ws.Create(ctx, cfg) + if err != nil { + t.Fatalf("create: %v", err) + } + if info.Path != filepath.Join(ws.managedRoot, "proj", "sess") || info.Branch != cfg.Branch || info.SessionID != cfg.SessionID || info.ProjectID != cfg.ProjectID { + t.Fatalf("info = %#v", info) + } + if _, err := os.Stat(filepath.Join(info.Path, "README.md")); err != nil { + t.Fatalf("created worktree missing seed file: %v", err) + } + + listed, err := ws.List(ctx, "proj") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(listed) != 1 || listed[0].Path != info.Path || listed[0].Branch != cfg.Branch || listed[0].SessionID != cfg.SessionID { + t.Fatalf("listed = %#v", listed) + } + + restored, err := ws.Restore(ctx, cfg) + if err != nil { + t.Fatalf("restore registered: %v", err) + } + if restored.Path != info.Path || restored.Branch != cfg.Branch { + t.Fatalf("restored = %#v", restored) + } + + if err := ws.Destroy(ctx, info); err != nil { + t.Fatalf("destroy: %v", err) + } + if _, err := os.Stat(info.Path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("path after destroy stat err = %v, want not exist", err) + } + + restored, err = ws.Restore(ctx, cfg) + if err != nil { + t.Fatalf("restore after destroy: %v", err) + } + if restored.Path != info.Path || restored.Branch != cfg.Branch { + t.Fatalf("restored after destroy = %#v", restored) + } + if err := ws.Destroy(ctx, restored); err != nil { + t.Fatalf("destroy restored: %v", err) + } +} + +func TestWorkspaceIntegrationDestroyRefusesLockedWorktree(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := setupOriginClone(t, git, tmp) + root := filepath.Join(tmp, "managed") + ws, err := New(Options{Binary: git, ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ctx := context.Background() + info, err := ws.Create(ctx, ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "feature/lock"}) + if err != nil { + t.Fatalf("create: %v", err) + } + runGit(t, git, repo, "worktree", "lock", info.Path) + + err = ws.Destroy(ctx, info) + if err == nil || !strings.Contains(err.Error(), "still registered") { + t.Fatalf("destroy locked error = %v, want still registered refusal", err) + } + if _, statErr := os.Stat(filepath.Join(info.Path, "README.md")); statErr != nil { + t.Fatalf("locked worktree was not preserved: %v", statErr) + } + + runGit(t, git, repo, "worktree", "unlock", info.Path) + if err := ws.Destroy(ctx, info); err != nil { + t.Fatalf("destroy after unlock: %v", err) + } +} + +func requireGit(t *testing.T) string { + t.Helper() + git, err := exec.LookPath("git") + if err != nil { + t.Skip("git not found") + } + return git +} + +func setupOriginClone(t *testing.T, git, tmp string) string { + t.Helper() + origin := filepath.Join(tmp, "origin.git") + seed := filepath.Join(tmp, "seed") + repo := filepath.Join(tmp, "repo") + run(t, git, "init", "--bare", origin) + run(t, git, "init", seed) + runGit(t, git, seed, "config", "user.email", "ao@example.com") + runGit(t, git, seed, "config", "user.name", "Ao Agents") + if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("seed\n"), 0o644); err != nil { + t.Fatalf("write seed: %v", err) + } + runGit(t, git, seed, "add", "README.md") + runGit(t, git, seed, "commit", "-m", "seed") + runGit(t, git, seed, "branch", "-M", "main") + runGit(t, git, seed, "remote", "add", "origin", origin) + runGit(t, git, seed, "push", "-u", "origin", "main") + run(t, git, "clone", origin, repo) + runGit(t, git, repo, "checkout", "main") + return repo +} + +func runGit(t *testing.T, git, dir string, args ...string) { + t.Helper() + run(t, git, append([]string{"-C", dir}, args...)...) +} + +func run(t *testing.T, binary string, args ...string) { + t.Helper() + cmd := exec.Command(binary, args...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s: %v\n%s", binary, strings.Join(args, " "), err, out) + } +} diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_test.go new file mode 100644 index 0000000000..7e56529de1 --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/workspace_test.go @@ -0,0 +1,187 @@ +package gitworktree + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCommandArgs(t *testing.T) { + repo := "/repo" + path := "/managed/proj/sess" + branch := "feature/test" + + cases := []struct { + name string + got []string + want []string + }{ + {"check ref", checkRefFormatBranchArgs(repo, branch), []string{"-C", repo, "check-ref-format", "--branch", branch}}, + {"rev parse", revParseVerifyArgs(repo, "origin/main"), []string{"-C", repo, "rev-parse", "--verify", "--quiet", "origin/main"}}, + {"add existing", chooseWorktreeAddArgs(repo, path, branch, "", true), []string{"-C", repo, "worktree", "add", path, branch}}, + {"add new", chooseWorktreeAddArgs(repo, path, branch, "origin/main", false), []string{"-C", repo, "worktree", "add", "-b", branch, path, "origin/main"}}, + {"remove", worktreeRemoveForceArgs(repo, path), []string{"-C", repo, "worktree", "remove", "--force", path}}, + {"prune", worktreePruneArgs(repo), []string{"-C", repo, "worktree", "prune"}}, + {"list", worktreeListPorcelainArgs(repo), []string{"-C", repo, "worktree", "list", "--porcelain"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if !reflect.DeepEqual(tc.got, tc.want) { + t.Fatalf("args = %#v, want %#v", tc.got, tc.want) + } + }) + } +} + +func TestBaseRefCandidates(t *testing.T) { + got := baseRefCandidates("feature/test", "main") + want := []string{"origin/feature/test", "origin/main", "feature/test"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("candidates = %#v, want %#v", got, want) + } +} + +func TestParseWorktreePorcelain(t *testing.T) { + input := strings.Join([]string{ + "worktree /repo", + "HEAD abc123", + "branch refs/heads/main", + "", + "worktree /managed/proj/sess1", + "HEAD def456", + "branch refs/heads/feature/test", + "", + "worktree /managed/proj/sess2", + "HEAD 789abc", + "detached", + "", + "worktree /bare", + "bare", + "", + }, "\n") + + recs, err := parseWorktreePorcelain(input) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(recs) != 4 { + t.Fatalf("len = %d, want 4: %#v", len(recs), recs) + } + if recs[1].Path != "/managed/proj/sess1" || recs[1].Branch != "feature/test" { + t.Fatalf("normal record = %#v", recs[1]) + } + if !recs[2].Detached || recs[2].Branch != "" { + t.Fatalf("detached record = %#v", recs[2]) + } + if !recs[3].Bare { + t.Fatalf("bare record = %#v", recs[3]) + } +} + +func TestFilterProjectWorktrees(t *testing.T) { + root := filepath.Clean("/managed/proj") + recs := []worktreeRecord{ + {Path: "/repo", Branch: "main"}, + {Path: "/managed/proj/s1", Branch: "feature/one"}, + {Path: "/managed/proj/s2", Branch: ""}, + {Path: "/managed/other/s3", Branch: "feature/three"}, + } + got := filterProjectWorktrees(recs, root, domain.ProjectID("proj")) + if len(got) != 2 { + t.Fatalf("len = %d, want 2: %#v", len(got), got) + } + if got[0].SessionID != "s1" || got[0].Branch != "feature/one" || got[0].ProjectID != "proj" { + t.Fatalf("first = %#v", got[0]) + } + if got[1].SessionID != "s2" || got[1].Branch != "" { + t.Fatalf("second = %#v", got[1]) + } +} + +func TestManagedPathSafety(t *testing.T) { + root := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": root}}) + if err != nil { + t.Fatalf("new: %v", err) + } + path, err := ws.managedPath("proj", "sess") + if err != nil { + t.Fatalf("managed path: %v", err) + } + if want := filepath.Join(ws.managedRoot, "proj", "sess"); path != want { + t.Fatalf("path = %q, want %q", path, want) + } + if _, err := ws.validateManagedPath(filepath.Join(root, "..", "outside")); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("outside error = %v, want ErrUnsafePath", err) + } + if _, err := ws.validateManagedPath("relative/path"); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("relative error = %v, want ErrUnsafePath", err) + } +} + +func TestRestoreRefusesNonEmptyUnregisteredPath(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ws.run = func(context.Context, string, ...string) ([]byte, error) { + return []byte("worktree " + repo + "\nbranch refs/heads/main\n"), nil + } + path := filepath.Join(ws.managedRoot, "proj", "sess") + if err := mkdirFile(path, "keep.txt"); err != nil { + t.Fatalf("seed path: %v", err) + } + _, err = ws.Restore(context.Background(), ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "feature/one"}) + if err == nil || !strings.Contains(err.Error(), "path exists and is not a registered worktree") { + t.Fatalf("restore error = %v", err) + } +} + +func TestDestroyRefusesStillRegisteredPathAndPreservesDirectory(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + path := filepath.Join(ws.managedRoot, "proj", "sess") + if err := mkdirFile(path, "keep.txt"); err != nil { + t.Fatalf("seed path: %v", err) + } + ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "worktree remove"): + return []byte("locked"), errors.New("remove failed") + case strings.Contains(joined, "worktree prune"): + return nil, nil + case strings.Contains(joined, "worktree list --porcelain"): + return []byte("worktree " + path + "\nbranch refs/heads/feature/one\n"), nil + default: + return nil, nil + } + } + err = ws.Destroy(context.Background(), ports.WorkspaceInfo{Path: path, ProjectID: "proj", SessionID: "sess", Branch: "feature/one"}) + if err == nil || !strings.Contains(err.Error(), "still registered") { + t.Fatalf("destroy error = %v", err) + } + if _, statErr := os.Stat(filepath.Join(path, "keep.txt")); statErr != nil { + t.Fatalf("expected directory to be preserved: %v", statErr) + } +} + +func mkdirFile(dir, name string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, name), []byte("data"), 0o644) +} From b5a344ac07026c323d67c5cf95863d38f2ce796a Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 17:17:59 +0530 Subject: [PATCH 026/250] fix: use exact tmux targets --- .../adapters/runtime/tmux/commands.go | 22 ++++++---- .../internal/adapters/runtime/tmux/tmux.go | 2 +- .../runtime/tmux/tmux_integration_test.go | 41 +++++++++++++++++++ .../adapters/runtime/tmux/tmux_test.go | 15 +++++-- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/backend/internal/adapters/runtime/tmux/commands.go b/backend/internal/adapters/runtime/tmux/commands.go index 700d369cf2..6cf8739ebd 100644 --- a/backend/internal/adapters/runtime/tmux/commands.go +++ b/backend/internal/adapters/runtime/tmux/commands.go @@ -15,27 +15,27 @@ func newSessionArgs(id, workspacePath, shellPath, script string) []string { } func setStatusOffArgs(id string) []string { - return []string{"set-option", "-t", id, "status", "off"} + return []string{"set-option", "-t", exactSessionTarget(id), "status", "off"} } func hasSessionArgs(id string) []string { - return []string{"has-session", "-t", id} + return []string{"has-session", "-t", exactSessionTarget(id)} } func killSessionArgs(id string) []string { - return []string{"kill-session", "-t", id} + return []string{"kill-session", "-t", exactSessionTarget(id)} } func capturePaneArgs(id string, lines int) []string { - return []string{"capture-pane", "-p", "-t", id, "-S", fmt.Sprintf("-%d", lines)} + return []string{"capture-pane", "-p", "-t", exactPaneTarget(id), "-S", fmt.Sprintf("-%d", lines)} } func sendLiteralArgs(id, message string) []string { - return []string{"send-keys", "-t", id, "-l", message} + return []string{"send-keys", "-t", exactPaneTarget(id), "-l", message} } func sendEnterArgs(id string) []string { - return []string{"send-keys", "-t", id, "C-m"} + return []string{"send-keys", "-t", exactPaneTarget(id), "C-m"} } func loadBufferArgs(bufferName, path string) []string { @@ -43,7 +43,15 @@ func loadBufferArgs(bufferName, path string) []string { } func pasteBufferArgs(id, bufferName string) []string { - return []string{"paste-buffer", "-d", "-t", id, "-b", bufferName} + return []string{"paste-buffer", "-d", "-t", exactPaneTarget(id), "-b", bufferName} +} + +func exactSessionTarget(id string) string { + return "=" + id + ":" +} + +func exactPaneTarget(id string) string { + return "=" + id + ":0.0" } func wrapLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index ac0fc6287e..5fbbafb25d 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -160,7 +160,7 @@ func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { if err != nil { return nil, err } - return append([]string{r.binary}, "attach", "-t", id), nil + return append([]string{r.binary}, "attach", "-t", exactSessionTarget(id)), nil } func (r *Runtime) sendViaBuffer(ctx context.Context, id, message string) error { diff --git a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go index 7d6d813778..7e79867382 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go @@ -69,3 +69,44 @@ func TestRuntimeIntegration(t *testing.T) { t.Fatal("alive after destroy = true, want false") } } + +func TestRuntimeIntegrationUsesExactTargets(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux unavailable") + } + + r := New(Options{Timeout: 5 * time.Second}) + ctx := context.Background() + longID := "ao_exact_target_long" + prefixID := "ao_exact_target" + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID, RuntimeName: runtimeName}) + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}) + + h, err := r.Create(ctx, ports.RuntimeConfig{ + SessionID: "ao_exact_target_long", + WorkspacePath: t.TempDir(), + LaunchCommand: "cat", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer r.Destroy(ctx, h) + + alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}) + if err != nil { + t.Fatalf("IsAlive prefix: %v", err) + } + if alive { + t.Fatal("prefix handle reported alive; tmux target matching is not exact") + } + if err := r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}); err != nil { + t.Fatalf("Destroy prefix: %v", err) + } + alive, err = r.IsAlive(ctx, h) + if err != nil { + t.Fatalf("IsAlive long after prefix destroy: %v", err) + } + if !alive { + t.Fatal("destroying prefix handle killed longer session") + } +} diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go index baa1d7fc78..1d46160996 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -16,14 +16,23 @@ func TestCommandBuilders(t *testing.T) { if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/zsh", "echo hi"), []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws", "/bin/zsh", "-lc", "echo hi"}; !reflect.DeepEqual(got, want) { t.Fatalf("newSessionArgs = %#v, want %#v", got, want) } - if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "sess-1", "status", "off"}; !reflect.DeepEqual(got, want) { + if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "=sess-1:", "status", "off"}; !reflect.DeepEqual(got, want) { t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want) } - if got, want := capturePaneArgs("sess-1", 42), []string{"capture-pane", "-p", "-t", "sess-1", "-S", "-42"}; !reflect.DeepEqual(got, want) { + if got, want := capturePaneArgs("sess-1", 42), []string{"capture-pane", "-p", "-t", "=sess-1:0.0", "-S", "-42"}; !reflect.DeepEqual(got, want) { t.Fatalf("capturePaneArgs = %#v, want %#v", got, want) } } +func TestExactTargets(t *testing.T) { + if got, want := exactSessionTarget("abc"), "=abc:"; got != want { + t.Fatalf("exactSessionTarget = %q, want %q", got, want) + } + if got, want := exactPaneTarget("abc"), "=abc:0.0"; got != want { + t.Fatalf("exactPaneTarget = %q, want %q", got, want) + } +} + func TestValidateSessionID(t *testing.T) { valid := []string{"sess-1", "S_2", "abc123"} for _, id := range valid { @@ -125,7 +134,7 @@ func TestSendMessageUsesBufferForMultilineInput(t *testing.T) { if fr.calls[0].args[0] != "load-buffer" { t.Fatalf("first command = %#v, want load-buffer", fr.calls[0].args) } - if got := fr.calls[1].args; !reflect.DeepEqual(got[:4], []string{"paste-buffer", "-d", "-t", "sess-1"}) { + if got := fr.calls[1].args; !reflect.DeepEqual(got[:4], []string{"paste-buffer", "-d", "-t", "=sess-1:0.0"}) { t.Fatalf("paste args = %#v", got) } if got, want := fr.calls[2].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) { From 37f1fe269e5e248692f405d7d7c099555309d7de Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 18:20:03 +0530 Subject: [PATCH 027/250] fix: harden tmux runtime teardown and ids --- .../internal/adapters/runtime/tmux/tmux.go | 57 +++++++++++++++---- .../adapters/runtime/tmux/tmux_test.go | 52 ++++++++++++++++- 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index 5fbbafb25d..ba7524ed82 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -3,6 +3,8 @@ package tmux import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "os" @@ -12,6 +14,7 @@ import ( "strings" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -61,14 +64,14 @@ func New(opts Options) *Runtime { shellPath = os.Getenv("SHELL") } if shellPath == "" { - shellPath = "/bin/zsh" + shellPath = "/bin/sh" } return &Runtime{binary: binary, timeout: timeout, shell: shellPath, runner: execRunner{}} } func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { - id := string(cfg.SessionID) - if err := validateSessionID(id); err != nil { + id, err := tmuxSessionName(cfg.SessionID) + if err != nil { return ports.RuntimeHandle{}, err } if cfg.WorkspacePath == "" { @@ -94,14 +97,11 @@ func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error if err != nil { return err } - alive, err := r.IsAlive(ctx, handle) - if err != nil { - return err - } - if !alive { - return nil - } if _, err := r.run(ctx, killSessionArgs(id)...); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil + } return fmt.Errorf("tmux runtime: destroy session %s: %w", id, err) } return nil @@ -205,6 +205,43 @@ func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { return out, nil } +func tmuxSessionName(id domain.SessionID) (string, error) { + raw := string(id) + if raw == "" { + return "", errors.New("tmux runtime: session id is required") + } + if sessionIDPattern.MatchString(raw) { + return raw, nil + } + return sanitizedSessionName(raw), nil +} + +func sanitizedSessionName(raw string) string { + var b strings.Builder + lastDash := false + for _, r := range raw { + valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' + if valid { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + base := strings.Trim(b.String(), "-") + if base == "" { + base = "session" + } + if len(base) > 40 { + base = strings.TrimRight(base[:40], "-") + } + sum := sha256.Sum256([]byte(raw)) + return base + "-" + hex.EncodeToString(sum[:4]) +} + func validateSessionID(id string) error { if id == "" { return errors.New("tmux runtime: session id is required") diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go index 1d46160996..cb56db35ff 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -12,6 +12,14 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) +func TestNewDefaultsToPortableShell(t *testing.T) { + t.Setenv("SHELL", "") + r := New(Options{}) + if got, want := r.shell, "/bin/sh"; got != want { + t.Fatalf("default shell = %q, want %q", got, want) + } +} + func TestCommandBuilders(t *testing.T) { if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/zsh", "echo hi"), []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws", "/bin/zsh", "-lc", "echo hi"}; !reflect.DeepEqual(got, want) { t.Fatalf("newSessionArgs = %#v, want %#v", got, want) @@ -33,6 +41,22 @@ func TestExactTargets(t *testing.T) { } } +func TestTmuxSessionNameSanitizesIssueRefs(t *testing.T) { + got, err := tmuxSessionName("repo/issue#42.1") + if err != nil { + t.Fatalf("tmuxSessionName: %v", err) + } + if err := validateSessionID(got); err != nil { + t.Fatalf("sanitized id %q is invalid: %v", got, err) + } + if !strings.HasPrefix(got, "repo-issue-42-1-") { + t.Fatalf("sanitized id = %q, want readable prefix", got) + } + if got == "repo/issue#42.1" { + t.Fatal("sanitized id still contains raw unsafe characters") + } +} + func TestValidateSessionID(t *testing.T) { valid := []string{"sess-1", "S_2", "abc123"} for _, id := range valid { @@ -104,6 +128,30 @@ func TestCreateRunsNewSessionAndDisablesStatus(t *testing.T) { } } +func TestCreateNormalizesUnsafeSessionID(t *testing.T) { + fr := &fakeRunner{} + r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/sh"}) + r.runner = fr + + handle, err := r.Create(context.Background(), ports.RuntimeConfig{ + SessionID: "repo/issue#42", + WorkspacePath: "/tmp/ws", + LaunchCommand: "echo ready", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := validateSessionID(handle.ID); err != nil { + t.Fatalf("handle id %q invalid: %v", handle.ID, err) + } + if handle.ID == "repo/issue#42" { + t.Fatal("handle kept unsafe raw session id") + } + if got := fr.calls[0].args[3]; got != handle.ID { + t.Fatalf("tmux session arg = %q, want handle id %q", got, handle.ID) + } +} + func TestSendMessageUsesLiteralForShortInput(t *testing.T) { fr := &fakeRunner{} r := New(Options{Timeout: time.Second}) @@ -164,8 +212,8 @@ func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}); err != nil { t.Fatalf("Destroy: %v", err) } - if len(fr.calls) != 1 || fr.calls[0].args[0] != "has-session" { - t.Fatalf("calls = %#v, want only has-session", fr.calls) + if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" { + t.Fatalf("calls = %#v, want only kill-session", fr.calls) } } From 77c01daf42d20e0d179c7c2bc297dc3673f25d15 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 18:34:42 +0530 Subject: [PATCH 028/250] feat: handle draft PR lifecycle state --- backend/internal/domain/decide/decide.go | 13 ++++-- backend/internal/domain/decide/decide_test.go | 44 ++++++++++++++++--- backend/internal/domain/decide/types.go | 3 +- backend/internal/domain/lifecycle.go | 1 + backend/internal/domain/status.go | 17 ++++++- backend/internal/domain/status_test.go | 16 +++++++ backend/internal/lifecycle/manager.go | 14 ++++-- backend/internal/lifecycle/manager_test.go | 28 ++++++++++++ 8 files changed, 120 insertions(+), 16 deletions(-) diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go index e7f2c44572..583b80d045 100644 --- a/backend/internal/domain/decide/decide.go +++ b/backend/internal/domain/decide/decide.go @@ -89,8 +89,9 @@ func ResolveProbeDecision(in ProbeInput) LifecycleDecision { } // ResolveOpenPRDecision walks the PR pipeline ladder. CI failure dominates -// everything, then requested changes, then the approval/merge states, then a -// pending review, then a stalled (idle-beyond-threshold) PR, else plain open. +// everything. Draft PRs then surface as draft and do not enter the review or +// merge states. Open PRs continue through requested changes, approval/merge +// states, pending review, stalled (idle-beyond-threshold), then plain open. func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { // evidence is a stable, timestamp-free summary " # " // for logs/traceability; it folds in the PR identity inputs (Number/URL). @@ -104,13 +105,17 @@ func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { } return s } + prState := domain.PROpen + if in.Draft { + prState = domain.PRDraft + } base := func(status domain.SessionStatus, cond string, prReason domain.PRReason, ss domain.SessionState, sr domain.SessionReason) LifecycleDecision { return LifecycleDecision{ Status: status, Evidence: evidence(cond), SessionState: ss, SessionReason: sr, - PRState: domain.PROpen, + PRState: prState, PRReason: prReason, } } @@ -118,6 +123,8 @@ func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { switch { case in.CIFailing: return base(domain.StatusCIFailed, "ci_failing", domain.PRReasonCIFailing, domain.SessionWorking, domain.ReasonFixingCI) + case in.Draft: + return base(domain.StatusDraft, "draft", domain.PRReasonInProgress, domain.SessionWorking, domain.ReasonPRCreated) case in.ChangesRequested: return base(domain.StatusChangesRequested, "changes_requested", domain.PRReasonChangesRequested, domain.SessionWorking, domain.ReasonResolvingReviewComments) case in.Mergeable: diff --git a/backend/internal/domain/decide/decide_test.go b/backend/internal/domain/decide/decide_test.go index d6e027f1e9..9af6e596ca 100644 --- a/backend/internal/domain/decide/decide_test.go +++ b/backend/internal/domain/decide/decide_test.go @@ -155,11 +155,12 @@ func TestResolveProbeDecision(t *testing.T) { func TestResolveOpenPRDecision(t *testing.T) { tests := []struct { - name string - in OpenPRInput - wantStatus domain.SessionStatus - wantPR domain.PRReason - wantState domain.SessionState + name string + in OpenPRInput + wantStatus domain.SessionStatus + wantPR domain.PRReason + wantPRState domain.PRState + wantState domain.SessionState }{ { name: "ci failing dominates everything", @@ -168,6 +169,22 @@ func TestResolveOpenPRDecision(t *testing.T) { wantPR: domain.PRReasonCIFailing, wantState: domain.SessionWorking, }, + { + name: "draft with failing CI maps to ci_failed", + in: OpenPRInput{Draft: true, CIFailing: true, ChangesRequested: true, Approved: true, Mergeable: true}, + wantStatus: domain.StatusCIFailed, + wantPR: domain.PRReasonCIFailing, + wantPRState: domain.PRDraft, + wantState: domain.SessionWorking, + }, + { + name: "draft ignores review and merge states", + in: OpenPRInput{Draft: true, ChangesRequested: true, Approved: true, Mergeable: true, ReviewPending: true, IdleBeyond: true}, + wantStatus: domain.StatusDraft, + wantPR: domain.PRReasonInProgress, + wantPRState: domain.PRDraft, + wantState: domain.SessionWorking, + }, { name: "changes requested before approval states", in: OpenPRInput{ChangesRequested: true, Approved: true, Mergeable: true}, @@ -235,8 +252,12 @@ func TestResolveOpenPRDecision(t *testing.T) { if got.PRReason != tt.wantPR { t.Errorf("PRReason = %q, want %q", got.PRReason, tt.wantPR) } - if got.PRState != domain.PROpen { - t.Errorf("PRState = %q, want %q", got.PRState, domain.PROpen) + wantPRState := tt.wantPRState + if wantPRState == "" { + wantPRState = domain.PROpen + } + if got.PRState != wantPRState { + t.Errorf("PRState = %q, want %q", got.PRState, wantPRState) } if got.SessionState != tt.wantState { t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState) @@ -287,6 +308,8 @@ func TestDecidersDeriveConsistently(t *testing.T) { var decisions []LifecycleDecision for _, in := range []OpenPRInput{ + {Draft: true, CIFailing: true}, + {Draft: true, ChangesRequested: true, Approved: true, Mergeable: true, ReviewPending: true, IdleBeyond: true}, {CIFailing: true}, {ChangesRequested: true}, {Approved: true, Mergeable: true}, @@ -363,6 +386,13 @@ func TestResolveTerminalPRStateDecision(t *testing.T) { wantState: domain.SessionWorking, wantReason: domain.ReasonTaskInProgress, }, + { + name: "non-terminal draft is a working no-op", + pr: domain.PRDraft, + wantStatus: domain.StatusWorking, + wantState: domain.SessionWorking, + wantReason: domain.ReasonTaskInProgress, + }, } for _, tt := range tests { diff --git a/backend/internal/domain/decide/types.go b/backend/internal/domain/decide/types.go index 7ac4adf1d4..e4fa92cab7 100644 --- a/backend/internal/domain/decide/types.go +++ b/backend/internal/domain/decide/types.go @@ -52,8 +52,9 @@ const ( ProcessIndeterminate ProcessLiveness = "indeterminate" ) -// OpenPRInput drives the PR pipeline ladder for an open PR. +// OpenPRInput drives the PR pipeline ladder for an open or draft PR. type OpenPRInput struct { + Draft bool CIFailing bool ChangesRequested bool Approved bool diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go index 567a47693c..2df4bb13a4 100644 --- a/backend/internal/domain/lifecycle.go +++ b/backend/internal/domain/lifecycle.go @@ -90,6 +90,7 @@ type PRState string const ( PRNone PRState = "none" + PRDraft PRState = "draft" PROpen PRState = "open" PRMerged PRState = "merged" PRClosed PRState = "closed" diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index b12b2b9f63..aff5ba1f13 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -9,6 +9,7 @@ const ( StatusWorking SessionStatus = "working" StatusDetecting SessionStatus = "detecting" StatusPROpen SessionStatus = "pr_open" + StatusDraft SessionStatus = "draft" StatusCIFailed SessionStatus = "ci_failed" StatusReviewPending SessionStatus = "review_pending" StatusChangesRequested SessionStatus = "changes_requested" @@ -32,8 +33,9 @@ const ( // 1. Terminal / hard session states (done, terminated, needs_input, stuck, // detecting, not_started) map directly — these OUTRANK PR facts. // 2. Otherwise a merged PR wins. -// 3. Otherwise an open PR maps by its reason. -// 4. Otherwise fall through to the SOFT session state (idle/working). +// 3. Otherwise a draft PR maps to draft, except CI failure still dominates. +// 4. Otherwise an open PR maps by its reason. +// 5. Otherwise fall through to the SOFT session state (idle/working). // // So "PR facts dominate session facts" applies only to the soft states: an idle // or working session with an open, CI-failing PR displays as ci_failed — but a @@ -59,6 +61,10 @@ func DeriveLegacyStatus(l CanonicalSessionLifecycle) SessionStatus { return StatusMerged } + if l.PR.State == PRDraft { + return draftPRStatus(l.PR.Reason) + } + if l.PR.State == PROpen { return openPRStatus(l.PR.Reason) } @@ -82,6 +88,13 @@ func terminatedStatus(r SessionReason) SessionStatus { } } +func draftPRStatus(r PRReason) SessionStatus { + if r == PRReasonCIFailing { + return StatusCIFailed + } + return StatusDraft +} + func openPRStatus(r PRReason) SessionStatus { switch r { case PRReasonCIFailing: diff --git a/backend/internal/domain/status_test.go b/backend/internal/domain/status_test.go index 12b0ade059..dc2c96e77d 100644 --- a/backend/internal/domain/status_test.go +++ b/backend/internal/domain/status_test.go @@ -49,6 +49,22 @@ func TestDeriveLegacyStatus(t *testing.T) { }, want: StatusCIFailed, }, + { + name: "draft PR with failing CI maps to ci_failed", + in: CanonicalSessionLifecycle{ + Session: SessionSubstate{State: SessionWorking}, + PR: PRSubstate{State: PRDraft, Reason: PRReasonCIFailing}, + }, + want: StatusCIFailed, + }, + { + name: "draft PR ignores review and merge reasons", + in: CanonicalSessionLifecycle{ + Session: SessionSubstate{State: SessionWorking}, + PR: PRSubstate{State: PRDraft, Reason: PRReasonMergeReady}, + }, + want: StatusDraft, + }, { name: "open PR approved", in: CanonicalSessionLifecycle{ diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 2581fea0ed..86c36b84c7 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -196,9 +196,9 @@ func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.Session } // ApplySCMObservation maps PR facts onto the PR axis. A failed fetch is dropped -// (failed probe != "no PR"). An open PR writes only the PR sub-state — the -// session axis stays owned by activity, and DeriveLegacyStatus surfaces the PR -// reason for display. A terminal PR (merged/closed) also parks the session. +// (failed probe != "no PR"). An open or draft PR writes only the PR sub-state — +// the session axis stays owned by activity, and DeriveLegacyStatus surfaces the +// PR reason for display. A terminal PR (merged/closed) also parks the session. func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, f ports.SCMFacts) error { tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { if !exists || !f.Fetched { @@ -206,6 +206,14 @@ func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, } switch f.PRState { + case domain.PRDraft: + in := openPRInput(f) + in.Draft = true + d := decide.ResolveOpenPRDecision(in) + var patch ports.LifecyclePatch + changed := setPRIfChanged(&patch, cur, d, f) + return patch, changed, nil + case domain.PROpen: d := decide.ResolveOpenPRDecision(openPRInput(f)) var patch ports.LifecyclePatch diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index d0a97125ae..d98afa9422 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -263,6 +263,34 @@ func TestApplySCMObservation(t *testing.T) { } }) + t.Run("draft PR writes draft or ci_failed without review states", func(t *testing.T) { + cases := []struct { + name string + facts ports.SCMFacts + wantReason domain.PRReason + wantStatus domain.SessionStatus + }{ + {"draft with failing CI", ports.SCMFacts{Fetched: true, PRState: domain.PRDraft, CISummary: ports.CIFailing}, domain.PRReasonCIFailing, domain.StatusCIFailed}, + {"draft ignores review and merge facts", ports.SCMFacts{Fetched: true, PRState: domain.PRDraft, ReviewDecision: ports.ReviewApproved, Mergeability: ports.Mergeability{Mergeable: true}}, domain.PRReasonInProgress, domain.StatusDraft}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + mgr, store := newManager() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + if err := mgr.ApplySCMObservation(context.Background(), sid, c.facts); err != nil { + t.Fatalf("apply: %v", err) + } + l := mustLoad(t, store) + if l.PR.State != domain.PRDraft || l.PR.Reason != c.wantReason { + t.Errorf("pr = %v/%v, want draft/%v", l.PR.State, l.PR.Reason, c.wantReason) + } + if got := domain.DeriveLegacyStatus(l); got != c.wantStatus { + t.Errorf("display = %v, want %v", got, c.wantStatus) + } + }) + } + }) + t.Run("merged PR parks the session and displays merged", func(t *testing.T) { mgr, store := newManager() seed := lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive) From 09a82d92062d764953a3c5a799172c1aa59a1b25 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 20:30:14 +0530 Subject: [PATCH 029/250] fix: keep draft PR reactions active --- backend/internal/lifecycle/manager_test.go | 9 +++++-- backend/internal/lifecycle/reactions.go | 13 +++++++--- backend/internal/lifecycle/reactions_test.go | 26 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index d98afa9422..471e845ba9 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -276,7 +276,8 @@ func TestApplySCMObservation(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { mgr, store := newManager() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + wantSession := domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress} + store.seed(sid, lc(wantSession.State, wantSession.Reason, domain.RuntimeAlive)) if err := mgr.ApplySCMObservation(context.Background(), sid, c.facts); err != nil { t.Fatalf("apply: %v", err) } @@ -284,6 +285,9 @@ func TestApplySCMObservation(t *testing.T) { if l.PR.State != domain.PRDraft || l.PR.Reason != c.wantReason { t.Errorf("pr = %v/%v, want draft/%v", l.PR.State, l.PR.Reason, c.wantReason) } + if l.Session != wantSession { + t.Errorf("session = %+v, want untouched %+v", l.Session, wantSession) + } if got := domain.DeriveLegacyStatus(l); got != c.wantStatus { t.Errorf("display = %v, want %v", got, c.wantStatus) } @@ -323,7 +327,8 @@ func TestApplySCMObservation(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { mgr, store := newManager() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + wantSession := domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress} + store.seed(sid, lc(wantSession.State, wantSession.Reason, domain.RuntimeAlive)) if err := mgr.ApplySCMObservation(context.Background(), sid, c.facts); err != nil { t.Fatalf("apply: %v", err) } diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index 7284151041..fc0921136c 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -237,18 +237,23 @@ func (m *Manager) react(ctx context.Context, id domain.SessionID, tr *transition } // incidentOver reports that a PR-pipeline incident has truly ended (PR no longer -// open, or the session terminal), so all trackers for the session may reset. +// active, or the session terminal), so all trackers for the session may reset. func incidentOver(l domain.CanonicalSessionLifecycle) bool { - return l.PR.State != domain.PROpen || isTerminal(l.Session.State) + return !isActivePRState(l.PR.State) || isTerminal(l.Session.State) +} + +func isActivePRState(s domain.PRState) bool { + return s == domain.PROpen || s == domain.PRDraft } // recovered reports a genuinely-green open PR: an approved/mergeable state, which // unambiguously means CI is no longer failing (the open-PR ladder ranks ci_failing // above approved, so an approved display cannot coexist with failing CI). Unlike // the ambiguous review_pending state — which may just be CI re-running — reaching -// this ends a ci-failed incident and re-arms its budget. +// this ends a ci-failed incident and re-arms its budget. Draft PRs are active, +// but not recoverable via review/merge state. func recovered(l domain.CanonicalSessionLifecycle) bool { - if l.PR.State != domain.PROpen { + if !isActivePRState(l.PR.State) || l.PR.State == domain.PRDraft { return false } switch l.PR.Reason { diff --git a/backend/internal/lifecycle/reactions_test.go b/backend/internal/lifecycle/reactions_test.go index e90e8881e8..d0635d4dc8 100644 --- a/backend/internal/lifecycle/reactions_test.go +++ b/backend/internal/lifecycle/reactions_test.go @@ -211,6 +211,32 @@ func TestReaction_CIFailedNumericEscalation(t *testing.T) { } } +func TestReaction_DraftPRDoesNotEndCIFailedIncident(t *testing.T) { + m, store, _, _ := newReactive() + seed := lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive) + seed.PR = domain.PRSubstate{State: domain.PRDraft, Reason: domain.PRReasonInProgress, Number: 7} + store.seed(sid, seed) + + tail := "fail" + if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PRDraft, CISummary: ports.CIFailing, PRNumber: 7, CIFailureLogTail: &tail, + }); err != nil { + t.Fatalf("draft fail: %v", err) + } + if sessionTrackerCount(m, sid) == 0 { + t.Fatalf("precondition: expected a ci-failed tracker") + } + + if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PRDraft, CISummary: ports.CIPending, PRNumber: 7, + }); err != nil { + t.Fatalf("draft pending: %v", err) + } + if n := sessionTrackerCount(m, sid); n == 0 { + t.Errorf("draft PR is still active; ci-failed tracker should survive, got %d", n) + } +} + func TestReaction_DurationEscalationFiresOnTick(t *testing.T) { m, store, notf, msgr := newReactive() store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) From f0766ebd8f6991c2522fa6050bf2abcd6ceb871a Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 22:19:07 +0530 Subject: [PATCH 030/250] fix: handle SCM observer seam facts --- backend/internal/domain/decide/decide.go | 4 ++ backend/internal/domain/decide/decide_test.go | 16 ++++++ backend/internal/domain/decide/types.go | 2 + backend/internal/domain/lifecycle.go | 2 + backend/internal/domain/status.go | 2 +- backend/internal/domain/status_test.go | 16 ++++++ backend/internal/lifecycle/decide_bridge.go | 21 ++++++- backend/internal/lifecycle/manager.go | 10 +--- backend/internal/lifecycle/manager_test.go | 5 ++ backend/internal/lifecycle/reactions.go | 14 ++++- backend/internal/lifecycle/reactions_test.go | 57 +++++++++++++++++++ backend/internal/ports/facts.go | 1 + 12 files changed, 136 insertions(+), 14 deletions(-) diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go index 583b80d045..c46df18d5f 100644 --- a/backend/internal/domain/decide/decide.go +++ b/backend/internal/domain/decide/decide.go @@ -127,6 +127,10 @@ func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { return base(domain.StatusDraft, "draft", domain.PRReasonInProgress, domain.SessionWorking, domain.ReasonPRCreated) case in.ChangesRequested: return base(domain.StatusChangesRequested, "changes_requested", domain.PRReasonChangesRequested, domain.SessionWorking, domain.ReasonResolvingReviewComments) + case in.BotComments: + return base(domain.StatusChangesRequested, "bot_comments", domain.PRReasonBotComments, domain.SessionWorking, domain.ReasonResolvingReviewComments) + case in.MergeConflicts: + return base(domain.StatusPROpen, "merge_conflicts", domain.PRReasonMergeConflicts, domain.SessionWorking, domain.ReasonPRCreated) case in.Mergeable: // Mergeability is the authoritative merge gate, so it already folds in // "approved if review is required". Checking it before Approved means a diff --git a/backend/internal/domain/decide/decide_test.go b/backend/internal/domain/decide/decide_test.go index 9af6e596ca..1a81595926 100644 --- a/backend/internal/domain/decide/decide_test.go +++ b/backend/internal/domain/decide/decide_test.go @@ -192,6 +192,20 @@ func TestResolveOpenPRDecision(t *testing.T) { wantPR: domain.PRReasonChangesRequested, wantState: domain.SessionWorking, }, + { + name: "bot comments get distinct PR reason", + in: OpenPRInput{BotComments: true, Approved: true, Mergeable: true}, + wantStatus: domain.StatusChangesRequested, + wantPR: domain.PRReasonBotComments, + wantState: domain.SessionWorking, + }, + { + name: "merge conflicts get distinct PR reason", + in: OpenPRInput{MergeConflicts: true, Approved: true}, + wantStatus: domain.StatusPROpen, + wantPR: domain.PRReasonMergeConflicts, + wantState: domain.SessionWorking, + }, { name: "approved + mergeable -> mergeable", in: OpenPRInput{Approved: true, Mergeable: true}, @@ -312,6 +326,8 @@ func TestDecidersDeriveConsistently(t *testing.T) { {Draft: true, ChangesRequested: true, Approved: true, Mergeable: true, ReviewPending: true, IdleBeyond: true}, {CIFailing: true}, {ChangesRequested: true}, + {BotComments: true}, + {MergeConflicts: true}, {Approved: true, Mergeable: true}, {Mergeable: true}, {Approved: true}, diff --git a/backend/internal/domain/decide/types.go b/backend/internal/domain/decide/types.go index e4fa92cab7..0a10691ff8 100644 --- a/backend/internal/domain/decide/types.go +++ b/backend/internal/domain/decide/types.go @@ -57,6 +57,8 @@ type OpenPRInput struct { Draft bool CIFailing bool ChangesRequested bool + BotComments bool + MergeConflicts bool Approved bool Mergeable bool ReviewPending bool diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go index 2df4bb13a4..bdb26f2949 100644 --- a/backend/internal/domain/lifecycle.go +++ b/backend/internal/domain/lifecycle.go @@ -104,6 +104,8 @@ const ( PRReasonCIFailing PRReason = "ci_failing" PRReasonReviewPending PRReason = "review_pending" PRReasonChangesRequested PRReason = "changes_requested" + PRReasonBotComments PRReason = "bot_comments" + PRReasonMergeConflicts PRReason = "merge_conflicts" PRReasonApproved PRReason = "approved" PRReasonMergeReady PRReason = "merge_ready" PRReasonMerged PRReason = "merged" diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index aff5ba1f13..1cc4404d39 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -99,7 +99,7 @@ func openPRStatus(r PRReason) SessionStatus { switch r { case PRReasonCIFailing: return StatusCIFailed - case PRReasonChangesRequested: + case PRReasonChangesRequested, PRReasonBotComments: return StatusChangesRequested case PRReasonApproved: return StatusApproved diff --git a/backend/internal/domain/status_test.go b/backend/internal/domain/status_test.go index dc2c96e77d..0985499847 100644 --- a/backend/internal/domain/status_test.go +++ b/backend/internal/domain/status_test.go @@ -65,6 +65,22 @@ func TestDeriveLegacyStatus(t *testing.T) { }, want: StatusDraft, }, + { + name: "open PR bot comments display as changes_requested", + in: CanonicalSessionLifecycle{ + Session: SessionSubstate{State: SessionWorking}, + PR: PRSubstate{State: PROpen, Reason: PRReasonBotComments}, + }, + want: StatusChangesRequested, + }, + { + name: "open PR merge conflicts display as plain open", + in: CanonicalSessionLifecycle{ + Session: SessionSubstate{State: SessionWorking}, + PR: PRSubstate{State: PROpen, Reason: PRReasonMergeConflicts}, + }, + want: StatusPROpen, + }, { name: "open PR approved", in: CanonicalSessionLifecycle{ diff --git a/backend/internal/lifecycle/decide_bridge.go b/backend/internal/lifecycle/decide_bridge.go index 942fdad419..501d12ac75 100644 --- a/backend/internal/lifecycle/decide_bridge.go +++ b/backend/internal/lifecycle/decide_bridge.go @@ -101,9 +101,13 @@ func hasRecentActivity(a domain.ActivitySubstate, now time.Time, window time.Dur // in split A — the idle-duration signal is owned by the escalation engine // (split B); the synchronous LCM has no clock of its own here. func openPRInput(f ports.SCMFacts) decide.OpenPRInput { + hasBotComments, hasHumanComments := classifyPendingComments(f.PendingComments) return decide.OpenPRInput{ + Draft: f.PRState == domain.PRDraft || f.Draft, CIFailing: f.CISummary == ports.CIFailing, - ChangesRequested: f.ReviewDecision == ports.ReviewChangesRequested, + ChangesRequested: f.ReviewDecision == ports.ReviewChangesRequested || hasHumanComments, + BotComments: hasBotComments, + MergeConflicts: hasMergeConflicts(f.Mergeability), Approved: f.ReviewDecision == ports.ReviewApproved, Mergeable: f.Mergeability.Mergeable, ReviewPending: f.ReviewDecision == ports.ReviewPending, @@ -112,6 +116,21 @@ func openPRInput(f ports.SCMFacts) decide.OpenPRInput { } } +func classifyPendingComments(comments []ports.ReviewComment) (hasBot, hasHuman bool) { + for _, c := range comments { + if c.IsBot { + hasBot = true + } else { + hasHuman = true + } + } + return hasBot, hasHuman +} + +func hasMergeConflicts(m ports.Mergeability) bool { + return !m.Mergeable && !m.NoConflicts && (m.CIPassing || m.Approved || len(m.Blockers) > 0) +} + // ---- activity -> session axis mapping (activity owns working/idle/waiting) ---- // activityToSession maps an activity classification onto the session sub-state. diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 86c36b84c7..f7b99ea6d5 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -206,15 +206,7 @@ func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, } switch f.PRState { - case domain.PRDraft: - in := openPRInput(f) - in.Draft = true - d := decide.ResolveOpenPRDecision(in) - var patch ports.LifecyclePatch - changed := setPRIfChanged(&patch, cur, d, f) - return patch, changed, nil - - case domain.PROpen: + case domain.PRDraft, domain.PROpen: d := decide.ResolveOpenPRDecision(openPRInput(f)) var patch ports.LifecyclePatch changed := setPRIfChanged(&patch, cur, d, f) diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index 471e845ba9..a1d5b6354c 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -271,6 +271,8 @@ func TestApplySCMObservation(t *testing.T) { wantStatus domain.SessionStatus }{ {"draft with failing CI", ports.SCMFacts{Fetched: true, PRState: domain.PRDraft, CISummary: ports.CIFailing}, domain.PRReasonCIFailing, domain.StatusCIFailed}, + {"draft via bool with open state", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, Draft: true}, domain.PRReasonInProgress, domain.StatusDraft}, + {"draft via bool with failing CI", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, Draft: true, CISummary: ports.CIFailing}, domain.PRReasonCIFailing, domain.StatusCIFailed}, {"draft ignores review and merge facts", ports.SCMFacts{Fetched: true, PRState: domain.PRDraft, ReviewDecision: ports.ReviewApproved, Mergeability: ports.Mergeability{Mergeable: true}}, domain.PRReasonInProgress, domain.StatusDraft}, } for _, c := range cases { @@ -321,6 +323,9 @@ func TestApplySCMObservation(t *testing.T) { wantStatus domain.SessionStatus }{ {"changes requested", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewChangesRequested}, domain.PRReasonChangesRequested, domain.StatusChangesRequested}, + {"pending human comments", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, PendingComments: []ports.ReviewComment{{Author: "human", Body: "fix"}}}, domain.PRReasonChangesRequested, domain.StatusChangesRequested}, + {"pending bot comments", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, PendingComments: []ports.ReviewComment{{Author: "bot", Body: "fix", IsBot: true}}}, domain.PRReasonBotComments, domain.StatusChangesRequested}, + {"merge conflicts", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, Mergeability: ports.Mergeability{CIPassing: true, Approved: true, NoConflicts: false, Blockers: []string{"merge conflicts"}}}, domain.PRReasonMergeConflicts, domain.StatusPROpen}, {"approved + mergeable", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewApproved, Mergeability: ports.Mergeability{Mergeable: true}}, domain.PRReasonMergeReady, domain.StatusMergeable}, {"review pending", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewPending}, domain.PRReasonReviewPending, domain.StatusReviewPending}, } diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index fc0921136c..761ac4a4a0 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -135,13 +135,21 @@ var defaultReactions = map[reactionKey]reactionConfig{ // current state has no reaction. // // A closed PR derives to the idle display status, so it is detected from the PR -// axis directly before falling through to the status mapping. bugbot-comments -// and merge-conflicts have no producer in the split-A decide core yet, so they -// are dormant: configured but unreachable until DECIDE surfaces them. +// axis directly before falling through to the status mapping. Bot review +// comments and merge conflicts are represented as PR reasons so the ACT layer +// can distinguish them from human-requested changes and plain open PRs. func reactionEventFor(l domain.CanonicalSessionLifecycle) (reactionKey, bool) { if l.PR.State == domain.PRClosed { return reactionPRClosed, true } + if isActivePRState(l.PR.State) { + switch l.PR.Reason { + case domain.PRReasonBotComments: + return reactionBugbotComments, true + case domain.PRReasonMergeConflicts: + return reactionMergeConflicts, true + } + } switch domain.DeriveLegacyStatus(l) { case domain.StatusCIFailed: return reactionCIFailed, true diff --git a/backend/internal/lifecycle/reactions_test.go b/backend/internal/lifecycle/reactions_test.go index d0635d4dc8..942bc339bc 100644 --- a/backend/internal/lifecycle/reactions_test.go +++ b/backend/internal/lifecycle/reactions_test.go @@ -78,6 +78,63 @@ func TestReaction_CIFailedSendsToAgentWithLogTail(t *testing.T) { } } +func TestReaction_BotAndHumanCommentsRouteSeparately(t *testing.T) { + tests := []struct { + name string + comments []ports.ReviewComment + wantMessage string + }{ + { + name: "bot comments -> bugbot-comments", + comments: []ports.ReviewComment{{Author: "bugbot", Body: "fix", IsBot: true}}, + wantMessage: "automated reviewer", + }, + { + name: "human comments -> changes-requested", + comments: []ports.ReviewComment{{Author: "reviewer", Body: "fix"}}, + wantMessage: "reviewer requested changes", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, store, _, msgr := newReactive() + store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) + + if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, PendingComments: tt.comments, PRNumber: 7, + }); err != nil { + t.Fatalf("apply: %v", err) + } + + if len(msgr.sent) != 1 { + t.Fatalf("want one send, got %d", len(msgr.sent)) + } + if !strings.Contains(msgr.sent[0].Message, tt.wantMessage) { + t.Errorf("message %q does not contain %q", msgr.sent[0].Message, tt.wantMessage) + } + }) + } +} + +func TestReaction_MergeConflictsSendsToAgent(t *testing.T) { + m, store, _, msgr := newReactive() + store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) + + if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, PRNumber: 7, + Mergeability: ports.Mergeability{CIPassing: true, Approved: true, NoConflicts: false, Blockers: []string{"merge conflicts"}}, + }); err != nil { + t.Fatalf("apply: %v", err) + } + + if len(msgr.sent) != 1 { + t.Fatalf("want one send, got %d", len(msgr.sent)) + } + if !strings.Contains(msgr.sent[0].Message, "merge conflicts") { + t.Errorf("message = %q, want merge conflict nudge", msgr.sent[0].Message) + } +} + func TestReaction_ApprovedAndGreenNotifiesNeverAutoMerges(t *testing.T) { m, store, notf, msgr := newReactive() store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) diff --git a/backend/internal/ports/facts.go b/backend/internal/ports/facts.go index 55f4f6ca6a..f1b0c702e7 100644 --- a/backend/internal/ports/facts.go +++ b/backend/internal/ports/facts.go @@ -24,6 +24,7 @@ type SCMFacts struct { Fetched bool ObservedAt time.Time PRState domain.PRState + Draft bool PRNumber int PRURL string CISummary CISummary From 586931b3ffc5febf248d7a6085254c0d05a2f1d1 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 23:05:08 +0530 Subject: [PATCH 031/250] feat: add zellij runtime adapter --- .../adapters/runtime/zellij/commands.go | 123 +++++ .../adapters/runtime/zellij/zellij.go | 511 ++++++++++++++++++ .../runtime/zellij/zellij_integration_test.go | 113 ++++ .../adapters/runtime/zellij/zellij_test.go | 311 +++++++++++ 4 files changed, 1058 insertions(+) create mode 100644 backend/internal/adapters/runtime/zellij/commands.go create mode 100644 backend/internal/adapters/runtime/zellij/zellij.go create mode 100644 backend/internal/adapters/runtime/zellij/zellij_integration_test.go create mode 100644 backend/internal/adapters/runtime/zellij/zellij_test.go diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go new file mode 100644 index 0000000000..2a866a0d70 --- /dev/null +++ b/backend/internal/adapters/runtime/zellij/commands.go @@ -0,0 +1,123 @@ +package zellij + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + runtimeName = "zellij" + agentPaneName = "agent" + defaultChunkBytes = 16 * 1024 +) + +func versionArgs() []string { + return []string{"--version"} +} + +func createSessionArgs(id, layoutPath string) []string { + return []string{ + "attach", "--create-background", id, + "options", + "--default-layout", layoutPath, + "--pane-frames", "false", + "--session-serialization", "false", + "--show-startup-tips", "false", + "--show-release-notes", "false", + } +} + +func listPanesArgs(id string) []string { + return []string{"--session", id, "action", "list-panes", "--all", "--json"} +} + +func pasteArgs(id, paneID, chunk string) []string { + return []string{"--session", id, "action", "paste", "--pane-id", paneID, chunk} +} + +func sendEnterArgs(id, paneID string) []string { + return []string{"--session", id, "action", "send-keys", "--pane-id", paneID, "Enter"} +} + +func dumpScreenArgs(id, paneID string) []string { + return []string{"--session", id, "action", "dump-screen", "--pane-id", paneID, "--full"} +} + +func listSessionsArgs() []string { + return []string{"list-sessions", "--no-formatting"} +} + +func killSessionArgs(id string) []string { + return []string{"kill-session", id} +} + +func attachArgs(id string) []string { + return []string{"attach", id} +} + +func handleIDValue(sessionID, paneID string) string { + return sessionID + "/" + paneID +} + +func terminalPaneID(id int) string { + return fmt.Sprintf("terminal_%d", id) +} + +func buildLayout(cfg ports.RuntimeConfig, shellPath string) string { + return "layout {\n" + + " cwd " + kdlQuote(cfg.WorkspacePath) + "\n" + + " pane command=" + kdlQuote(shellPath) + " name=" + kdlQuote(agentPaneName) + " {\n" + + " args " + kdlQuote("-lc") + " " + kdlQuote(wrapLaunchCommand(cfg, shellPath)) + "\n" + + " }\n" + + "}\n" +} + +func wrapLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { + path := cfg.Env["PATH"] + if path == "" { + path = getenv("PATH") + } + + var b strings.Builder + for _, key := range sortedKeys(cfg.Env) { + if key == "PATH" { + continue + } + b.WriteString("export ") + b.WriteString(key) + b.WriteString("=") + b.WriteString(shellQuote(cfg.Env[key])) + b.WriteString("; ") + } + if path != "" { + b.WriteString("export PATH=") + b.WriteString(shellQuote(path)) + b.WriteString("; ") + } + b.WriteString(cfg.LaunchCommand) + b.WriteString("; exec ") + b.WriteString(shellQuote(shellPath)) + b.WriteString(" -i") + return b.String() +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + +func kdlQuote(s string) string { + return strconv.Quote(s) +} diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go new file mode 100644 index 0000000000..1d110748eb --- /dev/null +++ b/backend/internal/adapters/runtime/zellij/zellij.go @@ -0,0 +1,511 @@ +// Package zellij implements ports.Runtime using Zellij sessions. +package zellij + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + defaultTimeout = 5 * time.Second + minMajor = 0 + minMinor = 44 + minPatch = 3 +) + +var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +var paneIDPattern = regexp.MustCompile(`^terminal_[0-9]+$`) + +var getenv = os.Getenv + +type Options struct { + Binary string + Timeout time.Duration + Shell string + SocketDir string + ConfigDir string + ChunkSize int +} + +type Runtime struct { + binary string + timeout time.Duration + shell string + socketDir string + configDir string + chunkSize int + runner runner +} + +var _ ports.Runtime = (*Runtime)(nil) + +type runner interface { + Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) +} + +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + if len(env) > 0 { + cmd.Env = append(os.Environ(), env...) + } + return cmd.CombinedOutput() +} + +func New(opts Options) *Runtime { + binary := opts.Binary + if binary == "" { + binary = "zellij" + } + timeout := opts.Timeout + if timeout == 0 { + timeout = defaultTimeout + } + shellPath := opts.Shell + if shellPath == "" { + shellPath = os.Getenv("SHELL") + } + if shellPath == "" { + shellPath = "/bin/sh" + } + chunkSize := opts.ChunkSize + if chunkSize <= 0 { + chunkSize = defaultChunkBytes + } + return &Runtime{binary: binary, timeout: timeout, shell: shellPath, socketDir: opts.SocketDir, configDir: opts.ConfigDir, chunkSize: chunkSize, runner: execRunner{}} +} + +func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { + id, err := zellijSessionName(cfg.SessionID) + if err != nil { + return ports.RuntimeHandle{}, err + } + if cfg.WorkspacePath == "" { + return ports.RuntimeHandle{}, errors.New("zellij runtime: workspace path is required") + } + if cfg.LaunchCommand == "" { + return ports.RuntimeHandle{}, errors.New("zellij runtime: launch command is required") + } + if err := r.ensureSupportedVersion(ctx); err != nil { + return ports.RuntimeHandle{}, err + } + + layoutPath, err := r.writeLayout(cfg) + if err != nil { + return ports.RuntimeHandle{}, err + } + defer os.Remove(layoutPath) + + if _, err := r.run(ctx, createSessionArgs(id, layoutPath)...); err != nil { + return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: create session %s: %w", id, err) + } + paneID, err := r.findAgentPane(ctx, id) + if err != nil { + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}) + return ports.RuntimeHandle{}, err + } + return ports.RuntimeHandle{ID: handleIDValue(id, paneID), RuntimeName: runtimeName}, nil +} + +func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error { + id, _, err := handleID(handle) + if err != nil { + return err + } + if _, err := r.run(ctx, killSessionArgs(id)...); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil + } + return fmt.Errorf("zellij runtime: destroy session %s: %w", id, err) + } + return nil +} + +func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { + id, paneID, err := handleID(handle) + if err != nil { + return err + } + for _, chunk := range chunks(message, r.chunkSize) { + if _, err := r.run(ctx, pasteArgs(id, paneID, chunk)...); err != nil { + return fmt.Errorf("zellij runtime: paste message %s/%s: %w", id, paneID, err) + } + } + if _, err := r.run(ctx, sendEnterArgs(id, paneID)...); err != nil { + return fmt.Errorf("zellij runtime: send enter %s/%s: %w", id, paneID, err) + } + return nil +} + +func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { + id, paneID, err := handleID(handle) + if err != nil { + return "", err + } + if lines <= 0 { + return "", errors.New("zellij runtime: lines must be positive") + } + out, err := r.run(ctx, dumpScreenArgs(id, paneID)...) + if err != nil { + return "", fmt.Errorf("zellij runtime: capture output %s/%s: %w", id, paneID, err) + } + return tailLines(string(out), lines), nil +} + +func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { + id, _, err := handleID(handle) + if err != nil { + return false, err + } + out, err := r.run(ctx, listSessionsArgs()...) + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return false, nil + } + return false, fmt.Errorf("zellij runtime: probe session %s: %w", id, err) + } + return sessionListedAlive(string(out), id), nil +} + +func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { + id, _, err := handleID(handle) + if err != nil { + return nil, err + } + args := append([]string{r.binary}, r.baseArgs()...) + args = append(args, attachArgs(id)...) + return args, nil +} + +func (r *Runtime) ensureSupportedVersion(ctx context.Context) error { + out, err := r.run(ctx, versionArgs()...) + if err != nil { + return fmt.Errorf("zellij runtime: check version: %w", err) + } + version, err := parseVersion(string(out)) + if err != nil { + return fmt.Errorf("zellij runtime: check version: %w", err) + } + if compareVersion(version, semver{minMajor, minMinor, minPatch}) < 0 { + return fmt.Errorf("zellij runtime: unsupported zellij version %s; require >= %d.%d.%d", version, minMajor, minMinor, minPatch) + } + return nil +} + +func (r *Runtime) writeLayout(cfg ports.RuntimeConfig) (string, error) { + file, err := os.CreateTemp(os.TempDir(), "ao-zellij-layout-*.kdl") + if err != nil { + return "", fmt.Errorf("zellij runtime: create layout temp file: %w", err) + } + path := file.Name() + if _, err := file.WriteString(buildLayout(cfg, r.shell)); err != nil { + _ = file.Close() + _ = os.Remove(path) + return "", fmt.Errorf("zellij runtime: write layout temp file: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return "", fmt.Errorf("zellij runtime: close layout temp file: %w", err) + } + return path, nil +} + +func (r *Runtime) findAgentPane(ctx context.Context, id string) (string, error) { + deadline := time.Now().Add(r.timeout) + var lastErr error + for { + out, err := r.run(ctx, listPanesArgs(id)...) + if err != nil { + return "", fmt.Errorf("zellij runtime: list panes %s: %w", id, err) + } + paneID, err := agentPaneID(out) + if err == nil { + return paneID, nil + } + lastErr = err + if time.Now().After(deadline) { + return "", fmt.Errorf("zellij runtime: list panes %s: %w", id, lastErr) + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} + +func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { + cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + fullArgs := append(r.baseArgs(), args...) + out, err := r.runner.Run(cmdCtx, r.env(), r.binary, fullArgs...) + if cmdCtx.Err() != nil { + return out, cmdCtx.Err() + } + if err != nil { + return out, commandError{err: err, output: strings.TrimSpace(string(out))} + } + return out, nil +} + +func (r *Runtime) baseArgs() []string { + args := []string{} + if r.configDir != "" { + args = append(args, "--config-dir", r.configDir) + } + return args +} + +func (r *Runtime) env() []string { + if r.socketDir == "" { + return nil + } + return []string{"ZELLIJ_SOCKET_DIR=" + r.socketDir} +} + +func zellijSessionName(id domain.SessionID) (string, error) { + raw := string(id) + if raw == "" { + return "", errors.New("zellij runtime: session id is required") + } + if sessionIDPattern.MatchString(raw) && len(raw) <= 48 { + return raw, nil + } + return sanitizedSessionName(raw), nil +} + +func sanitizedSessionName(raw string) string { + var b strings.Builder + lastDash := false + for _, r := range raw { + valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' + if valid { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + base := strings.Trim(b.String(), "-") + if base == "" { + base = "session" + } + if len(base) > 32 { + base = strings.TrimRight(base[:32], "-") + } + sum := sha256.Sum256([]byte(raw)) + return base + "-" + hex.EncodeToString(sum[:4]) +} + +func validateSessionID(id string) error { + if id == "" { + return errors.New("zellij runtime: session id is required") + } + if !sessionIDPattern.MatchString(id) { + return fmt.Errorf("zellij runtime: invalid session id %q", id) + } + return nil +} + +func validatePaneID(id string) error { + if id == "" { + return errors.New("zellij runtime: pane id is required") + } + if !paneIDPattern.MatchString(id) { + return fmt.Errorf("zellij runtime: invalid pane id %q", id) + } + return nil +} + +func handleID(handle ports.RuntimeHandle) (string, string, error) { + if handle.RuntimeName != "" && handle.RuntimeName != runtimeName { + return "", "", fmt.Errorf("zellij runtime: wrong runtime %q", handle.RuntimeName) + } + parts := strings.Split(handle.ID, "/") + if len(parts) == 1 { + if err := validateSessionID(parts[0]); err != nil { + return "", "", err + } + return parts[0], terminalPaneID(0), nil + } + if len(parts) != 2 { + return "", "", fmt.Errorf("zellij runtime: invalid handle id %q", handle.ID) + } + if err := validateSessionID(parts[0]); err != nil { + return "", "", err + } + if err := validatePaneID(parts[1]); err != nil { + return "", "", err + } + return parts[0], parts[1], nil +} + +type paneInfo struct { + ID int `json:"id"` + IsPlugin bool `json:"is_plugin"` + Title string `json:"title"` +} + +func agentPaneID(out []byte) (string, error) { + var panes []paneInfo + if err := json.Unmarshal(out, &panes); err != nil { + return "", fmt.Errorf("parse panes: %w", err) + } + for _, pane := range panes { + if !pane.IsPlugin && pane.Title == agentPaneName { + return terminalPaneID(pane.ID), nil + } + } + for _, pane := range panes { + if !pane.IsPlugin { + return terminalPaneID(pane.ID), nil + } + } + return "", errors.New("agent pane not found") +} + +func chunks(s string, maxBytes int) []string { + if s == "" { + return []string{""} + } + if maxBytes <= 0 || len(s) <= maxBytes { + return []string{s} + } + parts := []string{} + for len(s) > 0 { + if len(s) <= maxBytes { + parts = append(parts, s) + break + } + end := maxBytes + for end > 0 && !utf8.ValidString(s[:end]) { + end-- + } + if end == 0 { + _, size := utf8.DecodeRuneInString(s) + end = size + } + parts = append(parts, s[:end]) + s = s[end:] + } + return parts +} + +func tailLines(s string, n int) string { + if n <= 0 || s == "" { + return "" + } + lines := strings.SplitAfter(s, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if len(lines) <= n { + return s + } + return strings.Join(lines[len(lines)-n:], "") +} + +type semver struct { + major int + minor int + patch int +} + +func (v semver) String() string { + return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch) +} + +func parseVersion(out string) (semver, error) { + fields := strings.Fields(strings.TrimSpace(out)) + if len(fields) == 0 { + return semver{}, errors.New("empty version output") + } + raw := strings.TrimPrefix(fields[len(fields)-1], "v") + parts := strings.Split(raw, ".") + if len(parts) < 3 { + return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out)) + } + major, err := parseVersionPart(parts[0]) + if err != nil { + return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out)) + } + minor, err := parseVersionPart(parts[1]) + if err != nil { + return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out)) + } + patch, err := parseVersionPart(parts[2]) + if err != nil { + return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out)) + } + return semver{major: major, minor: minor, patch: patch}, nil +} + +func parseVersionPart(s string) (int, error) { + end := 0 + for end < len(s) && s[end] >= '0' && s[end] <= '9' { + end++ + } + if end == 0 { + return 0, errors.New("missing version number") + } + return strconv.Atoi(s[:end]) +} + +func compareVersion(a, b semver) int { + if a.major != b.major { + return a.major - b.major + } + if a.minor != b.minor { + return a.minor - b.minor + } + return a.patch - b.patch +} + +func sessionListedAlive(out, id string) bool { + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 || fields[0] != id { + continue + } + return !strings.Contains(line, "(EXITED") + } + return false +} + +type commandError struct { + err error + output string +} + +func (e commandError) Error() string { + if e.output == "" { + return e.err.Error() + } + return e.err.Error() + ": " + e.output +} + +func (e commandError) Unwrap() error { return e.err } diff --git a/backend/internal/adapters/runtime/zellij/zellij_integration_test.go b/backend/internal/adapters/runtime/zellij/zellij_integration_test.go new file mode 100644 index 0000000000..6729cc3b52 --- /dev/null +++ b/backend/internal/adapters/runtime/zellij/zellij_integration_test.go @@ -0,0 +1,113 @@ +package zellij + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestRuntimeIntegration(t *testing.T) { + if _, err := exec.LookPath("zellij"); err != nil { + t.Skip("zellij unavailable") + } + + ctx := context.Background() + id := "ao_itest_zj" + socketDir := filepath.Join(os.TempDir(), "ao-zj-itest") + if err := os.MkdirAll(socketDir, 0o755); err != nil { + t.Fatalf("mkdir socket dir: %v", err) + } + configDir := t.TempDir() + r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: configDir}) + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}) + + h, err := r.Create(ctx, ports.RuntimeConfig{ + SessionID: "ao_itest_zj", + WorkspacePath: t.TempDir(), + LaunchCommand: "printf ready-$AO_SESSION_ID\\n", + Env: map[string]string{"AO_SESSION_ID": id}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer r.Destroy(ctx, h) + + alive, err := r.IsAlive(ctx, h) + if err != nil { + t.Fatalf("IsAlive: %v", err) + } + if !alive { + t.Fatal("alive = false, want true") + } + + if err := r.SendMessage(ctx, h, "printf hello-from-zellij"); err != nil { + t.Fatalf("SendMessage: %v", err) + } + deadline := time.Now().Add(3 * time.Second) + var out string + for time.Now().Before(deadline) { + out, err = r.GetOutput(ctx, h, 30) + if err != nil { + t.Fatalf("GetOutput: %v", err) + } + if strings.Contains(out, "hello-from-zellij") { + break + } + time.Sleep(100 * time.Millisecond) + } + if !strings.Contains(out, "hello-from-zellij") { + t.Fatalf("output = %q, want sent command output", out) + } + + if err := r.Destroy(ctx, h); err != nil { + t.Fatalf("Destroy: %v", err) + } + alive, err = r.IsAlive(ctx, h) + if err != nil { + t.Fatalf("IsAlive after destroy: %v", err) + } + if alive { + t.Fatal("alive after destroy = true, want false") + } +} + +func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) { + if _, err := exec.LookPath("zellij"); err != nil { + t.Skip("zellij unavailable") + } + + ctx := context.Background() + socketDir := filepath.Join(os.TempDir(), "ao-zj-exact-itest") + if err := os.MkdirAll(socketDir, 0o755); err != nil { + t.Fatalf("mkdir socket dir: %v", err) + } + r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: t.TempDir()}) + longID := "ao_zj_exact_long" + prefixID := "ao_zj_exact" + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID, RuntimeName: runtimeName}) + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}) + + h, err := r.Create(ctx, ports.RuntimeConfig{ + SessionID: "ao_zj_exact_long", + WorkspacePath: t.TempDir(), + LaunchCommand: "printf ready\\n", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer r.Destroy(ctx, h) + + alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}) + if err != nil { + t.Fatalf("IsAlive prefix: %v", err) + } + if alive { + t.Fatal("prefix handle reported alive; zellij session matching is not exact") + } +} diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go new file mode 100644 index 0000000000..72ab19e29a --- /dev/null +++ b/backend/internal/adapters/runtime/zellij/zellij_test.go @@ -0,0 +1,311 @@ +package zellij + +import ( + "context" + "errors" + "os/exec" + "reflect" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestNewDefaultsToPortableShell(t *testing.T) { + t.Setenv("SHELL", "") + r := New(Options{}) + if got, want := r.shell, "/bin/sh"; got != want { + t.Fatalf("default shell = %q, want %q", got, want) + } +} + +func TestCommandBuilders(t *testing.T) { + if got, want := versionArgs(), []string{"--version"}; !reflect.DeepEqual(got, want) { + t.Fatalf("versionArgs = %#v, want %#v", got, want) + } + if got, want := createSessionArgs("sess-1", "/tmp/layout.kdl"), []string{"attach", "--create-background", "sess-1", "options", "--default-layout", "/tmp/layout.kdl", "--pane-frames", "false", "--session-serialization", "false", "--show-startup-tips", "false", "--show-release-notes", "false"}; !reflect.DeepEqual(got, want) { + t.Fatalf("createSessionArgs = %#v, want %#v", got, want) + } + if got, want := listPanesArgs("sess-1"), []string{"--session", "sess-1", "action", "list-panes", "--all", "--json"}; !reflect.DeepEqual(got, want) { + t.Fatalf("listPanesArgs = %#v, want %#v", got, want) + } + if got, want := pasteArgs("sess-1", "terminal_0", "hello"), []string{"--session", "sess-1", "action", "paste", "--pane-id", "terminal_0", "hello"}; !reflect.DeepEqual(got, want) { + t.Fatalf("pasteArgs = %#v, want %#v", got, want) + } + if got, want := dumpScreenArgs("sess-1", "terminal_0"), []string{"--session", "sess-1", "action", "dump-screen", "--pane-id", "terminal_0", "--full"}; !reflect.DeepEqual(got, want) { + t.Fatalf("dumpScreenArgs = %#v, want %#v", got, want) + } +} + +func TestZellijSessionNameSanitizesIssueRefs(t *testing.T) { + got, err := zellijSessionName("repo/issue#42.1") + if err != nil { + t.Fatalf("zellijSessionName: %v", err) + } + if err := validateSessionID(got); err != nil { + t.Fatalf("sanitized id %q is invalid: %v", got, err) + } + if !strings.HasPrefix(got, "repo-issue-42-1-") { + t.Fatalf("sanitized id = %q, want readable prefix", got) + } + if got == "repo/issue#42.1" { + t.Fatal("sanitized id still contains raw unsafe characters") + } +} + +func TestValidateSessionAndPaneID(t *testing.T) { + for _, id := range []string{"sess-1", "S_2", "abc123"} { + if err := validateSessionID(id); err != nil { + t.Fatalf("validateSessionID(%q): %v", id, err) + } + } + for _, id := range []string{"", "sess.1", "sess/1", "$(boom)", "with space"} { + if err := validateSessionID(id); err == nil { + t.Fatalf("validateSessionID(%q): got nil, want error", id) + } + } + for _, id := range []string{"terminal_0", "terminal_42"} { + if err := validatePaneID(id); err != nil { + t.Fatalf("validatePaneID(%q): %v", id, err) + } + } + for _, id := range []string{"", "0", "plugin_0", "terminal_x", "terminal_1/2"} { + if err := validatePaneID(id); err == nil { + t.Fatalf("validatePaneID(%q): got nil, want error", id) + } + } +} + +func TestHandleID(t *testing.T) { + session, pane, err := handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7", RuntimeName: runtimeName}) + if err != nil { + t.Fatalf("handleID: %v", err) + } + if session != "sess-1" || pane != "terminal_7" { + t.Fatalf("handleID = %q/%q", session, pane) + } + _, _, err = handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7", RuntimeName: "tmux"}) + if err == nil { + t.Fatal("wrong runtime: got nil, want error") + } +} + +func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) { + oldGetenv := getenv + getenv = func(key string) string { + if key == "PATH" { + return "/usr/bin:/bin" + } + return "" + } + defer func() { getenv = oldGetenv }() + + got := buildLayout(ports.RuntimeConfig{WorkspacePath: "/tmp/ws", LaunchCommand: "ao run", Env: map[string]string{ + "AO_SESSION_ID": "sess-1", + "ODD": "can't", + "PATH": "/custom/bin:/usr/bin", + }}, "/bin/zsh") + + for _, want := range []string{ + `cwd "/tmp/ws"`, + `pane command="/bin/zsh" name="agent"`, + "export AO_SESSION_ID='sess-1';", + "export ODD='can'\\\\''t';", + "export PATH='/custom/bin:/usr/bin';", + "ao run; exec '/bin/zsh' -i", + } { + if !strings.Contains(got, want) { + t.Fatalf("layout missing %q in %q", want, got) + } + } +} + +func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { + fr := &fakeRunner{outputs: [][]byte{[]byte("zellij 0.44.3"), nil, []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`)}} + r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"}) + r.runner = fr + + handle, err := r.Create(context.Background(), ports.RuntimeConfig{ + SessionID: "sess-1", + WorkspacePath: "/tmp/ws", + LaunchCommand: "echo ready", + Env: map[string]string{"AO_SESSION_ID": "sess-1"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if handle != (ports.RuntimeHandle{ID: "sess-1/terminal_3", RuntimeName: runtimeName}) { + t.Fatalf("handle = %+v, want zellij handle", handle) + } + if len(fr.calls) != 3 { + t.Fatalf("calls = %d, want 3", len(fr.calls)) + } + if got, want := fr.calls[0].args, []string{"--config-dir", "/tmp/cfg", "--version"}; !reflect.DeepEqual(got, want) { + t.Fatalf("version args = %#v, want %#v", got, want) + } + if got := fr.calls[1].args[:5]; !reflect.DeepEqual(got, []string{"--config-dir", "/tmp/cfg", "attach", "--create-background", "sess-1"}) { + t.Fatalf("create args prefix = %#v", got) + } + if got := fr.calls[2].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) { + t.Fatalf("list panes args = %#v", got) + } + if got, want := fr.calls[0].env, []string{"ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) { + t.Fatalf("env = %#v, want %#v", got, want) + } +} + +func TestParseVersion(t *testing.T) { + for _, tc := range []struct { + in string + want semver + }{ + {in: "zellij 0.44.3", want: semver{0, 44, 3}}, + {in: "zellij v1.2.3\n", want: semver{1, 2, 3}}, + {in: "zellij 0.44.3-dev", want: semver{0, 44, 3}}, + } { + got, err := parseVersion(tc.in) + if err != nil { + t.Fatalf("parseVersion(%q): %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("parseVersion(%q) = %v, want %v", tc.in, got, tc.want) + } + } + if _, err := parseVersion("zellij nope"); err == nil { + t.Fatal("parseVersion invalid: got nil, want error") + } + if compareVersion(semver{0, 44, 2}, semver{0, 44, 3}) >= 0 { + t.Fatal("compareVersion should order 0.44.2 before 0.44.3") + } +} + +func TestSendMessageChunksAndSendsEnter(t *testing.T) { + fr := &fakeRunner{} + r := New(Options{Timeout: time.Second, ChunkSize: 5}) + r.runner = fr + + if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, "hello世界"); err != nil { + t.Fatalf("SendMessage: %v", err) + } + if len(fr.calls) != 4 { + t.Fatalf("calls = %d, want 4", len(fr.calls)) + } + if got, want := fr.calls[0].args, pasteArgs("sess-1", "terminal_0", "hello"); !reflect.DeepEqual(got, want) { + t.Fatalf("paste 1 args = %#v, want %#v", got, want) + } + if got, want := fr.calls[1].args, pasteArgs("sess-1", "terminal_0", "世"); !reflect.DeepEqual(got, want) { + t.Fatalf("paste 2 args = %#v, want %#v", got, want) + } + if got, want := fr.calls[2].args, pasteArgs("sess-1", "terminal_0", "界"); !reflect.DeepEqual(got, want) { + t.Fatalf("paste 3 args = %#v, want %#v", got, want) + } + if got, want := fr.calls[3].args, sendEnterArgs("sess-1", "terminal_0"); !reflect.DeepEqual(got, want) { + t.Fatalf("enter args = %#v, want %#v", got, want) + } +} + +func TestGetOutputTrimsLines(t *testing.T) { + fr := &fakeRunner{outputs: [][]byte{[]byte("one\ntwo\nthree\n")}} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, 2) + if err != nil { + t.Fatalf("GetOutput: %v", err) + } + if out != "two\nthree\n" { + t.Fatalf("output = %q, want last two lines", out) + } +} + +func TestIsAliveParsesNoFormattingOutput(t *testing.T) { + fr := &fakeRunner{outputs: [][]byte{[]byte("sess-1 [Created 1s ago] \nold [Created 2s ago] (EXITED - attach to resurrect)\n")}} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}) + if err != nil { + t.Fatalf("IsAlive: %v", err) + } + if !alive { + t.Fatal("alive = false, want true") + } + if sessionListedAlive("sess-1-long [Created 1s ago]", "sess-1") { + t.Fatal("prefix matched as alive") + } + if sessionListedAlive("sess-1 [Created 1s ago] (EXITED - attach to resurrect)", "sess-1") { + t.Fatal("exited session matched as alive") + } +} + +func TestIsAliveTreatsExitStatusAsNotAlive(t *testing.T) { + fr := &fakeRunner{err: &exec.ExitError{}} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}) + if err != nil { + t.Fatalf("IsAlive: %v", err) + } + if alive { + t.Fatal("alive = true, want false") + } +} + +func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { + fr := &fakeRunner{err: &exec.ExitError{}} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}); err != nil { + t.Fatalf("Destroy: %v", err) + } + if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" { + t.Fatalf("calls = %#v, want only kill-session", fr.calls) + } +} + +func TestGetOutputValidatesLines(t *testing.T) { + r := New(Options{Timeout: time.Second}) + _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, 0) + if err == nil { + t.Fatal("GetOutput lines=0: got nil, want error") + } +} + +type fakeRunner struct { + calls []runnerCall + outputs [][]byte + err error +} + +type runnerCall struct { + env []string + name string + args []string +} + +func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)}) + var out []byte + if len(f.outputs) > 0 { + out = f.outputs[0] + f.outputs = f.outputs[1:] + } + if f.err != nil { + return out, f.err + } + return out, nil +} + +func TestCommandErrorUnwraps(t *testing.T) { + base := errors.New("base") + err := commandError{err: base, output: "details"} + if !errors.Is(err, base) { + t.Fatal("commandError should unwrap base error") + } + if !strings.Contains(err.Error(), "details") { + t.Fatalf("error = %q, want output details", err.Error()) + } +} From db6975eb7147e20e7b4d18ee3c8d9f69290f1636 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 28 May 2026 16:18:27 +0530 Subject: [PATCH 032/250] fix: tighten zellij runtime attach and launch --- .../adapters/runtime/zellij/commands.go | 116 +++++++++++++++++- .../adapters/runtime/zellij/zellij.go | 47 +++++-- .../adapters/runtime/zellij/zellij_test.go | 94 +++++++++++++- 3 files changed, 244 insertions(+), 13 deletions(-) diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go index 2a866a0d70..4cdf865424 100644 --- a/backend/internal/adapters/runtime/zellij/commands.go +++ b/backend/internal/adapters/runtime/zellij/commands.go @@ -68,15 +68,46 @@ func terminalPaneID(id int) string { } func buildLayout(cfg ports.RuntimeConfig, shellPath string) string { + spec := shellLaunchSpecFor(shellPath) + shellCommand := shellLaunchCommand(cfg, shellPath, spec) + return layoutString(cfg.WorkspacePath, shellPath, spec.args, shellCommand) +} + +type shellLaunchSpec struct { + args []string +} + +func shellLaunchSpecFor(shellPath string) shellLaunchSpec { + base := strings.ToLower(filepathBase(shellPath)) + if strings.Contains(base, "cmd") { + return shellLaunchSpec{args: []string{"/D", "/S", "/K"}} + } + if strings.Contains(base, "powershell") || strings.Contains(base, "pwsh") { + return shellLaunchSpec{args: []string{"-NoLogo", "-NoProfile", "-NoExit", "-Command"}} + } + return shellLaunchSpec{args: []string{"-lc"}} +} + +func layoutString(workspacePath, shellPath string, shellArgs []string, shellCommand string) string { return "layout {\n" + - " cwd " + kdlQuote(cfg.WorkspacePath) + "\n" + + " cwd " + kdlQuote(workspacePath) + "\n" + " pane command=" + kdlQuote(shellPath) + " name=" + kdlQuote(agentPaneName) + " {\n" + - " args " + kdlQuote("-lc") + " " + kdlQuote(wrapLaunchCommand(cfg, shellPath)) + "\n" + + " args " + kdlJoin(shellArgs) + " " + kdlQuote(shellCommand) + "\n" + " }\n" + "}\n" } -func wrapLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { +func shellLaunchCommand(cfg ports.RuntimeConfig, shellPath string, spec shellLaunchSpec) string { + if len(spec.args) > 0 && spec.args[0] == "-NoLogo" { + return wrapLaunchCommandPowerShell(cfg, shellPath) + } + if len(spec.args) > 0 && spec.args[0] == "/D" { + return wrapLaunchCommandCmd(cfg) + } + return wrapLaunchCommandUnix(cfg, shellPath) +} + +func wrapLaunchCommandUnix(cfg ports.RuntimeConfig, shellPath string) string { path := cfg.Env["PATH"] if path == "" { path = getenv("PATH") @@ -105,6 +136,58 @@ func wrapLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { return b.String() } +func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig, shellPath string) string { + path := cfg.Env["PATH"] + if path == "" { + path = getenv("PATH") + } + + var b strings.Builder + for _, key := range sortedKeys(cfg.Env) { + if key == "PATH" { + continue + } + b.WriteString("$env:") + b.WriteString(key) + b.WriteString(" = ") + b.WriteString(psQuote(cfg.Env[key])) + b.WriteString("; ") + } + if path != "" { + b.WriteString("$env:PATH = ") + b.WriteString(psQuote(path)) + b.WriteString("; ") + } + b.WriteString(cfg.LaunchCommand) + return b.String() +} + +func wrapLaunchCommandCmd(cfg ports.RuntimeConfig) string { + path := cfg.Env["PATH"] + if path == "" { + path = getenv("PATH") + } + + var b strings.Builder + for _, key := range sortedKeys(cfg.Env) { + if key == "PATH" { + continue + } + b.WriteString("set \"") + b.WriteString(key) + b.WriteString("=") + b.WriteString(cmdQuote(cfg.Env[key])) + b.WriteString("\" && ") + } + if path != "" { + b.WriteString("set \"PATH=") + b.WriteString(cmdQuote(path)) + b.WriteString("\" && ") + } + b.WriteString(cfg.LaunchCommand) + return b.String() +} + func sortedKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { @@ -118,6 +201,33 @@ func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } +func psQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +func cmdQuote(s string) string { + return strings.ReplaceAll(s, "\"", "\"\"") +} + func kdlQuote(s string) string { return strconv.Quote(s) } + +func kdlJoin(args []string) string { + parts := make([]string, 0, len(args)) + for _, arg := range args { + parts = append(parts, kdlQuote(arg)) + } + return strings.Join(parts, " ") +} + +func filepathBase(path string) string { + if path == "" { + return "" + } + i := strings.LastIndexAny(path, `/\`) + if i < 0 { + return path + } + return path[i+1:] +} diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go index 1d110748eb..deeb38a3c8 100644 --- a/backend/internal/adapters/runtime/zellij/zellij.go +++ b/backend/internal/adapters/runtime/zellij/zellij.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "regexp" + "runtime" "strconv" "strings" "time" @@ -81,7 +82,11 @@ func New(opts Options) *Runtime { shellPath = os.Getenv("SHELL") } if shellPath == "" { - shellPath = "/bin/sh" + if runtime.GOOS == "windows" { + shellPath = "powershell.exe" + } else { + shellPath = "/bin/sh" + } } chunkSize := opts.ChunkSize if chunkSize <= 0 { @@ -189,9 +194,12 @@ func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { if err != nil { return nil, err } - args := append([]string{r.binary}, r.baseArgs()...) + args := append([]string{}, r.baseArgs()...) args = append(args, attachArgs(id)...) - return args, nil + if r.socketDir == "" { + return append([]string{r.binary}, args...), nil + } + return attachCommandWithEnv(r.binary, r.socketDir, args...), nil } func (r *Runtime) ensureSupportedVersion(ctx context.Context) error { @@ -232,14 +240,15 @@ func (r *Runtime) findAgentPane(ctx context.Context, id string) (string, error) var lastErr error for { out, err := r.run(ctx, listPanesArgs(id)...) - if err != nil { - return "", fmt.Errorf("zellij runtime: list panes %s: %w", id, err) - } - paneID, err := agentPaneID(out) if err == nil { - return paneID, nil + paneID, parseErr := agentPaneID(out) + if parseErr == nil { + return paneID, nil + } + lastErr = parseErr + } else { + lastErr = err } - lastErr = err if time.Now().After(deadline) { return "", fmt.Errorf("zellij runtime: list panes %s: %w", id, lastErr) } @@ -280,6 +289,26 @@ func (r *Runtime) env() []string { return []string{"ZELLIJ_SOCKET_DIR=" + r.socketDir} } +func attachCommandWithEnv(binary, socketDir string, args ...string) []string { + if socketDir == "" { + return append([]string{binary}, args...) + } + if runtime.GOOS == "windows" { + command := strings.Builder{} + command.WriteString("$env:ZELLIJ_SOCKET_DIR = ") + command.WriteString(psQuote(socketDir)) + command.WriteString("; & ") + command.WriteString(psQuote(binary)) + for _, arg := range args { + command.WriteByte(' ') + command.WriteString(psQuote(arg)) + } + return []string{"powershell.exe", "-NoLogo", "-NoProfile", "-Command", command.String()} + } + return append([]string{"env", "ZELLIJ_SOCKET_DIR=" + socketDir, binary}, args...) +} + + func zellijSessionName(id domain.SessionID) (string, error) { raw := string(id) if raw == "" { diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go index 72ab19e29a..a690af0385 100644 --- a/backend/internal/adapters/runtime/zellij/zellij_test.go +++ b/backend/internal/adapters/runtime/zellij/zellij_test.go @@ -5,6 +5,7 @@ import ( "errors" "os/exec" "reflect" + "runtime" "strings" "testing" "time" @@ -15,7 +16,11 @@ import ( func TestNewDefaultsToPortableShell(t *testing.T) { t.Setenv("SHELL", "") r := New(Options{}) - if got, want := r.shell, "/bin/sh"; got != want { + want := "/bin/sh" + if runtime.GOOS == "windows" { + want = "powershell.exe" + } + if got := r.shell; got != want { t.Fatalf("default shell = %q, want %q", got, want) } } @@ -121,6 +126,56 @@ func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) { } } +func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) { + oldGetenv := getenv + getenv = func(key string) string { + if key == "PATH" { + return `C:\custom\bin` + } + return "" + } + defer func() { getenv = oldGetenv }() + + got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, LaunchCommand: "Write-Host ready", Env: map[string]string{ + "AO_SESSION_ID": "sess-1", + }}, `C:\Program Files\PowerShell\7\pwsh.exe`) + + for _, want := range []string{ + `pane command="C:\\Program Files\\PowerShell\\7\\pwsh.exe" name="agent"`, + `args "-NoLogo" "-NoProfile" "-NoExit" "-Command"`, + "$env:AO_SESSION_ID = 'sess-1';", + "$env:PATH = ", + "Write-Host ready", + } { + if !strings.Contains(got, want) { + t.Fatalf("powershell layout missing %q in %q", want, got) + } + } +} + +func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) { + oldGetenv := getenv + getenv = func(key string) string { + return "" + } + defer func() { getenv = oldGetenv }() + + got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, LaunchCommand: "echo ready", Env: map[string]string{ + "AO_SESSION_ID": "sess-1", + }}, `C:\Windows\System32\cmd.exe`) + + for _, want := range []string{ + `pane command="C:\\Windows\\System32\\cmd.exe" name="agent"`, + `args "/D" "/S" "/K"`, + `AO_SESSION_ID=sess-1`, + "echo ready", + } { + if !strings.Contains(got, want) { + t.Fatalf("cmd layout missing %q in %q", want, got) + } + } +} + func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { fr := &fakeRunner{outputs: [][]byte{[]byte("zellij 0.44.3"), nil, []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`)}} r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"}) @@ -155,6 +210,43 @@ func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { } } +func TestAttachCommandUsesSocketDir(t *testing.T) { + r := New(Options{SocketDir: "/tmp/zj"}) + args, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}) + if err != nil { + t.Fatalf("AttachCommand: %v", err) + } + if runtime.GOOS == "windows" { + if len(args) < 4 || args[0] != "powershell.exe" { + t.Fatalf("attach command = %#v, want powershell wrapper", args) + } + if !strings.Contains(strings.Join(args, " "), "ZELLIJ_SOCKET_DIR") { + t.Fatalf("attach command missing socket dir env: %#v", args) + } + return + } + if got, want := args[:3], []string{"env", "ZELLIJ_SOCKET_DIR=/tmp/zj", r.binary}; !reflect.DeepEqual(got, want) { + t.Fatalf("attach prefix = %#v, want %#v", got, want) + } +} + +func TestFindAgentPaneRetriesTransientErrors(t *testing.T) { + fr := &fakeRunner{outputs: [][]byte{[]byte("boom"), []byte(`[{"id":0,"is_plugin":false,"title":"agent"}]`)}} + r := New(Options{Timeout: time.Second}) + r.runner = fr + + got, err := r.findAgentPane(context.Background(), "sess-1") + if err != nil { + t.Fatalf("findAgentPane: %v", err) + } + if got != "terminal_0" { + t.Fatalf("findAgentPane = %q, want terminal_0", got) + } + if len(fr.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(fr.calls)) + } +} + func TestParseVersion(t *testing.T) { for _, tc := range []struct { in string From 1e715ecfa952d143a2fe1dea82f99ffc72741a61 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 28 May 2026 16:20:13 +0530 Subject: [PATCH 033/250] style: gofmt zellij runtime adapter --- backend/internal/adapters/runtime/zellij/zellij.go | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go index deeb38a3c8..b98df84912 100644 --- a/backend/internal/adapters/runtime/zellij/zellij.go +++ b/backend/internal/adapters/runtime/zellij/zellij.go @@ -308,7 +308,6 @@ func attachCommandWithEnv(binary, socketDir string, args ...string) []string { return append([]string{"env", "ZELLIJ_SOCKET_DIR=" + socketDir, binary}, args...) } - func zellijSessionName(id domain.SessionID) (string, error) { raw := string(id) if raw == "" { From e66988ab773b7c240c22a36bdf314dc3e64a3016 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 22:04:27 +0530 Subject: [PATCH 034/250] fix: reconcile lifecycle writer contract --- backend/internal/domain/decide/types.go | 8 +- backend/internal/domain/lifecycle.go | 5 +- backend/internal/lifecycle/fakes_test.go | 54 +------ backend/internal/lifecycle/manager.go | 170 ++++++++++++--------- backend/internal/lifecycle/manager_test.go | 23 +-- backend/internal/ports/inbound.go | 9 +- backend/internal/ports/outbound.go | 56 ++----- backend/internal/session/fakes_test.go | 53 +------ backend/internal/session/manager.go | 32 ++-- backend/internal/session/manager_test.go | 26 ++-- 10 files changed, 176 insertions(+), 260 deletions(-) diff --git a/backend/internal/domain/decide/types.go b/backend/internal/domain/decide/types.go index 0a10691ff8..1666fae740 100644 --- a/backend/internal/domain/decide/types.go +++ b/backend/internal/domain/decide/types.go @@ -14,11 +14,11 @@ import ( // sub-state — leave it unchanged", NOT "set it to the empty value". SessionState // is always populated, but the probe/detecting/kill paths legitimately leave // PRState/PRReason empty: a liveness verdict knows nothing about the PR. When -// the LCM turns a decision into a LifecyclePatch it must therefore map an empty -// PRState to a nil patch.PR (left untouched) rather than writing it through — +// the LCM folds a decision into the next full canonical row it must therefore +// leave empty PRState/PRReason unchanged rather than writing them through — // writing PRNone on a routine probe tick would clobber a live PR. Detecting is -// nil-by-default for the same reason; see LifecyclePatch's three-way -// Detecting/ClearDetecting semantics. +// nil-by-default; the LCM explicitly clears stale detecting memory when a probe +// verdict leaves detecting. type LifecycleDecision struct { Status domain.SessionStatus Evidence string diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go index bdb26f2949..ab22eed31d 100644 --- a/backend/internal/domain/lifecycle.go +++ b/backend/internal/domain/lifecycle.go @@ -22,9 +22,8 @@ const LifecycleVersion = 1 type CanonicalSessionLifecycle struct { // Version is the schema version of this record's shape (LifecycleVersion). Version int `json:"version"` - // Revision is a monotonic counter the store bumps on every write. It is used - // for optimistic-concurrency checks (LifecyclePatch.ExpectedRevision) and is - // distinct from the schema Version above. + // Revision is a monotonic counter the LCM bumps on every full-row Upsert and + // is distinct from the schema Version above. Revision int `json:"revision"` Session SessionSubstate `json:"session"` PR PRSubstate `json:"pr"` diff --git a/backend/internal/lifecycle/fakes_test.go b/backend/internal/lifecycle/fakes_test.go index cc47ad847d..af7c06559e 100644 --- a/backend/internal/lifecycle/fakes_test.go +++ b/backend/internal/lifecycle/fakes_test.go @@ -2,18 +2,14 @@ package lifecycle import ( "context" - "fmt" "sync" - "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// fakeStore is an in-memory LifecycleStore that faithfully applies merge-patch -// semantics (sparse field writes, the three-way Detecting/ClearDetecting rule, -// ExpectedRevision optimistic-concurrency check, monotonic Revision bump) so -// tests assert against the real persisted canonical. +// fakeStore is an in-memory LifecycleStore that faithfully applies full-row +// Upsert semantics so tests assert against the real persisted canonical. type fakeStore struct { mu sync.Mutex records map[domain.SessionID]*domain.SessionRecord @@ -49,53 +45,9 @@ func (s *fakeStore) Load(_ context.Context, id domain.SessionID) (domain.Canonic return rec.Lifecycle, true, nil } -func (s *fakeStore) PatchLifecycle(_ context.Context, id domain.SessionID, p ports.LifecyclePatch) error { +func (s *fakeStore) Upsert(_ context.Context, rec domain.SessionRecord) error { s.mu.Lock() defer s.mu.Unlock() - - rec, ok := s.records[id] - if !ok { - rec = &domain.SessionRecord{ID: id, Lifecycle: domain.CanonicalSessionLifecycle{Version: domain.LifecycleVersion}} - s.records[id] = rec - } - l := &rec.Lifecycle - - if p.ExpectedRevision != nil && *p.ExpectedRevision != l.Revision { - return fmt.Errorf("revision mismatch for %s: have %d, expected %d", id, l.Revision, *p.ExpectedRevision) - } - - if p.Session != nil { - l.Session = *p.Session - } - if p.PR != nil { - l.PR = *p.PR - } - if p.Runtime != nil { - l.Runtime = *p.Runtime - } - if p.Activity != nil { - l.Activity = *p.Activity - } - switch { - case p.ClearDetecting: - l.Detecting = nil - case p.Detecting != nil: - d := *p.Detecting - l.Detecting = &d - } - - l.Version = domain.LifecycleVersion - l.Revision++ - rec.UpdatedAt = time.Now() - return nil -} - -func (s *fakeStore) Seed(_ context.Context, rec domain.SessionRecord) error { - s.mu.Lock() - defer s.mu.Unlock() - if _, ok := s.records[rec.ID]; ok { - return fmt.Errorf("seed: session %s already exists", rec.ID) - } if rec.Lifecycle.Version == 0 { rec.Lifecycle.Version = domain.LifecycleVersion } diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index f7b99ea6d5..84fb072eb0 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -1,12 +1,12 @@ // Package lifecycle implements ports.LifecycleManager: the synchronous // observe->decide->persist reducer. Every Apply*/On* entrypoint runs the same -// pipeline under a per-session lock — load canonical, run the matching pure -// decider, diff the result into a sparse merge-patch, persist. The LCM never +// pipeline under a per-session lock — load the full canonical record, run the +// matching pure decider, diff into a full-row Upsert, persist. The LCM never // polls and never writes the display status (that is derived on read). // // After a transition is persisted, the Apply* paths fire the mapped reaction // (the ACT layer: reaction table + escalation engine) via the react() chokepoint -// in reactions.go. The Session Manager lands in a later split. +// in reactions.go. package lifecycle import ( @@ -111,16 +111,16 @@ func (m *Manager) withLock(id domain.SessionID, fn func() error) error { } // transition is what a persisted write produced: the canonical before and after -// the patch. The ACT layer (react) derives the reaction from these. It is nil -// when the pipeline made no write. +// the full-row upsert. The ACT layer (react) derives the reaction from these. It +// is nil when the pipeline made no write. type transition struct { beforeLC domain.CanonicalSessionLifecycle afterLC domain.CanonicalSessionLifecycle } -// mutate runs the shared pipeline: load -> build patch -> persist (only if the -// patch changed something). decideFn returns the diffed patch and whether it -// touches anything; a false "changed" is a clean no-op (no write, no revision +// mutate runs the shared pipeline: load full row -> build next canonical -> +// Upsert full row (only if changed). decideFn returns the full next lifecycle +// and whether it changed anything; false is a clean no-op (no write, no revision // bump), which is how failed-probe / unknown-fact inputs are dropped. // // On a write it returns the transition (before/after canonical) so the caller — @@ -128,34 +128,39 @@ type transition struct { func (m *Manager) mutate( ctx context.Context, id domain.SessionID, - decideFn func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error), + decideFn func(cur domain.CanonicalSessionLifecycle, exists bool) (domain.CanonicalSessionLifecycle, bool, error), ) (*transition, error) { var tr *transition err := m.withLock(id, func() error { - cur, exists, err := m.store.Load(ctx, id) + rec, exists, err := m.store.Get(ctx, id) if err != nil { return err } - patch, changed, err := decideFn(cur, exists) + cur := rec.Lifecycle + next, changed, err := decideFn(cur, exists) if err != nil { return err } if !changed { return nil } - if err := m.store.PatchLifecycle(ctx, id, patch); err != nil { + rec.Lifecycle = m.prepareLifecycleWrite(cur, next) + rec.UpdatedAt = m.clock() + if err := m.store.Upsert(ctx, rec); err != nil { return err } - after, _, err := m.store.Load(ctx, id) - if err != nil { - return err - } - tr = &transition{beforeLC: cur, afterLC: after} + tr = &transition{beforeLC: cur, afterLC: rec.Lifecycle} return nil }) return tr, err } +func (m *Manager) prepareLifecycleWrite(cur, next domain.CanonicalSessionLifecycle) domain.CanonicalSessionLifecycle { + next.Version = domain.LifecycleVersion + next.Revision = cur.Revision + 1 + return next +} + // ---- OBSERVE entrypoints ---- // ApplyRuntimeObservation feeds the probe decider. Liveness always writes the @@ -163,18 +168,18 @@ func (m *Manager) mutate( // non-detecting verdict clears any stale detecting memory (#3) so the next // probe doesn't read a phantom prior. func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error { - tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (domain.CanonicalSessionLifecycle, bool, error) { if !exists { - return ports.LifecyclePatch{}, false, nil // nothing seeded; ignore stray probe + return cur, false, nil // nothing seeded; ignore stray probe } d := decide.ResolveProbeDecision(runtimeFactsToProbeInput(f, cur, m.recentActivityWindow)) - var patch ports.LifecyclePatch + next := cur changed := false if rt := runtimeSubstateFromFacts(f); cur.Runtime != rt { - patch.Runtime = &rt + next.Runtime = rt changed = true } // A terminal session is reopened only by an explicit Restore: an @@ -182,12 +187,12 @@ func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.Session // the session axis nor the detecting memory. if !isTerminal(cur.Session.State) { if shouldWriteSessionRuntime(d, cur) { - changed = setSessionIfChanged(&patch, cur, d.SessionState, d.SessionReason) || changed + changed = setSessionIfChanged(&next, d.SessionState, d.SessionReason) || changed } - changed = setDetecting(&patch, cur, d.Detecting) || changed + changed = setDetecting(&next, d.Detecting) || changed } - return patch, changed, nil + return next, changed, nil }) if err != nil { return err @@ -200,34 +205,34 @@ func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.Session // the session axis stays owned by activity, and DeriveLegacyStatus surfaces the // PR reason for display. A terminal PR (merged/closed) also parks the session. func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, f ports.SCMFacts) error { - tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (domain.CanonicalSessionLifecycle, bool, error) { if !exists || !f.Fetched { - return ports.LifecyclePatch{}, false, nil + return cur, false, nil } switch f.PRState { case domain.PRDraft, domain.PROpen: d := decide.ResolveOpenPRDecision(openPRInput(f)) - var patch ports.LifecyclePatch - changed := setPRIfChanged(&patch, cur, d, f) - return patch, changed, nil + next := cur + changed := setPRIfChanged(&next, d, f) + return next, changed, nil case domain.PRMerged, domain.PRClosed: d := decide.ResolveTerminalPRStateDecision(f.PRState) - var patch ports.LifecyclePatch - changed := setPRIfChanged(&patch, cur, d, f) + next := cur + changed := setPRIfChanged(&next, d, f) // A merge/close is a milestone that ends the work, so it parks the // session axis (idle / merged_waiting_decision) even over an // activity-owned needs_input/blocked — unlike the open-PR path, // which leaves the session axis to activity. A terminal session is // still never reopened. if !isTerminal(cur.Session.State) { - changed = setSessionIfChanged(&patch, cur, d.SessionState, d.SessionReason) || changed + changed = setSessionIfChanged(&next, d.SessionState, d.SessionReason) || changed } - return patch, changed, nil + return next, changed, nil default: // none / unset: no PR-driven transition in split A - return ports.LifecyclePatch{}, false, nil + return cur, false, nil } }) if err != nil { @@ -243,31 +248,31 @@ func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, // life, so it may resolve a detecting session — clearing the quarantine memory // so a later probe doesn't resume counting from a stale prior. func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error { - tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (domain.CanonicalSessionLifecycle, bool, error) { if !exists || s.State != ports.SignalValid { - return ports.LifecyclePatch{}, false, nil + return cur, false, nil } - var patch ports.LifecyclePatch + next := cur changed := false act := domain.ActivitySubstate{State: s.Activity, LastActivityAt: nowOr(s.Timestamp), Source: s.Source} if !sameActivity(cur.Activity, act) { - patch.Activity = &act + next.Activity = act changed = true } if st, rs, ok := activityToSession(s.Activity); ok && shouldWriteSessionActivity(cur) { - changed = setSessionIfChanged(&patch, cur, st, rs) || changed + changed = setSessionIfChanged(&next, st, rs) || changed // Proof of life that pulls the session out of detecting must also // drop the quarantine memory (detecting memory only exists while // detecting, so this is a no-op otherwise). if cur.Detecting != nil { - patch.ClearDetecting = true + next.Detecting = nil changed = true } } - return patch, changed, nil + return next, changed, nil }) if err != nil { return err @@ -275,7 +280,24 @@ func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, return m.react(ctx, id, tr, reactionContext{}) } -// ---- mutation outcomes reported by the Session Manager ---- +// ---- mutation commands/outcomes reported by the Session Manager ---- + +// OnSpawnInitiated seeds or reopens the full session record for a spawn-like +// mutation. It is the Session Manager's create/reopen command under the Writer +// contract: the SM builds the identity + initial canonical row, but only the LCM +// writes it. +func (m *Manager) OnSpawnInitiated(ctx context.Context, rec domain.SessionRecord) error { + return m.withLock(rec.ID, func() error { + cur := rec.Lifecycle + rec.Lifecycle = m.prepareLifecycleWrite(cur, cur) + now := m.clock() + if rec.CreatedAt.IsZero() { + rec.CreatedAt = now + } + rec.UpdatedAt = now + return m.store.Upsert(ctx, rec) + }) +} // OnSpawnCompleted records that a spawn finished: the runtime is up and the // handles are known. Per the agreed rule it flips the runtime axis to alive and @@ -283,7 +305,7 @@ func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, // (display: spawning) — the agent "acknowledges" via the first activity signal. func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error { return m.withLock(id, func() error { - cur, exists, err := m.store.Load(ctx, id) + rec, exists, err := m.store.Get(ctx, id) if err != nil { return err } @@ -294,8 +316,13 @@ func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o p return fmt.Errorf("lifecycle: OnSpawnCompleted for unseeded session %q", id) } rt := domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning} - if cur.Runtime != rt { - if err := m.store.PatchLifecycle(ctx, id, ports.LifecyclePatch{Runtime: &rt}); err != nil { + if rec.Lifecycle.Runtime != rt { + cur := rec.Lifecycle + next := cur + next.Runtime = rt + rec.Lifecycle = m.prepareLifecycleWrite(cur, next) + rec.UpdatedAt = m.clock() + if err := m.store.Upsert(ctx, rec); err != nil { return err } } @@ -315,30 +342,30 @@ func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o p func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, r ports.KillReason) error { // An explicit user kill is a human action, not an inferred event, so it // fires no reaction — the transition is discarded. - _, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { + _, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (domain.CanonicalSessionLifecycle, bool, error) { if !exists { // Killing an unknown/already-gone session is a benign race; no-op // rather than fabricating a terminal record for a session we never // knew about. - return ports.LifecyclePatch{}, false, nil + return cur, false, nil } - var patch ports.LifecyclePatch + next := cur changed := false if sess := killSession(r.Kind); cur.Session != sess { - patch.Session = &sess + next.Session = sess changed = true } if rt := killRuntime(r.Kind); cur.Runtime != rt { - patch.Runtime = &rt + next.Runtime = rt changed = true } if cur.Detecting != nil { - patch.ClearDetecting = true + next.Detecting = nil changed = true } - return patch, changed, nil + return next, changed, nil }) if err != nil { return err @@ -350,47 +377,48 @@ func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, r po return nil } -// ---- patch helpers (diff -> sparse merge-patch) ---- +// ---- diff helpers ---- -// setSessionIfChanged sets patch.Session only when the decided sub-state -// differs from current; an empty decided state means "decider does not address -// the session axis" and is left untouched. -func setSessionIfChanged(patch *ports.LifecyclePatch, cur domain.CanonicalSessionLifecycle, st domain.SessionState, rs domain.SessionReason) bool { +// setSessionIfChanged sets next.Session only when the decided sub-state differs +// from the current next value; an empty decided state means "decider does not +// address the session axis" and is left untouched. +func setSessionIfChanged(next *domain.CanonicalSessionLifecycle, st domain.SessionState, rs domain.SessionReason) bool { if st == "" { return false } want := domain.SessionSubstate{State: st, Reason: rs} - if cur.Session == want { + if next.Session == want { return false } - patch.Session = &want + next.Session = want return true } // setPRIfChanged folds the decided PR sub-state plus the fact-borne PR identity -// (number/url) into the patch when it differs from current. -func setPRIfChanged(patch *ports.LifecyclePatch, cur domain.CanonicalSessionLifecycle, d decide.LifecycleDecision, f ports.SCMFacts) bool { +// (number/url) into next when it differs from the current next value. +func setPRIfChanged(next *domain.CanonicalSessionLifecycle, d decide.LifecycleDecision, f ports.SCMFacts) bool { want := domain.PRSubstate{State: d.PRState, Reason: d.PRReason, Number: f.PRNumber, URL: f.PRURL} - if cur.PR == want { + if next.PR == want { return false } - patch.PR = &want + next.PR = want return true } -// setDetecting implements the three-way detecting semantics: set/replace when -// the decision carries memory, clear (#3) when it doesn't but canonical still -// holds stale memory, else leave untouched. -func setDetecting(patch *ports.LifecyclePatch, cur domain.CanonicalSessionLifecycle, d *domain.DetectingState) bool { +// setDetecting implements the detecting semantics on the full canonical row: +// set/replace when the decision carries memory, clear (#3) when it doesn't but +// canonical still holds stale memory, else leave untouched. +func setDetecting(next *domain.CanonicalSessionLifecycle, d *domain.DetectingState) bool { if d != nil { - if cur.Detecting != nil && *cur.Detecting == *d { + if next.Detecting != nil && *next.Detecting == *d { return false } - patch.Detecting = d + dc := *d + next.Detecting = &dc return true } - if cur.Detecting != nil { - patch.ClearDetecting = true + if next.Detecting != nil { + next.Detecting = nil return true } return false diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index a1d5b6354c..c5f75ad0fb 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -454,18 +454,23 @@ func TestOnKillRequested_UnseededIsNoOp(t *testing.T) { // ---- fake store contract ---- -func TestFakeStoreExpectedRevision(t *testing.T) { +func TestFakeStoreUpsertFullRow(t *testing.T) { store := newFakeStore() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) // revision 0 - rt := domain.RuntimeSubstate{State: domain.RuntimeExited} + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) - bad := 99 - if err := store.PatchLifecycle(context.Background(), sid, ports.LifecyclePatch{Runtime: &rt, ExpectedRevision: &bad}); err == nil { - t.Error("stale ExpectedRevision must be rejected") + rec, ok, err := store.Get(context.Background(), sid) + if err != nil || !ok { + t.Fatalf("seeded record missing: ok=%v err=%v", ok, err) } - good := 0 - if err := store.PatchLifecycle(context.Background(), sid, ports.LifecyclePatch{Runtime: &rt, ExpectedRevision: &good}); err != nil { - t.Errorf("matching ExpectedRevision must succeed, got %v", err) + rec.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionIdle, Reason: domain.ReasonResearchComplete} + rec.Lifecycle.Runtime = domain.RuntimeSubstate{State: domain.RuntimeExited} + if err := store.Upsert(context.Background(), rec); err != nil { + t.Fatalf("upsert: %v", err) + } + + got, _, _ := store.Get(context.Background(), sid) + if got.Lifecycle.Session.State != domain.SessionIdle || got.Lifecycle.Runtime.State != domain.RuntimeExited { + t.Fatalf("upsert should replace the full canonical row, got %+v", got.Lifecycle) } } diff --git a/backend/internal/ports/inbound.go b/backend/internal/ports/inbound.go index 30ab755954..6508845c04 100644 --- a/backend/internal/ports/inbound.go +++ b/backend/internal/ports/inbound.go @@ -9,7 +9,7 @@ import ( // LifecycleManager is the inbound contract we implement. Every Apply* method // runs the same synchronous pipeline: load canonical -> pure decide -> diff -> -// persist (merge-patch) -> if the status transitioned, fire reactions. The LCM +// persist (full-row Upsert) -> if the status transitioned, fire reactions. The LCM // never polls; observers (SCM poller, reaper, activity ingest) call in. // // Concurrency: the LCM serialises per session, so concurrent Apply* calls for @@ -20,7 +20,8 @@ type LifecycleManager interface { ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f RuntimeFacts) error ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ActivitySignal) error - // Mutation outcomes reported by the Session Manager. + // Mutation commands/outcomes reported by the Session Manager. + OnSpawnInitiated(ctx context.Context, rec domain.SessionRecord) error OnSpawnCompleted(ctx context.Context, id domain.SessionID, o SpawnOutcome) error OnKillRequested(ctx context.Context, id domain.SessionID, r KillReason) error @@ -30,8 +31,8 @@ type LifecycleManager interface { } // SessionManager is the inbound contract called by the API layer and CLI. It -// owns explicit mutations (spawn/kill/restore/cleanup) and never derives or -// writes observed state directly — it routes outcomes to the LCM. +// owns explicit mutations (spawn/kill/restore/cleanup) and never writes +// sessions directly — it routes mutation commands/outcomes to the LCM. type SessionManager interface { Spawn(ctx context.Context, cfg SpawnConfig) (domain.Session, error) Kill(ctx context.Context, id domain.SessionID, opts KillOptions) (KillResult, error) diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index a9c03e22ae..c5ee6bfac8 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -6,61 +6,31 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// LifecycleStore is the persistence adapter, the ONLY disk writer. It owns -// merge-patch, atomic write, file lock, and CDC eventing. The LCM and SM only -// ever touch state through this narrow interface. +// LifecycleStore is Tom's persistence adapter for session records. // -// List returns persistence records (no derived status); the Session Manager -// turns those into domain.Session by attaching the derived display status. +// Writer contract: the Lifecycle Manager (LCM) is the sole logical writer of +// sessions. Controllers, the Session Manager, observers, and other goroutines +// must route mutations to the LCM; no other goroutine writes sessions directly. +// The LCM serializes mutations and calls Upsert with the full SessionRecord. +// This full-row insert-or-update replaces the older sparse merge-patch model and +// is safe only under the single-writer LCM invariant. // -// Seed and Get are the two record-with-identity methods the Session Manager -// needs that the LCM does not: Load returns lifecycle only (all the decider -// needs), so the SM read-model and explicit-create path would otherwise have no -// way to write or read a record's identity (ID/ProjectID/IssueID/Kind/CreatedAt) -// by id. (Co-owned with Tom's persistence layer — added here to close that gap.) +// List/Get return persistence records (no derived status); the Session Manager +// hydrates them into domain.Session by attaching DeriveLegacyStatus on read. type LifecycleStore interface { + // Upsert inserts or replaces the full session row. Only the LCM may call it. + Upsert(ctx context.Context, rec domain.SessionRecord) error Load(ctx context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) - PatchLifecycle(ctx context.Context, id domain.SessionID, patch LifecyclePatch) error List(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) GetMetadata(ctx context.Context, id domain.SessionID) (map[string]string, error) PatchMetadata(ctx context.Context, id domain.SessionID, kv map[string]string) error - // Seed creates a new record with its identity and initial lifecycle. It is - // the SM's explicit-create path (the LCM only ever patches existing records); - // OnSpawnCompleted requires a seeded record, so Spawn calls this first. It - // must reject a seed for an id that already exists rather than overwrite — - // re-seeding an existing session (e.g. Restore) goes through PatchLifecycle. - Seed(ctx context.Context, rec domain.SessionRecord) error - // Get returns a single full record (with identity) by id. Load is - // lifecycle-only, so the SM uses this to build the read-model and to - // reconstruct teardown handles for Kill/Restore on one id. + // lifecycle-only, so readers use this to build the read-model and reconstruct + // teardown handles for Kill/Restore on one id. Get(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) } -// LifecyclePatch is a sparse merge-patch: a nil field is left untouched, a -// non-nil field is written. -// -// Detecting needs three-way semantics (leave / set / clear-to-nil): -// - ClearDetecting == true → store clears the detecting memory and IGNORES -// the Detecting field (clear wins; setting both is a caller bug). -// - ClearDetecting == false, Detecting != nil → set/replace the memory. -// - ClearDetecting == false, Detecting == nil → leave it untouched. -// -// ExpectedRevision supports optimistic concurrency: when non-nil the store must -// reject the patch if the stored Revision (the monotonic write counter, NOT the -// schema Version) differs. This is the alternative to the LCM owning all -// per-session serialisation itself. -type LifecyclePatch struct { - Session *domain.SessionSubstate - PR *domain.PRSubstate - Runtime *domain.RuntimeSubstate - Activity *domain.ActivitySubstate - Detecting *domain.DetectingState - ClearDetecting bool - ExpectedRevision *int -} - // Notifier delivers events to the human (desktop/Slack later). Push, never pull. type Notifier interface { Notify(ctx context.Context, event OrchestratorEvent) error diff --git a/backend/internal/session/fakes_test.go b/backend/internal/session/fakes_test.go index 648172dee7..5974684390 100644 --- a/backend/internal/session/fakes_test.go +++ b/backend/internal/session/fakes_test.go @@ -42,7 +42,7 @@ func (c *callLog) indexOf(name string) int { return -1 } -// ---- fakeStore: in-memory LifecycleStore with faithful merge-patch + Seed/Get ---- +// ---- fakeStore: in-memory LifecycleStore with full-row Upsert + Get ---- type fakeStore struct { mu sync.Mutex @@ -59,12 +59,9 @@ func newFakeStore() *fakeStore { } } -func (s *fakeStore) Seed(_ context.Context, rec domain.SessionRecord) error { +func (s *fakeStore) Upsert(_ context.Context, rec domain.SessionRecord) error { s.mu.Lock() defer s.mu.Unlock() - if _, ok := s.records[rec.ID]; ok { - return fmt.Errorf("seed: session %s already exists", rec.ID) - } if rec.Lifecycle.Version == 0 { rec.Lifecycle.Version = domain.LifecycleVersion } @@ -93,47 +90,6 @@ func (s *fakeStore) Load(_ context.Context, id domain.SessionID) (domain.Canonic return rec.Lifecycle, true, nil } -func (s *fakeStore) PatchLifecycle(_ context.Context, id domain.SessionID, p ports.LifecyclePatch) error { - s.mu.Lock() - defer s.mu.Unlock() - - rec, ok := s.records[id] - if !ok { - rec = &domain.SessionRecord{ID: id, Lifecycle: domain.CanonicalSessionLifecycle{Version: domain.LifecycleVersion}} - s.records[id] = rec - } - l := &rec.Lifecycle - - if p.ExpectedRevision != nil && *p.ExpectedRevision != l.Revision { - return fmt.Errorf("revision mismatch for %s: have %d, expected %d", id, l.Revision, *p.ExpectedRevision) - } - - if p.Session != nil { - l.Session = *p.Session - } - if p.PR != nil { - l.PR = *p.PR - } - if p.Runtime != nil { - l.Runtime = *p.Runtime - } - if p.Activity != nil { - l.Activity = *p.Activity - } - switch { - case p.ClearDetecting: - l.Detecting = nil - case p.Detecting != nil: - d := *p.Detecting - l.Detecting = &d - } - - l.Version = domain.LifecycleVersion - l.Revision++ - rec.UpdatedAt = time.Now() - return nil -} - func (s *fakeStore) List(_ context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { s.mu.Lock() defer s.mu.Unlock() @@ -327,6 +283,11 @@ type recordingLCM struct { var _ ports.LifecycleManager = (*recordingLCM)(nil) +func (l *recordingLCM) OnSpawnInitiated(ctx context.Context, rec domain.SessionRecord) error { + l.log.add("OnSpawnInitiated") + return l.inner.OnSpawnInitiated(ctx, rec) +} + func (l *recordingLCM) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error { l.log.add("OnSpawnCompleted") if l.onSpawnErr != nil { diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index e2723d26a8..79d440273e 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -1,11 +1,10 @@ // Package session implements ports.SessionManager: the explicit-mutation half // of the lane. The SM is impure plumbing — it drives the Runtime/Agent/Workspace -// plugins to create and tear down sessions, seeds the initial lifecycle record, -// and routes mutation outcomes to the LCM (OnSpawnCompleted / OnKillRequested). +// plugins to create and tear down sessions, and routes mutation commands and +// outcomes to the LCM (OnSpawnInitiated / OnSpawnCompleted / OnKillRequested). // -// It NEVER derives or observes lifecycle state: observed transitions are the -// LCM's job. The SM's only canonical writes are the explicit ones — seeding a -// new record on Spawn and re-seeding (reopening) on Restore — and it is the +// It NEVER writes sessions directly: observed transitions and explicit +// canonical mutations are the LCM's job under the Writer contract. The SM is the // single producer of the derived display status, attached on read in List/Get // and never persisted. package session @@ -96,8 +95,8 @@ func New(d Deps) *Manager { // ---- Spawn ---- -// Spawn runs the create pipeline in spec order: workspace -> runtime -> seed -> -// report to the LCM. The record is seeded LATE (after the runtime is up), so a +// Spawn runs the create pipeline in spec order: workspace -> runtime -> route +// seed command to the LCM -> report completion to the LCM. The record is seeded LATE (after the runtime is up), so a // failure before the seed leaves no record for Cleanup to reclaim — hence each // step eagerly rolls back the steps that already succeeded. func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { @@ -124,10 +123,10 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess return domain.Session{}, fmt.Errorf("spawn %s: runtime create: %w", id, err) } - if err := m.store.Seed(ctx, seedRecord(id, cfg, m.clock())); err != nil { + if err := m.lcm.OnSpawnInitiated(ctx, seedRecord(id, cfg, m.clock())); err != nil { m.rollbackRuntime(ctx, handle) m.rollbackWorkspace(ctx, ws) - return domain.Session{}, fmt.Errorf("spawn %s: seed: %w", id, err) + return domain.Session{}, fmt.Errorf("spawn %s: on spawn initiated: %w", id, err) } outcome := ports.SpawnOutcome{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle} @@ -245,7 +244,7 @@ func (m *Manager) Send(ctx context.Context, id domain.SessionID, message string) // fallible I/O (workspace restore + runtime create) runs first so a failure // touches no canonical state and never destroys the worktree (it may hold the // agent's prior work). Only once the runtime is up do we reopen the lifecycle: -// resetting a terminal session is an explicit mutation (the SM's authority; the +// resetting a terminal session is an explicit mutation routed to the LCM (the // LCM's observe path would never resurrect a terminal session), and the PR axis // is cleared. OnSpawnCompleted then flips the runtime to alive. func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { @@ -298,13 +297,14 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess // Past this point the runtime is live: a failure must tear it back down (but // never the workspace, which holds the agent's prior work) so we don't strand // a process while parking the session in a terminal lifecycle. - reopen := ports.LifecyclePatch{ - Session: &domain.SessionSubstate{State: domain.SessionNotStarted, Reason: domain.ReasonSpawnRequested}, - PR: &domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonClearedOnRestore}, - } - if err := m.store.PatchLifecycle(ctx, id, reopen); err != nil { + reopen := rec + reopen.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionNotStarted, Reason: domain.ReasonSpawnRequested} + reopen.Lifecycle.PR = domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonClearedOnRestore} + reopen.Lifecycle.Runtime = domain.RuntimeSubstate{State: domain.RuntimeUnknown, Reason: domain.RuntimeReasonSpawnIncomplete} + reopen.Lifecycle.Detecting = nil + if err := m.lcm.OnSpawnInitiated(ctx, reopen); err != nil { m.rollbackRuntime(ctx, handle) - return domain.Session{}, fmt.Errorf("restore %s: reopen: %w", id, err) + return domain.Session{}, fmt.Errorf("restore %s: on spawn initiated: %w", id, err) } outcome := ports.SpawnOutcome{ diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index 702a735e43..cf9407bea7 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -41,7 +41,7 @@ func TestSpawn_HappyPath(t *testing.T) { t.Errorf("status = %q, want %q", sess.Status, domain.StatusSpawning) } - // Record seeded with identity + initial lifecycle, then OnSpawnCompleted flipped + // Record seeded by the LCM with identity + initial lifecycle, then OnSpawnCompleted flipped // the runtime axis to alive. rec, ok, err := h.store.Get(ctx, "sess-1") if err != nil || !ok { @@ -60,8 +60,8 @@ func TestSpawn_HappyPath(t *testing.T) { t.Errorf("runtime substate = %+v, want alive/process_running", got) } - // Pipeline order: workspace -> runtime -> (seed) -> LCM. - wantOrder := []string{"Workspace.Create", "Runtime.Create", "OnSpawnCompleted"} + // Pipeline order: workspace -> runtime -> LCM seed command -> LCM completion. + wantOrder := []string{"Workspace.Create", "Runtime.Create", "OnSpawnInitiated", "OnSpawnCompleted"} if got := h.log.snapshot(); !equalStrings(got, wantOrder) { t.Errorf("call order = %v, want %v", got, wantOrder) } @@ -217,11 +217,11 @@ func TestKill_IncompleteMetadata_RefusesTeardown(t *testing.T) { ctx := context.Background() // A record with no teardown metadata (empty runtime handle + workspace path), // e.g. a partially-seeded or corrupted record. - if err := h.store.Seed(ctx, domain.SessionRecord{ + if err := h.store.Upsert(ctx, domain.SessionRecord{ ID: "sess-1", ProjectID: testProject, Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), }); err != nil { - t.Fatalf("seed: %v", err) + t.Fatalf("upsert: %v", err) } if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); !errors.Is(err, ErrIncompleteTeardownMetadata) { @@ -241,11 +241,11 @@ func TestCleanup_IncompleteMetadata_Skipped(t *testing.T) { ctx := context.Background() // Terminal session but no workspace path persisted — must be skipped, never // handed to Destroy with an empty path. - if err := h.store.Seed(ctx, domain.SessionRecord{ + if err := h.store.Upsert(ctx, domain.SessionRecord{ ID: "orphan-1", ProjectID: testProject, Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), }); err != nil { - t.Fatalf("seed: %v", err) + t.Fatalf("upsert: %v", err) } res, err := h.sm.Cleanup(ctx, testProject) @@ -307,8 +307,8 @@ func TestListAndGet_DeriveStatus(t *testing.T) { h := newHarness("unused") ctx := context.Background() for _, c := range cases { - if err := h.store.Seed(ctx, domain.SessionRecord{ID: domain.SessionID(c.name), ProjectID: testProject, Lifecycle: c.lc}); err != nil { - t.Fatalf("seed %s: %v", c.name, err) + if err := h.store.Upsert(ctx, domain.SessionRecord{ID: domain.SessionID(c.name), ProjectID: testProject, Lifecycle: c.lc}); err != nil { + t.Fatalf("upsert %s: %v", c.name, err) } } @@ -474,11 +474,11 @@ func TestCleanup_SkipsUncommittedWork(t *testing.T) { // Two terminal sessions (reclaimable) + one working session (must be ignored). seedTerminal(t, h, "done-1", "/tmp/ws/done-1") seedTerminal(t, h, "dirty-1", "/tmp/ws/dirty-1") - if err := h.store.Seed(ctx, domain.SessionRecord{ + if err := h.store.Upsert(ctx, domain.SessionRecord{ ID: "live-1", ProjectID: testProject, Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), }); err != nil { - t.Fatalf("seed live: %v", err) + t.Fatalf("upsert live: %v", err) } // dirty-1's worktree still holds uncommitted work — Destroy refuses it. h.workspace.refuse["/tmp/ws/dirty-1"] = true @@ -514,11 +514,11 @@ func lc(s domain.SessionState, r domain.SessionReason, prs domain.PRState, prr d func seedTerminal(t *testing.T, h *harness, id domain.SessionID, wsPath string) { t.Helper() ctx := context.Background() - if err := h.store.Seed(ctx, domain.SessionRecord{ + if err := h.store.Upsert(ctx, domain.SessionRecord{ ID: id, ProjectID: testProject, Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), }); err != nil { - t.Fatalf("seed %s: %v", id, err) + t.Fatalf("upsert %s: %v", id, err) } if err := h.store.PatchMetadata(ctx, id, map[string]string{lifecycle.MetaWorkspacePath: wsPath}); err != nil { t.Fatalf("patch metadata %s: %v", id, err) From fcb3aec9886a37513f19fc68475ecfe4afbf44e9 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 28 May 2026 17:25:02 +0530 Subject: [PATCH 035/250] fix: handle restore rollback and spawn id collisions --- backend/internal/session/manager.go | 8 ++++++ backend/internal/session/manager_test.go | 31 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index 79d440273e..6ae2a60e98 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -101,6 +101,11 @@ func New(d Deps) *Manager { // step eagerly rolls back the steps that already succeeded. func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { id := m.newID(cfg) + if _, ok, err := m.store.Get(ctx, id); err != nil { + return domain.Session{}, fmt.Errorf("spawn %s: check existing: %w", id, err) + } else if ok { + return domain.Session{}, fmt.Errorf("spawn %s: already exists", id) + } ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ ProjectID: cfg.ProjectID, @@ -315,6 +320,9 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess } if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { m.rollbackRuntime(ctx, handle) + if revertErr := m.lcm.OnSpawnInitiated(ctx, rec); revertErr != nil { + return domain.Session{}, fmt.Errorf("restore %s: revert after spawn completed failure: %w (original error: %v)", id, revertErr, err) + } return domain.Session{}, fmt.Errorf("restore %s: on spawn completed: %w", id, err) } return m.Get(ctx, id) diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index cf9407bea7..b50555580a 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -121,6 +121,32 @@ func TestSpawn_RuntimeCreateFailure_RollsBack(t *testing.T) { } } +func TestSpawn_ExistingSessionIDRejectedBeforeWork(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + if err := h.store.Upsert(ctx, domain.SessionRecord{ + ID: "sess-1", + ProjectID: testProject, + Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), + }); err != nil { + t.Fatalf("seed existing row: %v", err) + } + + _, err := h.sm.Spawn(ctx, spawnCfg()) + if err == nil { + t.Fatal("spawn: want error for existing session id, got nil") + } + if len(h.workspace.created) != 0 { + t.Error("workspace should not be created when session id already exists") + } + if len(h.runtime.created) != 0 { + t.Error("runtime should not be created when session id already exists") + } + if h.log.indexOf("OnSpawnInitiated") != -1 || h.log.indexOf("OnSpawnCompleted") != -1 { + t.Error("LCM should not be called when session id already exists") + } +} + func TestSpawn_OnSpawnCompletedFailure_RoutesOrphanToErrored(t *testing.T) { h := newHarness("sess-1") ctx := context.Background() @@ -457,6 +483,11 @@ func TestRestore_OnSpawnCompletedFailure_RollsBackRuntime(t *testing.T) { t.Fatal("restore: want error, got nil") } + rec, _, _ := h.store.Get(ctx, "sess-1") + if got := rec.Lifecycle.Session; got.State != domain.SessionTerminated || got.Reason != domain.ReasonManuallyKilled { + t.Fatalf("restore failure should restore terminal lifecycle, got %+v", got) + } + // The runtime created during restore is torn back down so no process is // stranded; the workspace is left intact (it holds the agent's prior work). if len(h.runtime.destroyed) != destroyedBefore+1 { From 71604970132e533cf03106ceecd1509a13752163 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 28 May 2026 19:37:20 +0530 Subject: [PATCH 036/250] add agent-orchestrator.yaml to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 291f2c955a..e5ea212a2b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ bin/ vendor/ # compiled daemon binary /backend/backend +agent-orchestrator.yaml # Environment .env From 1fee0164e2770d8b42bb7d0227171383145f0ba4 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 28 May 2026 19:46:54 +0530 Subject: [PATCH 037/250] fix: restore revision monotonicity --- backend/internal/lifecycle/manager.go | 7 ++++++- backend/internal/session/fakes_test.go | 7 +++++++ backend/internal/session/manager_test.go | 4 ++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 84fb072eb0..c8e74f2ce9 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -289,7 +289,12 @@ func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, func (m *Manager) OnSpawnInitiated(ctx context.Context, rec domain.SessionRecord) error { return m.withLock(rec.ID, func() error { cur := rec.Lifecycle - rec.Lifecycle = m.prepareLifecycleWrite(cur, cur) + if current, ok, err := m.store.Get(ctx, rec.ID); err != nil { + return err + } else if ok { + cur = current.Lifecycle + } + rec.Lifecycle = m.prepareLifecycleWrite(cur, rec.Lifecycle) now := m.clock() if rec.CreatedAt.IsZero() { rec.CreatedAt = now diff --git a/backend/internal/session/fakes_test.go b/backend/internal/session/fakes_test.go index 5974684390..ebeac65985 100644 --- a/backend/internal/session/fakes_test.go +++ b/backend/internal/session/fakes_test.go @@ -62,6 +62,13 @@ func newFakeStore() *fakeStore { func (s *fakeStore) Upsert(_ context.Context, rec domain.SessionRecord) error { s.mu.Lock() defer s.mu.Unlock() + if existing, ok := s.records[rec.ID]; ok { + if rec.Lifecycle.Revision != existing.Lifecycle.Revision+1 { + return fmt.Errorf("revision mismatch for %s: have %d, want %d", rec.ID, rec.Lifecycle.Revision, existing.Lifecycle.Revision+1) + } + } else if rec.Lifecycle.Revision == 0 { + rec.Lifecycle.Revision = 1 + } if rec.Lifecycle.Version == 0 { rec.Lifecycle.Version = domain.LifecycleVersion } diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index b50555580a..a0812456da 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -476,6 +476,7 @@ func TestRestore_OnSpawnCompletedFailure_RollsBackRuntime(t *testing.T) { // Fail the post-create LCM call; capture teardown counts just before restore. h.lcm.onSpawnErr = errors.New("lcm boom") + before, _, _ := h.store.Get(ctx, "sess-1") destroyedBefore := len(h.runtime.destroyed) wsDestroyedBefore := len(h.workspace.destroyed) @@ -487,6 +488,9 @@ func TestRestore_OnSpawnCompletedFailure_RollsBackRuntime(t *testing.T) { if got := rec.Lifecycle.Session; got.State != domain.SessionTerminated || got.Reason != domain.ReasonManuallyKilled { t.Fatalf("restore failure should restore terminal lifecycle, got %+v", got) } + if rec.Lifecycle.Revision != before.Lifecycle.Revision+2 { + t.Fatalf("restore failure should advance revision twice, got %d want %d", rec.Lifecycle.Revision, before.Lifecycle.Revision+2) + } // The runtime created during restore is torn back down so no process is // stranded; the workspace is left intact (it holds the agent's prior work). From 59a654afeabf7665f1af1403f00bb7a528ba7a8c Mon Sep 17 00:00:00 2001 From: neversettle <41864816+neversettle17-101@users.noreply.github.com> Date: Fri, 29 May 2026 10:02:53 +0530 Subject: [PATCH 038/250] =?UTF-8?q?feat(backend):=20HTTP=20daemon=20skelet?= =?UTF-8?q?on=20(Phase=201a)=20=E2=80=94=20#10=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(backend): HTTP daemon skeleton — config, health, runfile, graceful shutdown (#10) Phase 1a of the Go HTTP daemon lane (#10). Stands up the loopback-only sidecar skeleton the later REST/SSE/WS/static surfaces build on: - config: env-driven (AO_HOST/PORT/ENV/timeouts/run-file) with zero-config defaults; binds 127.0.0.1:3001; validates and fails fast on bad input. - httpd: chi router with the recoverer → request-id → logger → real-ip middleware stack and /healthz + /readyz probes. Per-request timeout is carried in config but intentionally not global — it scopes to /api/v1 in Phase 1b so it never throttles SSE/WS/health. - runfile: atomic PID + port handshake (running.json) for the Electron supervisor, with a dead-PID stale check so a crashed predecessor doesn't block startup while a live one fails fast. - server: bind-before-publish (port conflict fails fast), graceful shutdown on SIGINT/SIGTERM via signal.NotifyContext with a 10s hard timeout, and run-file cleanup on exit. Why: the daemon must be safely supervisable as a child process — the supervisor needs a discoverable PID/port and the daemon must not leave a half-started process or stale handshake behind. Locking the lifecycle down now keeps the future port split a small change rather than a rewrite. Tests cover config defaults/overrides/validation, run-file round-trip and live/dead PID detection, health probes, full Run lifecycle, and port-conflict fail-fast. Co-Authored-By: Claude Opus 4.7 * refactor(backend): drop Env config field — not needed yet (#10) Per review on #14: AO_ENV / Config.Env / IsProduction() weren't load-bearing for Phase 1a — they only switched the slog handler. Removing them now keeps the surface minimal; the env knob can come back later when a real consumer needs it. - config: remove Env field, AO_ENV parsing, and IsProduction helper. - main: collapse newLogger to a single text-handler path. - httpd: drop the env field from the listening log line. - tests: drop the env assertions and AO_ENV fixture. Co-Authored-By: Claude Opus 4.7 * docs: add backend run + config quick-start to README (#10) Co-Authored-By: Claude Opus 4.7 * fix(backend): address Phase 1a review comments (#10) - config: drop AO_HOST entirely — the daemon is loopback-only by design, so making the bind host env-configurable was a security footgun - config: use net.JoinHostPort in Addr() so IPv6 literals stay valid - config: reject zero/negative AO_REQUEST_TIMEOUT and AO_SHUTDOWN_TIMEOUT (time.ParseDuration accepts both; either would silently break the daemon — instant request expiry / no graceful drain) - runfile: split processAlive into unix/windows build-tagged files so liveness detection is reliable on both platforms (Windows uses OpenProcess; POSIX keeps signal 0) - runfile: document os.Rename overwrite semantics (atomic on POSIX, REPLACE_EXISTING on Windows) so the temp-then-rename pattern's cross-platform behaviour is explicit - httpd tests: give probe/waitForHealth clients an explicit per-request timeout so a stalled connect can't hang the test on the outer deadline * fix(backend): strip trailing blank line from runfile.go (#10) gofmt CI was failing because removing the orphan processAlive doc comment left an extra newline at EOF. * fix(backend): cross-platform run-file replace + AO_HOST rationale (#10) - runfile: introduce build-tagged atomicReplace — POSIX rename(2) on Unix, MoveFileEx with MOVEFILE_REPLACE_EXISTING on Windows. The Go runtime happens to do the Windows call internally already, but invoking it directly makes the cross-platform contract explicit instead of a runtime implementation detail - runfile: tighten process_unix.go build tag from `!windows` to `unix` so plan9/js/wasm fail to build rather than silently using a broken signal-0 probe - runfile: add TestWriteOverwritesExisting covering the stale run-file replace path that none of the previous tests exercised - config: anchor the loopback-only decision in the LoopbackHost doc so the next contributor doesn't reintroduce AO_HOST without the security rationale * fix(backend): route chi access logs through slog/stderr (#10) chi's middleware.Logger writes via stdlib log to stdout, but the daemon's slog logger writes to stderr — so REST traffic and daemon logs landed on different streams in different formats. Replace it with a small slog-backed requestLogger that: - Wraps the response writer via middleware.NewWrapResponseWriter so status/bytes are accurate even when handlers return without an explicit WriteHeader. - Reads the request id off the context set by middleware.RequestID (kept mounted just before this middleware so the id is available). - Emits one structured Info line per request with method, path, status, bytes, duration, and remote — same key=value shape as the rest of the daemon, one stream for the Electron supervisor to capture. --------- Co-authored-by: Claude Opus 4.7 --- README.md | 44 ++++++ backend/go.mod | 2 + backend/go.sum | 2 + backend/internal/config/config.go | 140 ++++++++++++++++++++ backend/internal/config/config_test.go | 84 ++++++++++++ backend/internal/httpd/json.go | 17 +++ backend/internal/httpd/log.go | 40 ++++++ backend/internal/httpd/router.go | 63 +++++++++ backend/internal/httpd/server.go | 113 ++++++++++++++++ backend/internal/httpd/server_test.go | 130 ++++++++++++++++++ backend/internal/runfile/process_unix.go | 24 ++++ backend/internal/runfile/process_windows.go | 21 +++ backend/internal/runfile/rename_unix.go | 13 ++ backend/internal/runfile/rename_windows.go | 28 ++++ backend/internal/runfile/runfile.go | 111 ++++++++++++++++ backend/internal/runfile/runfile_test.go | 119 +++++++++++++++++ backend/main.go | 59 ++++++++- 17 files changed, 1008 insertions(+), 2 deletions(-) create mode 100644 backend/go.sum create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/config/config_test.go create mode 100644 backend/internal/httpd/json.go create mode 100644 backend/internal/httpd/log.go create mode 100644 backend/internal/httpd/router.go create mode 100644 backend/internal/httpd/server.go create mode 100644 backend/internal/httpd/server_test.go create mode 100644 backend/internal/runfile/process_unix.go create mode 100644 backend/internal/runfile/process_windows.go create mode 100644 backend/internal/runfile/rename_unix.go create mode 100644 backend/internal/runfile/rename_windows.go create mode 100644 backend/internal/runfile/runfile.go create mode 100644 backend/internal/runfile/runfile_test.go diff --git a/README.md b/README.md index 353d12001d..f5c17deb72 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,47 @@ paired with an Electron + TypeScript frontend (`frontend/`). See [`docs/`](docs/README.md) for architecture and status — start with the Lifecycle Manager + Session Manager lane in [`docs/architecture.md`](docs/architecture.md). + +## Backend daemon + +The Go binary in [`backend/`](backend/) is the HTTP daemon — a loopback-only +sidecar the Electron supervisor will spawn (Phase 1c). Phase 1a landed the +skeleton: chi router, middleware stack (recoverer → request-id → logger → +real-ip), `/healthz` + `/readyz`, atomic `running.json` PID/port handshake, +graceful shutdown on SIGINT/SIGTERM. + +### Run + +```bash +cd backend +go run . # binds 127.0.0.1:3001 with all defaults +AO_PORT=3019 go run . # override per invocation +``` + +Health check: + +```bash +curl localhost:3001/healthz # {"status":"ok"} +curl localhost:3001/readyz # {"status":"ready"} +``` + +### Configuration (env only) + +The bind host is always `127.0.0.1`: the daemon is a loopback-only sidecar +and binding any other interface would be a security regression, so the host +is intentionally not env-configurable. + +| Var | Default | Purpose | +|---|---|---| +| `AO_PORT` | `3001` | bind port; fails fast if taken | +| `AO_REQUEST_TIMEOUT` | `60s` | per-request timeout (Go duration) | +| `AO_SHUTDOWN_TIMEOUT` | `10s` | graceful-shutdown hard cap | +| `AO_RUN_FILE` | `/agent-orchestrator/running.json` | PID + port handshake path | + +### Test + +```bash +cd backend +gofmt -l . && go build ./... && go vet ./... && go test -race ./... +``` + diff --git a/backend/go.mod b/backend/go.mod index 22a555cda5..311cea2887 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,5 @@ module github.com/aoagents/agent-orchestrator/backend go 1.22 + +require github.com/go-chi/chi/v5 v5.1.0 diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000000..823cdbb1ac --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,2 @@ +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000000..d6765dba28 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,140 @@ +// Package config loads the daemon's runtime configuration. The HTTP daemon is +// a loopback-only sidecar: it binds 127.0.0.1, takes no public traffic, and +// reads everything it needs from the environment with sane defaults so it can +// boot with zero configuration in development. +package config + +import ( + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "time" +) + +const ( + // LoopbackHost is the only host the daemon ever binds. There is deliberately + // no AO_HOST env var: the daemon has no auth/CORS/TLS and a stray + // AO_HOST=0.0.0.0 would turn it into a public no-auth service. The legacy + // TS server bound all-interfaces by accident and docs/CROSS_PLATFORM.md + // already calls that out as a bug; the Go rewrite fixes it by removing the + // knob entirely. If a non-default loopback (e.g. ::1, 127.0.0.2) is ever + // needed, add it back with an IsLoopback() validator — not a raw env read. + LoopbackHost = "127.0.0.1" + // DefaultPort is the single port the whole surface (REST, SSE, WS, static) + // is served from. Single-port keeps it same-origin: no CORS, one lifecycle. + DefaultPort = 3001 + // DefaultRequestTimeout bounds a single request. Long-lived surfaces (SSE, + // WS) are mounted outside this timeout; it guards the REST surface only. + DefaultRequestTimeout = 60 * time.Second + // DefaultShutdownTimeout is the hard cap on graceful shutdown. After this + // the process exits even if connections are still draining. + DefaultShutdownTimeout = 10 * time.Second +) + +// Config is the fully-resolved daemon configuration. It is immutable once +// built by Load. +type Config struct { + // Host is the bind address. Always loopback — see LoopbackHost. + Host string + // Port is the TCP port to bind. The daemon fails fast if it is taken. + Port int + // RequestTimeout bounds REST request handling. + RequestTimeout time.Duration + // ShutdownTimeout is the hard graceful-shutdown deadline. + ShutdownTimeout time.Duration + // RunFilePath is where the PID + port handshake file (running.json) is + // written so the Electron supervisor can discover and reap the daemon. + RunFilePath string +} + +// Addr returns the host:port the HTTP server binds. It uses net.JoinHostPort so +// the result is correct for IPv6 literals as well as IPv4 / hostnames. +func (c Config) Addr() string { + return net.JoinHostPort(c.Host, strconv.Itoa(c.Port)) +} + +// Load resolves configuration from the environment, applying defaults. It +// returns an error only for values that are present but malformed (e.g. a +// non-numeric AO_PORT); missing values fall back to defaults. +// +// Recognised variables: +// +// AO_PORT bind port (default 3001) +// AO_REQUEST_TIMEOUT per-request timeout (Go duration > 0, default 60s) +// AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s) +// AO_RUN_FILE running.json path (default /running.json) +// +// The bind host is not configurable: the daemon is loopback-only by design. +func Load() (Config, error) { + cfg := Config{ + Host: LoopbackHost, + Port: DefaultPort, + RequestTimeout: DefaultRequestTimeout, + ShutdownTimeout: DefaultShutdownTimeout, + } + + if raw := os.Getenv("AO_PORT"); raw != "" { + port, err := strconv.Atoi(raw) + if err != nil { + return Config{}, fmt.Errorf("invalid AO_PORT %q: %w", raw, err) + } + if port < 1 || port > 65535 { + return Config{}, fmt.Errorf("invalid AO_PORT %d: out of range 1-65535", port) + } + cfg.Port = port + } + + if raw := os.Getenv("AO_REQUEST_TIMEOUT"); raw != "" { + d, err := parsePositiveDuration("AO_REQUEST_TIMEOUT", raw) + if err != nil { + return Config{}, err + } + cfg.RequestTimeout = d + } + + if raw := os.Getenv("AO_SHUTDOWN_TIMEOUT"); raw != "" { + d, err := parsePositiveDuration("AO_SHUTDOWN_TIMEOUT", raw) + if err != nil { + return Config{}, err + } + cfg.ShutdownTimeout = d + } + + runFile, err := resolveRunFilePath() + if err != nil { + return Config{}, err + } + cfg.RunFilePath = runFile + + return cfg, nil +} + +// parsePositiveDuration rejects zero and negative durations: a zero +// RequestTimeout would expire every request instantly, and a non-positive +// ShutdownTimeout would defeat graceful shutdown. +func parsePositiveDuration(name, raw string) (time.Duration, error) { + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("invalid %s %q: %w", name, raw, err) + } + if d <= 0 { + return 0, fmt.Errorf("invalid %s %q: must be > 0", name, raw) + } + return d, nil +} + +// resolveRunFilePath picks where running.json lives. An explicit AO_RUN_FILE +// wins; otherwise it sits under the per-user state directory so multiple repos +// share one supervisor handshake location. +func resolveRunFilePath() (string, error) { + if p, ok := os.LookupEnv("AO_RUN_FILE"); ok && p != "" { + return p, nil + } + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve state dir: %w", err) + } + return filepath.Join(dir, "agent-orchestrator", "running.json"), nil +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000000..dfcb5b8af7 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,84 @@ +package config + +import ( + "testing" + "time" +) + +func TestLoadDefaults(t *testing.T) { + // Clear every recognised var so we observe pure defaults regardless of the + // surrounding environment. + for _, k := range []string{"AO_PORT", "AO_REQUEST_TIMEOUT", "AO_SHUTDOWN_TIMEOUT", "AO_RUN_FILE"} { + t.Setenv(k, "") + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Host != LoopbackHost { + t.Errorf("Host = %q, want %q", cfg.Host, LoopbackHost) + } + if cfg.Port != DefaultPort { + t.Errorf("Port = %d, want %d", cfg.Port, DefaultPort) + } + if cfg.RequestTimeout != DefaultRequestTimeout { + t.Errorf("RequestTimeout = %s, want %s", cfg.RequestTimeout, DefaultRequestTimeout) + } + if cfg.ShutdownTimeout != DefaultShutdownTimeout { + t.Errorf("ShutdownTimeout = %s, want %s", cfg.ShutdownTimeout, DefaultShutdownTimeout) + } + if cfg.RunFilePath == "" { + t.Error("RunFilePath is empty, want a resolved default path") + } +} + +func TestLoadOverrides(t *testing.T) { + t.Setenv("AO_PORT", "4002") + t.Setenv("AO_REQUEST_TIMEOUT", "5s") + t.Setenv("AO_SHUTDOWN_TIMEOUT", "3s") + t.Setenv("AO_RUN_FILE", "/tmp/ao-test-running.json") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Addr() != "127.0.0.1:4002" { + t.Errorf("Addr() = %q, want 127.0.0.1:4002", cfg.Addr()) + } + if cfg.RequestTimeout != 5*time.Second { + t.Errorf("RequestTimeout = %s, want 5s", cfg.RequestTimeout) + } + if cfg.ShutdownTimeout != 3*time.Second { + t.Errorf("ShutdownTimeout = %s, want 3s", cfg.ShutdownTimeout) + } + if cfg.RunFilePath != "/tmp/ao-test-running.json" { + t.Errorf("RunFilePath = %q, want /tmp/ao-test-running.json", cfg.RunFilePath) + } +} + +func TestLoadInvalid(t *testing.T) { + tests := []struct { + name string + env map[string]string + }{ + {"non-numeric port", map[string]string{"AO_PORT": "abc"}}, + {"port out of range", map[string]string{"AO_PORT": "70000"}}, + {"bad request timeout", map[string]string{"AO_REQUEST_TIMEOUT": "soon"}}, + {"bad shutdown timeout", map[string]string{"AO_SHUTDOWN_TIMEOUT": "later"}}, + {"zero request timeout", map[string]string{"AO_REQUEST_TIMEOUT": "0s"}}, + {"negative request timeout", map[string]string{"AO_REQUEST_TIMEOUT": "-1s"}}, + {"zero shutdown timeout", map[string]string{"AO_SHUTDOWN_TIMEOUT": "0s"}}, + {"negative shutdown timeout", map[string]string{"AO_SHUTDOWN_TIMEOUT": "-5s"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + if _, err := Load(); err == nil { + t.Fatal("Load() = nil error, want error") + } + }) + } +} diff --git a/backend/internal/httpd/json.go b/backend/internal/httpd/json.go new file mode 100644 index 0000000000..9b87461fbc --- /dev/null +++ b/backend/internal/httpd/json.go @@ -0,0 +1,17 @@ +package httpd + +import ( + "encoding/json" + "net/http" +) + +// writeJSON serialises v as JSON with the given status. It is the single JSON +// writer for the skeleton; the typed error envelope (open item Q1.3) will build +// on this in a later phase. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + // A write error here means the client went away mid-response; there is + // nothing useful to do but stop. + _ = json.NewEncoder(w).Encode(v) +} diff --git a/backend/internal/httpd/log.go b/backend/internal/httpd/log.go new file mode 100644 index 0000000000..abb9462ec2 --- /dev/null +++ b/backend/internal/httpd/log.go @@ -0,0 +1,40 @@ +package httpd + +import ( + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +// requestLogger emits one structured access-log line per request via the +// daemon's slog logger. Chi's built-in middleware.Logger writes to stdout +// using stdlib log; reusing the daemon's slog keeps every line on stderr in +// the same key=value shape as the rest of the daemon (one stream for the +// Electron supervisor to capture, one format to grep). +// +// Status, bytes, and duration come from a wrapped ResponseWriter so the log +// is accurate even when the handler returns without calling WriteHeader. The +// request id is read off the context populated by middleware.RequestID, so +// this middleware must be mounted after it. +func requestLogger(log *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + start := time.Now() + defer func() { + log.Info("http request", + "id", middleware.GetReqID(r.Context()), + "method", r.Method, + "path", r.URL.Path, + "status", ww.Status(), + "bytes", ww.BytesWritten(), + "duration", time.Since(start), + "remote", r.RemoteAddr, + ) + }() + next.ServeHTTP(ww, r) + }) + } +} diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go new file mode 100644 index 0000000000..6e078b8df0 --- /dev/null +++ b/backend/internal/httpd/router.go @@ -0,0 +1,63 @@ +// Package httpd builds and runs the daemon's HTTP surface. Phase 1a is the +// skeleton: the middleware stack, liveness/readiness probes, and a graceful +// run loop. Route registration (/api/v1, /events, /mux, /) lands in later +// phases on top of the router this package builds. +package httpd + +import ( + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" +) + +// NewRouter builds the root router with the standard middleware stack and the +// health probes mounted. +// +// Middleware order (outermost first): +// +// Recoverer → turn a handler panic into 500 instead of crashing the daemon +// RequestID → attach a request id for correlation +// requestLogger → slog-backed access log, stderr, carries the request id +// RealIP → normalise client IP (loopback proxy from the dev server) +// +// The per-request Timeout from the decision table is deliberately NOT applied +// globally: it must wrap only the /api/v1 REST surface, never the long-lived +// SSE (/events) or WebSocket (/mux) surfaces, nor the always-must-answer health +// probes. It is therefore applied per-surface when those subrouters are mounted +// in Phase 1b; cfg.RequestTimeout carries the value through to that point. +func NewRouter(cfg config.Config, log *slog.Logger) chi.Router { + r := chi.NewRouter() + + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + r.Use(requestLogger(log)) + r.Use(middleware.RealIP) + + mountHealth(r) + + return r +} + +// mountHealth registers the liveness and readiness probes the Electron +// supervisor polls before letting the renderer connect. +func mountHealth(r chi.Router) { + r.Get("/healthz", handleHealthz) + r.Get("/readyz", handleReadyz) +} + +// handleHealthz is the liveness probe: it answers 200 as long as the process is +// up and serving. It does no dependency checks by design. +func handleHealthz(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleReadyz is the readiness probe. In the 1a skeleton the daemon is ready +// as soon as it is listening; later phases will gate this on dependency +// initialisation (e.g. store/event-bus warm-up). +func handleReadyz(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) +} diff --git a/backend/internal/httpd/server.go b/backend/internal/httpd/server.go new file mode 100644 index 0000000000..f42ed88aa8 --- /dev/null +++ b/backend/internal/httpd/server.go @@ -0,0 +1,113 @@ +package httpd + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" +) + +// Server is the daemon's HTTP server together with its lifecycle: bind the +// loopback port, publish the running.json handshake, serve until the context +// is cancelled, then shut down gracefully and clean up the handshake file. +type Server struct { + cfg config.Config + log *slog.Logger + http *http.Server + listen net.Listener +} + +// New constructs a Server and binds the listener immediately so a port +// conflict fails fast — before any running.json is written. The caller owns +// the returned Server's lifecycle via Run. +func New(cfg config.Config, log *slog.Logger) (*Server, error) { + ln, err := net.Listen("tcp", cfg.Addr()) + if err != nil { + return nil, fmt.Errorf("bind %s (is a daemon already running?): %w", cfg.Addr(), err) + } + + srv := &Server{ + cfg: cfg, + log: log, + listen: ln, + http: &http.Server{ + Handler: NewRouter(cfg, log), + // ReadHeaderTimeout guards against slow-loris even on loopback; + // per-request body/handler timeouts are applied per-surface. + ReadHeaderTimeout: 10 * time.Second, + }, + } + return srv, nil +} + +// Addr returns the actual bound address (useful when the configured port was 0 +// and the OS chose one — primarily in tests). +func (s *Server) Addr() net.Addr { return s.listen.Addr() } + +// Run serves until ctx is cancelled (SIGINT/SIGTERM via signal.NotifyContext), +// then performs a graceful shutdown bounded by cfg.ShutdownTimeout. It writes +// running.json before serving and removes it on the way out. Run blocks until +// shutdown is complete. +func (s *Server) Run(ctx context.Context) error { + info := runfile.Info{ + PID: os.Getpid(), + Port: s.boundPort(), + StartedAt: time.Now().UTC(), + } + if err := runfile.Write(s.cfg.RunFilePath, info); err != nil { + s.listen.Close() + return fmt.Errorf("write run-file: %w", err) + } + defer func() { + if err := runfile.Remove(s.cfg.RunFilePath); err != nil { + s.log.Warn("failed to remove run-file", "path", s.cfg.RunFilePath, "err", err) + } + }() + + serveErr := make(chan error, 1) + go func() { + s.log.Info("daemon listening", "addr", s.Addr().String(), "pid", info.PID) + // Serve returns ErrServerClosed on a clean Shutdown; that is success. + if err := s.http.Serve(s.listen); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErr <- err + return + } + serveErr <- nil + }() + + select { + case err := <-serveErr: + // Serve died on its own (bind already happened, so this is a real + // runtime failure) before any shutdown signal. + return err + case <-ctx.Done(): + s.log.Info("shutdown signal received, draining connections", "timeout", s.cfg.ShutdownTimeout) + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), s.cfg.ShutdownTimeout) + defer cancel() + + if err := s.http.Shutdown(shutdownCtx); err != nil { + // The deadline elapsed with connections still open; force them closed. + s.log.Warn("graceful shutdown timed out, forcing close", "err", err) + _ = s.http.Close() + return fmt.Errorf("graceful shutdown exceeded %s: %w", s.cfg.ShutdownTimeout, err) + } + + s.log.Info("daemon stopped cleanly") + return <-serveErr +} + +func (s *Server) boundPort() int { + if tcp, ok := s.listen.Addr().(*net.TCPAddr); ok { + return tcp.Port + } + return s.cfg.Port +} diff --git a/backend/internal/httpd/server_test.go b/backend/internal/httpd/server_test.go new file mode 100644 index 0000000000..2570397fca --- /dev/null +++ b/backend/internal/httpd/server_test.go @@ -0,0 +1,130 @@ +package httpd + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestHealthProbes(t *testing.T) { + router := NewRouter(config.Config{}, discardLogger()) + srv := httptest.NewServer(router) + defer srv.Close() + + client := &http.Client{Timeout: 2 * time.Second} + for _, path := range []string{"/healthz", "/readyz"} { + resp, err := client.Get(srv.URL + path) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("GET %s = %d, want 200", path, resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "application/json; charset=utf-8" { + t.Errorf("GET %s Content-Type = %q, want JSON", path, ct) + } + } +} + +// TestServerLifecycle exercises the full Run loop: bind an ephemeral port, +// publish running.json, serve a request, then cancel the context and confirm a +// clean shutdown that removes the handshake file. +func TestServerLifecycle(t *testing.T) { + runPath := filepath.Join(t.TempDir(), "running.json") + cfg := config.Config{ + Host: "127.0.0.1", + Port: 0, // let the OS pick a free port — no conflict with a real daemon + ShutdownTimeout: 5 * time.Second, + RunFilePath: runPath, + } + + srv, err := New(cfg, discardLogger()) + if err != nil { + t.Fatalf("New: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + runErr := make(chan error, 1) + go func() { runErr <- srv.Run(ctx) }() + + // Wait for the handshake file to confirm the server is up. + base := "http://" + srv.Addr().String() + waitForHealth(t, base) + + info, err := runfile.Read(runPath) + if err != nil { + t.Fatalf("read run-file: %v", err) + } + if info == nil { + t.Fatal("run-file not written while server running") + } + if info.Port == 0 { + t.Error("run-file recorded port 0; want the actual bound port") + } + + cancel() + + select { + case err := <-runErr: + if err != nil { + t.Fatalf("Run returned error on graceful shutdown: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("Run did not return after context cancel") + } + + if after, _ := runfile.Read(runPath); after != nil { + t.Error("run-file still present after shutdown; want it removed") + } +} + +func waitForHealth(t *testing.T, base string) { + t.Helper() + // Per-request timeout so a stalled connect or hung handshake doesn't park + // the test for the full Go test timeout; the outer deadline only bounds + // the polling loop, not any single GET. + client := &http.Client{Timeout: 500 * time.Millisecond} + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + resp, err := client.Get(base + "/healthz") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return + } + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("server did not become healthy within timeout") +} + +// TestNewFailsOnPortConflict confirms a second bind of the same port fails +// fast rather than silently sharing it. +func TestNewFailsOnPortConflict(t *testing.T) { + cfg := config.Config{Host: "127.0.0.1", Port: 0, RunFilePath: filepath.Join(t.TempDir(), "r.json")} + + first, err := New(cfg, discardLogger()) + if err != nil { + t.Fatalf("first New: %v", err) + } + defer first.listen.Close() + + // Re-bind the exact port the first server took. + conflict := config.Config{Host: "127.0.0.1", Port: first.boundPort(), RunFilePath: cfg.RunFilePath} + if _, err := New(conflict, discardLogger()); err == nil { + t.Fatal("New on an already-bound port = nil error, want bind failure") + } +} diff --git a/backend/internal/runfile/process_unix.go b/backend/internal/runfile/process_unix.go new file mode 100644 index 0000000000..efe957e182 --- /dev/null +++ b/backend/internal/runfile/process_unix.go @@ -0,0 +1,24 @@ +//go:build unix + +package runfile + +import ( + "errors" + "os" + "syscall" +) + +// processAlive probes existence with signal 0: kill(pid, 0) returns nil if the +// process exists and we can signal it, EPERM if it exists but is owned by +// another user, and ESRCH (or any other error from FindProcess) if it is gone. +func processAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + err = proc.Signal(syscall.Signal(0)) + if err == nil { + return true + } + return errors.Is(err, syscall.EPERM) +} diff --git a/backend/internal/runfile/process_windows.go b/backend/internal/runfile/process_windows.go new file mode 100644 index 0000000000..1f8e78fee0 --- /dev/null +++ b/backend/internal/runfile/process_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package runfile + +import ( + "syscall" +) + +// processAlive opens the process with the minimum-rights query flag. On +// Windows, OpenProcess returns ERROR_INVALID_PARAMETER for a PID that no +// longer maps to a live process, and a usable handle when one is. We close +// the handle immediately; the only thing we needed was the open's outcome. +func processAlive(pid int) bool { + const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + h, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + _ = syscall.CloseHandle(h) + return true +} diff --git a/backend/internal/runfile/rename_unix.go b/backend/internal/runfile/rename_unix.go new file mode 100644 index 0000000000..dd9dbd5035 --- /dev/null +++ b/backend/internal/runfile/rename_unix.go @@ -0,0 +1,13 @@ +//go:build unix + +package runfile + +import "os" + +// atomicReplace renames src to dst, replacing dst if it exists. POSIX +// rename(2) is atomic and overwrites an existing destination by default, +// provided src and dst live on the same filesystem — which is always true +// here because the temp file is created in the target directory. +func atomicReplace(src, dst string) error { + return os.Rename(src, dst) +} diff --git a/backend/internal/runfile/rename_windows.go b/backend/internal/runfile/rename_windows.go new file mode 100644 index 0000000000..031411eeed --- /dev/null +++ b/backend/internal/runfile/rename_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package runfile + +import "syscall" + +// movefileReplaceExisting tells MoveFileEx to overwrite dst if it already +// exists. Mirrors MOVEFILE_REPLACE_EXISTING from the Win32 API; declared +// locally so we don't pull in golang.org/x/sys for a single constant. +const movefileReplaceExisting = 0x1 + +// atomicReplace renames src to dst, replacing dst if it exists. Go's +// os.Rename on Windows happens to do the same MoveFileEx call internally, +// but calling it directly makes the cross-platform contract explicit instead +// of leaning on a runtime implementation detail. The replace is atomic +// against concurrent readers — readers see either the old or the new file, +// never an empty or partially-written one. +func atomicReplace(src, dst string) error { + srcPtr, err := syscall.UTF16PtrFromString(src) + if err != nil { + return err + } + dstPtr, err := syscall.UTF16PtrFromString(dst) + if err != nil { + return err + } + return syscall.MoveFileEx(srcPtr, dstPtr, movefileReplaceExisting) +} diff --git a/backend/internal/runfile/runfile.go b/backend/internal/runfile/runfile.go new file mode 100644 index 0000000000..7dafe1befe --- /dev/null +++ b/backend/internal/runfile/runfile.go @@ -0,0 +1,111 @@ +// Package runfile manages running.json — the PID + port handshake the Electron +// main process uses to discover, health-check, and reap the daemon. The daemon +// writes it on startup and removes it on graceful shutdown. On startup the +// daemon also checks for a stale entry left by a crashed predecessor so it can +// fail fast instead of fighting over the port. +package runfile + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// Info is the on-disk handshake payload. +type Info struct { + // PID is the daemon process id. + PID int `json:"pid"` + // Port is the loopback port the daemon bound. + Port int `json:"port"` + // StartedAt is when the daemon came up (RFC 3339). + StartedAt time.Time `json:"startedAt"` +} + +// Write atomically writes running.json at path, creating parent directories +// as needed. It writes to a temp file in the same directory and then calls +// atomicReplace — POSIX rename(2) on Unix, MoveFileEx with +// MOVEFILE_REPLACE_EXISTING on Windows — so a reader never observes a +// partial file and a stale running.json from a crashed predecessor is +// overwritten without an intermediate "no file" window. +func Write(path string, info Info) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create run-file dir: %w", err) + } + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return fmt.Errorf("marshal run-file: %w", err) + } + data = append(data, '\n') + + tmp, err := os.CreateTemp(filepath.Dir(path), ".running-*.json") + if err != nil { + return fmt.Errorf("create temp run-file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op once the rename succeeds + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temp run-file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp run-file: %w", err) + } + if err := atomicReplace(tmpName, path); err != nil { + return fmt.Errorf("replace run-file: %w", err) + } + return nil +} + +// Read loads running.json. A missing file returns (nil, nil) — that is the +// normal "no daemon recorded" state, not an error. +func Read(path string) (*Info, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read run-file: %w", err) + } + var info Info + if err := json.Unmarshal(data, &info); err != nil { + return nil, fmt.Errorf("parse run-file: %w", err) + } + return &info, nil +} + +// Remove deletes running.json. A missing file is not an error — graceful +// shutdown should be idempotent. +func Remove(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove run-file: %w", err) + } + return nil +} + +// CheckStale inspects an existing run-file before the new daemon binds. It +// returns: +// +// - (nil, nil) no run-file, or one left by a dead process (safe to +// proceed; the caller should overwrite it); +// - (*Info, nil) a run-file whose recorded PID is still alive — a live +// daemon already owns the port, so the caller should fail fast. +// +// A run-file pointing at a dead PID is treated as stale and reported safe; the +// fresh Write will overwrite it. +func CheckStale(path string) (*Info, error) { + info, err := Read(path) + if err != nil { + return nil, err + } + if info == nil || info.PID <= 0 { + return nil, nil + } + if processAlive(info.PID) { + return info, nil + } + return nil, nil +} diff --git a/backend/internal/runfile/runfile_test.go b/backend/internal/runfile/runfile_test.go new file mode 100644 index 0000000000..fbdf74e07f --- /dev/null +++ b/backend/internal/runfile/runfile_test.go @@ -0,0 +1,119 @@ +package runfile + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestWriteReadRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "running.json") + want := Info{PID: 4242, Port: 3001, StartedAt: time.Now().UTC().Truncate(time.Second)} + + if err := Write(path, want); err != nil { + t.Fatalf("Write: %v", err) + } + got, err := Read(path) + if err != nil { + t.Fatalf("Read: %v", err) + } + if got == nil { + t.Fatal("Read returned nil for an existing file") + } + if got.PID != want.PID || got.Port != want.Port || !got.StartedAt.Equal(want.StartedAt) { + t.Errorf("round trip mismatch: got %+v, want %+v", *got, want) + } +} + +// TestWriteOverwritesExisting is the cross-platform overwrite check: a stale +// running.json from a crashed predecessor must be replaced cleanly. POSIX +// rename(2) handles this natively; Windows needs MoveFileEx with +// MOVEFILE_REPLACE_EXISTING — atomicReplace gives us both. +func TestWriteOverwritesExisting(t *testing.T) { + path := filepath.Join(t.TempDir(), "running.json") + + if err := Write(path, Info{PID: 1, Port: 3001}); err != nil { + t.Fatalf("first Write: %v", err) + } + if err := Write(path, Info{PID: 2, Port: 3002}); err != nil { + t.Fatalf("second Write (overwrite): %v", err) + } + + got, err := Read(path) + if err != nil { + t.Fatalf("Read: %v", err) + } + if got == nil || got.PID != 2 || got.Port != 3002 { + t.Errorf("after overwrite: got %+v, want PID=2 Port=3002", got) + } +} + +func TestReadMissingIsNotError(t *testing.T) { + got, err := Read(filepath.Join(t.TempDir(), "absent.json")) + if err != nil { + t.Fatalf("Read missing: %v", err) + } + if got != nil { + t.Errorf("Read missing = %+v, want nil", got) + } +} + +func TestRemoveIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "running.json") + if err := Remove(path); err != nil { + t.Errorf("Remove on missing file: %v", err) + } + if err := Write(path, Info{PID: 1, Port: 2}); err != nil { + t.Fatalf("Write: %v", err) + } + if err := Remove(path); err != nil { + t.Errorf("Remove existing: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("file still present after Remove") + } +} + +func TestCheckStaleDeadPID(t *testing.T) { + path := filepath.Join(t.TempDir(), "running.json") + // PID 0x7FFFFFFF is effectively guaranteed not to exist. + if err := Write(path, Info{PID: 0x7FFFFFFF, Port: 3001}); err != nil { + t.Fatalf("Write: %v", err) + } + live, err := CheckStale(path) + if err != nil { + t.Fatalf("CheckStale: %v", err) + } + if live != nil { + t.Errorf("CheckStale on dead PID = %+v, want nil (stale, safe to overwrite)", live) + } +} + +func TestCheckStaleLivePID(t *testing.T) { + path := filepath.Join(t.TempDir(), "running.json") + // This test process is unquestionably alive. + if err := Write(path, Info{PID: os.Getpid(), Port: 3001}); err != nil { + t.Fatalf("Write: %v", err) + } + live, err := CheckStale(path) + if err != nil { + t.Fatalf("CheckStale: %v", err) + } + if live == nil { + t.Fatal("CheckStale on live PID = nil, want the live Info") + } + if live.PID != os.Getpid() { + t.Errorf("live.PID = %d, want %d", live.PID, os.Getpid()) + } +} + +func TestCheckStaleNoFile(t *testing.T) { + live, err := CheckStale(filepath.Join(t.TempDir(), "absent.json")) + if err != nil { + t.Fatalf("CheckStale: %v", err) + } + if live != nil { + t.Errorf("CheckStale with no file = %+v, want nil", live) + } +} diff --git a/backend/main.go b/backend/main.go index 30a6e84c6a..78a232927b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,7 +1,62 @@ +// Command backend is the Agent Orchestrator HTTP daemon: a loopback-only +// sidecar spawned and supervised by the Electron main process. Phase 1a brings +// up the server skeleton — config, 127.0.0.1 bind, middleware stack, health +// probes, the running.json handshake, and graceful shutdown. package main -import "fmt" +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" +) func main() { - fmt.Println("ao backend daemon starting") + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "ao backend daemon: "+err.Error()) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + + log := newLogger() + + // Fail fast if a live daemon already owns the handshake file. A run-file + // left by a crashed predecessor (dead PID) is treated as stale and + // overwritten when the new server starts. + if live, err := runfile.CheckStale(cfg.RunFilePath); err != nil { + return fmt.Errorf("inspect run-file: %w", err) + } else if live != nil { + return fmt.Errorf("daemon already running (pid %d, port %d); refusing to start", live.PID, live.Port) + } + + srv, err := httpd.New(cfg, log) + if err != nil { + return err + } + + // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the + // graceful shutdown inside Server.Run. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + return srv.Run(ctx) +} + +// newLogger returns the daemon's slog logger. It writes to stderr so the +// Electron supervisor can capture it separately from any structured stdout +// protocol added later. +func newLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) } From fb7dbbbb420e088f02064f855c98786a4a2bf90d Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 29 May 2026 15:43:58 +0530 Subject: [PATCH 039/250] fix: guard spawn initiation and restore metadata --- backend/internal/lifecycle/manager.go | 6 +++++- backend/internal/lifecycle/manager_test.go | 19 +++++++++++++++++++ backend/internal/session/manager.go | 6 ++++++ backend/internal/session/manager_test.go | 17 +++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index c8e74f2ce9..7494e346f9 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -292,7 +292,11 @@ func (m *Manager) OnSpawnInitiated(ctx context.Context, rec domain.SessionRecord if current, ok, err := m.store.Get(ctx, rec.ID); err != nil { return err } else if ok { - cur = current.Lifecycle + currentLC := current.Lifecycle + if !isTerminal(currentLC.Session.State) && !isTerminal(rec.Lifecycle.Session.State) { + return fmt.Errorf("lifecycle: OnSpawnInitiated for active session %q", rec.ID) + } + cur = currentLC } rec.Lifecycle = m.prepareLifecycleWrite(cur, rec.Lifecycle) now := m.clock() diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index c5f75ad0fb..fb7bf84d5b 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -392,6 +392,25 @@ func TestOnSpawnCompleted(t *testing.T) { } } +func TestOnSpawnInitiated_ActiveSessionRejected(t *testing.T) { + mgr, store := newManager() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + + err := mgr.OnSpawnInitiated(context.Background(), domain.SessionRecord{ + ID: sid, + ProjectID: domain.ProjectID("proj"), + Lifecycle: lc(domain.SessionNotStarted, domain.ReasonSpawnRequested, domain.RuntimeUnknown), + }) + if err == nil { + t.Fatal("OnSpawnInitiated should reject a non-terminal row on top of an active session") + } + + got := mustLoad(t, store) + if got.Session.State != domain.SessionWorking || got.Revision != 0 { + t.Fatalf("active row should be unchanged, got %+v", got) + } +} + func TestOnKillRequested(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index 6ae2a60e98..c7ccf33963 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -320,9 +320,15 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess } if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { m.rollbackRuntime(ctx, handle) + // Re-upsert the original record to undo the reopen. if revertErr := m.lcm.OnSpawnInitiated(ctx, rec); revertErr != nil { return domain.Session{}, fmt.Errorf("restore %s: revert after spawn completed failure: %w (original error: %v)", id, revertErr, err) } + if len(rec.Metadata) > 0 { + if revertErr := m.store.PatchMetadata(ctx, id, rec.Metadata); revertErr != nil { + return domain.Session{}, fmt.Errorf("restore %s: revert metadata after spawn completed failure: %w (original error: %v)", id, revertErr, err) + } + } return domain.Session{}, fmt.Errorf("restore %s: on spawn completed: %w", id, err) } return m.Get(ctx, id) diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index a0812456da..b54cf4b090 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -473,6 +473,7 @@ func TestRestore_OnSpawnCompletedFailure_RollsBackRuntime(t *testing.T) { if err := h.store.PatchMetadata(ctx, "sess-1", map[string]string{lifecycle.MetaAgentSessionID: "agent-xyz"}); err != nil { t.Fatalf("patch metadata: %v", err) } + beforeMeta, _ := h.store.GetMetadata(ctx, "sess-1") // Fail the post-create LCM call; capture teardown counts just before restore. h.lcm.onSpawnErr = errors.New("lcm boom") @@ -491,6 +492,10 @@ func TestRestore_OnSpawnCompletedFailure_RollsBackRuntime(t *testing.T) { if rec.Lifecycle.Revision != before.Lifecycle.Revision+2 { t.Fatalf("restore failure should advance revision twice, got %d want %d", rec.Lifecycle.Revision, before.Lifecycle.Revision+2) } + afterMeta, _ := h.store.GetMetadata(ctx, "sess-1") + if !equalStringMap(afterMeta, beforeMeta) { + t.Fatalf("restore failure should restore metadata, got %+v want %+v", afterMeta, beforeMeta) + } // The runtime created during restore is torn back down so no process is // stranded; the workspace is left intact (it holds the agent's prior work). @@ -572,6 +577,18 @@ func equalStrings(a, b []string) bool { return true } +func equalStringMap(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + func contains(ids []domain.SessionID, id domain.SessionID) bool { for _, x := range ids { if x == id { From 1376b019332f45e3d9b619fde4eff42235c5be7e Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 29 May 2026 16:13:14 +0530 Subject: [PATCH 040/250] fix: align writer contract with schema-2 --- backend/internal/domain/lifecycle.go | 9 ++-- backend/internal/lifecycle/fakes_test.go | 14 +++++- backend/internal/lifecycle/manager.go | 54 ++++++++++++++++------ backend/internal/lifecycle/manager_test.go | 5 +- backend/internal/ports/outbound.go | 26 +++++++++-- backend/internal/session/fakes_test.go | 12 +++-- backend/internal/session/manager.go | 3 +- backend/internal/session/manager_test.go | 12 ++--- 8 files changed, 100 insertions(+), 35 deletions(-) diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go index ab22eed31d..fca87b6b84 100644 --- a/backend/internal/domain/lifecycle.go +++ b/backend/internal/domain/lifecycle.go @@ -20,10 +20,11 @@ const LifecycleVersion = 1 // between observations (they are read back by the pure decide core), so they // live in the persisted record too. type CanonicalSessionLifecycle struct { - // Version is the schema version of this record's shape (LifecycleVersion). - Version int `json:"version"` - // Revision is a monotonic counter the LCM bumps on every full-row Upsert and - // is distinct from the schema Version above. + // Version is the Go-only schema-shape constant for this record. It is not + // persisted and is not part of the CDC payload. + Version int + // Revision is the per-write monotonic counter. The storage layer's Upsert + // bumps it when the full row is persisted; the LCM does not. Revision int `json:"revision"` Session SessionSubstate `json:"session"` PR PRSubstate `json:"pr"` diff --git a/backend/internal/lifecycle/fakes_test.go b/backend/internal/lifecycle/fakes_test.go index af7c06559e..5bacb49a6f 100644 --- a/backend/internal/lifecycle/fakes_test.go +++ b/backend/internal/lifecycle/fakes_test.go @@ -2,6 +2,7 @@ package lifecycle import ( "context" + "fmt" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -45,9 +46,20 @@ func (s *fakeStore) Load(_ context.Context, id domain.SessionID) (domain.Canonic return rec.Lifecycle, true, nil } -func (s *fakeStore) Upsert(_ context.Context, rec domain.SessionRecord) error { +func (s *fakeStore) Upsert(_ context.Context, rec domain.SessionRecord, _ ports.EventType) error { s.mu.Lock() defer s.mu.Unlock() + if existing, ok := s.records[rec.ID]; ok { + if rec.Lifecycle.Revision != existing.Lifecycle.Revision { + return fmt.Errorf("revision mismatch for %s: have %d, want %d", rec.ID, rec.Lifecycle.Revision, existing.Lifecycle.Revision) + } + rec.Lifecycle.Revision = existing.Lifecycle.Revision + 1 + } else { + if rec.Lifecycle.Revision != 0 { + return fmt.Errorf("revision mismatch for insert %s: have %d, want 0", rec.ID, rec.Lifecycle.Revision) + } + rec.Lifecycle.Revision = 1 + } if rec.Lifecycle.Version == 0 { rec.Lifecycle.Version = domain.LifecycleVersion } diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 7494e346f9..bedfb3b1fd 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -1,8 +1,9 @@ // Package lifecycle implements ports.LifecycleManager: the synchronous // observe->decide->persist reducer. Every Apply*/On* entrypoint runs the same // pipeline under a per-session lock — load the full canonical record, run the -// matching pure decider, diff into a full-row Upsert, persist. The LCM never -// polls and never writes the display status (that is derived on read). +// matching pure decider, classify the resulting change, and persist the full +// row through the store. The store owns Revision++; the LCM never polls and +// never writes the display status (that is derived on read). // // After a transition is persisted, the Apply* paths fire the mapped reaction // (the ACT layer: reaction table + escalation engine) via the react() chokepoint @@ -120,8 +121,8 @@ type transition struct { // mutate runs the shared pipeline: load full row -> build next canonical -> // Upsert full row (only if changed). decideFn returns the full next lifecycle -// and whether it changed anything; false is a clean no-op (no write, no revision -// bump), which is how failed-probe / unknown-fact inputs are dropped. +// and whether it changed anything; false is a clean no-op (no write), which is +// how failed-probe / unknown-fact inputs are dropped. // // On a write it returns the transition (before/after canonical) so the caller — // which still holds the originating facts — can fire the mapped reaction. @@ -144,9 +145,9 @@ func (m *Manager) mutate( if !changed { return nil } - rec.Lifecycle = m.prepareLifecycleWrite(cur, next) + rec.Lifecycle = m.prepareLifecycleWrite(next) rec.UpdatedAt = m.clock() - if err := m.store.Upsert(ctx, rec); err != nil { + if err := m.store.Upsert(ctx, rec, classifyEventType(cur, rec.Lifecycle, false)); err != nil { return err } tr = &transition{beforeLC: cur, afterLC: rec.Lifecycle} @@ -155,9 +156,8 @@ func (m *Manager) mutate( return tr, err } -func (m *Manager) prepareLifecycleWrite(cur, next domain.CanonicalSessionLifecycle) domain.CanonicalSessionLifecycle { +func (m *Manager) prepareLifecycleWrite(next domain.CanonicalSessionLifecycle) domain.CanonicalSessionLifecycle { next.Version = domain.LifecycleVersion - next.Revision = cur.Revision + 1 return next } @@ -285,10 +285,13 @@ func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, // OnSpawnInitiated seeds or reopens the full session record for a spawn-like // mutation. It is the Session Manager's create/reopen command under the Writer // contract: the SM builds the identity + initial canonical row, but only the LCM -// writes it. +// writes it. Fresh rows emit session_created; reopening a terminal row reuses +// the current row as the before-image and lets the classifier emit the schema +// event for the reopen. func (m *Manager) OnSpawnInitiated(ctx context.Context, rec domain.SessionRecord) error { return m.withLock(rec.ID, func() error { cur := rec.Lifecycle + isInsert := true if current, ok, err := m.store.Get(ctx, rec.ID); err != nil { return err } else if ok { @@ -297,14 +300,20 @@ func (m *Manager) OnSpawnInitiated(ctx context.Context, rec domain.SessionRecord return fmt.Errorf("lifecycle: OnSpawnInitiated for active session %q", rec.ID) } cur = currentLC + isInsert = false + } + rec.Lifecycle = m.prepareLifecycleWrite(rec.Lifecycle) + if isInsert { + rec.Lifecycle.Revision = 0 + } else { + rec.Lifecycle.Revision = cur.Revision } - rec.Lifecycle = m.prepareLifecycleWrite(cur, rec.Lifecycle) now := m.clock() if rec.CreatedAt.IsZero() { rec.CreatedAt = now } rec.UpdatedAt = now - return m.store.Upsert(ctx, rec) + return m.store.Upsert(ctx, rec, classifyEventType(cur, rec.Lifecycle, isInsert)) }) } @@ -329,9 +338,9 @@ func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o p cur := rec.Lifecycle next := cur next.Runtime = rt - rec.Lifecycle = m.prepareLifecycleWrite(cur, next) + rec.Lifecycle = m.prepareLifecycleWrite(next) rec.UpdatedAt = m.clock() - if err := m.store.Upsert(ctx, rec); err != nil { + if err := m.store.Upsert(ctx, rec, classifyEventType(cur, rec.Lifecycle, false)); err != nil { return err } } @@ -433,6 +442,25 @@ func setDetecting(next *domain.CanonicalSessionLifecycle, d *domain.DetectingSta return false } +func classifyEventType(before, after domain.CanonicalSessionLifecycle, isInsert bool) ports.EventType { + switch { + case isInsert: + return ports.EventSessionCreated + case before.Session.State != after.Session.State && after.Session.State == domain.SessionTerminated: + return ports.EventSessionTerminated + case before.Session != after.Session: + return ports.EventSessionStateChanged + case before.PR != after.PR: + return ports.EventSessionPRUpdated + case before.Runtime != after.Runtime: + return ports.EventSessionRuntimeUpdated + case before.Activity != after.Activity: + return ports.EventSessionActivityUpdated + default: + return ports.EventSessionUpdated + } +} + // sameActivity compares activity sub-states with time-aware equality (== on // time.Time is monotonic-clock sensitive and would spuriously report changes). func sameActivity(a, b domain.ActivitySubstate) bool { diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index fb7bf84d5b..e93a33c5da 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -483,7 +483,7 @@ func TestFakeStoreUpsertFullRow(t *testing.T) { } rec.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionIdle, Reason: domain.ReasonResearchComplete} rec.Lifecycle.Runtime = domain.RuntimeSubstate{State: domain.RuntimeExited} - if err := store.Upsert(context.Background(), rec); err != nil { + if err := store.Upsert(context.Background(), rec, ports.EventSessionStateChanged); err != nil { t.Fatalf("upsert: %v", err) } @@ -491,6 +491,9 @@ func TestFakeStoreUpsertFullRow(t *testing.T) { if got.Lifecycle.Session.State != domain.SessionIdle || got.Lifecycle.Runtime.State != domain.RuntimeExited { t.Fatalf("upsert should replace the full canonical row, got %+v", got.Lifecycle) } + if got.Lifecycle.Revision != 1 { + t.Fatalf("upsert should bump revision inside the store, got %d want 1", got.Lifecycle.Revision) + } } // ---- per-session serialisation under the race detector ---- diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index c5ee6bfac8..ba08d9b9ab 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -11,15 +11,16 @@ import ( // Writer contract: the Lifecycle Manager (LCM) is the sole logical writer of // sessions. Controllers, the Session Manager, observers, and other goroutines // must route mutations to the LCM; no other goroutine writes sessions directly. -// The LCM serializes mutations and calls Upsert with the full SessionRecord. -// This full-row insert-or-update replaces the older sparse merge-patch model and -// is safe only under the single-writer LCM invariant. +// The LCM serializes mutations and calls Upsert with the full SessionRecord and +// the classified event_type. The storage layer owns Revision++ and performs the +// full-row insert-or-update; the older sparse merge-patch model is gone. // // List/Get return persistence records (no derived status); the Session Manager // hydrates them into domain.Session by attaching DeriveLegacyStatus on read. type LifecycleStore interface { - // Upsert inserts or replaces the full session row. Only the LCM may call it. - Upsert(ctx context.Context, rec domain.SessionRecord) error + // Upsert inserts or replaces the full session row and bumps Revision inside + // the storage layer. Only the LCM may call it. + Upsert(ctx context.Context, rec domain.SessionRecord, eventType EventType) error Load(ctx context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) List(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) GetMetadata(ctx context.Context, id domain.SessionID) (map[string]string, error) @@ -31,6 +32,21 @@ type LifecycleStore interface { Get(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) } +// EventType is the schema-level event label attached to each Upsert. +type EventType string + +const ( + EventSessionCreated EventType = "session_created" + EventSessionTerminated EventType = "session_terminated" + EventSessionStateChanged EventType = "session_state_changed" + EventSessionPRUpdated EventType = "session_pr_updated" + EventSessionRuntimeUpdated EventType = "session_runtime_updated" + EventSessionAttentionUpdated EventType = "session_attention_updated" + EventSessionActivityUpdated EventType = "session_activity_updated" + EventSessionDisplayUpdated EventType = "session_display_updated" + EventSessionUpdated EventType = "session_updated" +) + // Notifier delivers events to the human (desktop/Slack later). Push, never pull. type Notifier interface { Notify(ctx context.Context, event OrchestratorEvent) error diff --git a/backend/internal/session/fakes_test.go b/backend/internal/session/fakes_test.go index ebeac65985..1796e509e0 100644 --- a/backend/internal/session/fakes_test.go +++ b/backend/internal/session/fakes_test.go @@ -59,14 +59,18 @@ func newFakeStore() *fakeStore { } } -func (s *fakeStore) Upsert(_ context.Context, rec domain.SessionRecord) error { +func (s *fakeStore) Upsert(_ context.Context, rec domain.SessionRecord, _ ports.EventType) error { s.mu.Lock() defer s.mu.Unlock() if existing, ok := s.records[rec.ID]; ok { - if rec.Lifecycle.Revision != existing.Lifecycle.Revision+1 { - return fmt.Errorf("revision mismatch for %s: have %d, want %d", rec.ID, rec.Lifecycle.Revision, existing.Lifecycle.Revision+1) + if rec.Lifecycle.Revision != existing.Lifecycle.Revision { + return fmt.Errorf("revision mismatch for %s: have %d, want %d", rec.ID, rec.Lifecycle.Revision, existing.Lifecycle.Revision) + } + rec.Lifecycle.Revision = existing.Lifecycle.Revision + 1 + } else { + if rec.Lifecycle.Revision != 0 { + return fmt.Errorf("revision mismatch for insert %s: have %d, want 0", rec.ID, rec.Lifecycle.Revision) } - } else if rec.Lifecycle.Revision == 0 { rec.Lifecycle.Revision = 1 } if rec.Lifecycle.Version == 0 { diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index c7ccf33963..4d9157398d 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -320,7 +320,8 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess } if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { m.rollbackRuntime(ctx, handle) - // Re-upsert the original record to undo the reopen. + // Re-upsert the original record to undo the reopen; the store will + // assign the next revision. if revertErr := m.lcm.OnSpawnInitiated(ctx, rec); revertErr != nil { return domain.Session{}, fmt.Errorf("restore %s: revert after spawn completed failure: %w (original error: %v)", id, revertErr, err) } diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index b54cf4b090..381fb57e06 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -128,7 +128,7 @@ func TestSpawn_ExistingSessionIDRejectedBeforeWork(t *testing.T) { ID: "sess-1", ProjectID: testProject, Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), - }); err != nil { + }, ports.EventSessionCreated); err != nil { t.Fatalf("seed existing row: %v", err) } @@ -246,7 +246,7 @@ func TestKill_IncompleteMetadata_RefusesTeardown(t *testing.T) { if err := h.store.Upsert(ctx, domain.SessionRecord{ ID: "sess-1", ProjectID: testProject, Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), - }); err != nil { + }, ports.EventSessionCreated); err != nil { t.Fatalf("upsert: %v", err) } @@ -270,7 +270,7 @@ func TestCleanup_IncompleteMetadata_Skipped(t *testing.T) { if err := h.store.Upsert(ctx, domain.SessionRecord{ ID: "orphan-1", ProjectID: testProject, Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), - }); err != nil { + }, ports.EventSessionCreated); err != nil { t.Fatalf("upsert: %v", err) } @@ -333,7 +333,7 @@ func TestListAndGet_DeriveStatus(t *testing.T) { h := newHarness("unused") ctx := context.Background() for _, c := range cases { - if err := h.store.Upsert(ctx, domain.SessionRecord{ID: domain.SessionID(c.name), ProjectID: testProject, Lifecycle: c.lc}); err != nil { + if err := h.store.Upsert(ctx, domain.SessionRecord{ID: domain.SessionID(c.name), ProjectID: testProject, Lifecycle: c.lc}, ports.EventSessionCreated); err != nil { t.Fatalf("upsert %s: %v", c.name, err) } } @@ -517,7 +517,7 @@ func TestCleanup_SkipsUncommittedWork(t *testing.T) { if err := h.store.Upsert(ctx, domain.SessionRecord{ ID: "live-1", ProjectID: testProject, Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), - }); err != nil { + }, ports.EventSessionCreated); err != nil { t.Fatalf("upsert live: %v", err) } // dirty-1's worktree still holds uncommitted work — Destroy refuses it. @@ -557,7 +557,7 @@ func seedTerminal(t *testing.T, h *harness, id domain.SessionID, wsPath string) if err := h.store.Upsert(ctx, domain.SessionRecord{ ID: id, ProjectID: testProject, Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), - }); err != nil { + }, ports.EventSessionCreated); err != nil { t.Fatalf("upsert %s: %v", id, err) } if err := h.store.PatchMetadata(ctx, id, map[string]string{lifecycle.MetaWorkspacePath: wsPath}); err != nil { From 650ecdf08a0a48c1d2c314e6cf93afc9e7032f9d Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 28 May 2026 19:49:56 +0530 Subject: [PATCH 041/250] fix: address LCM/SM review blockers R1, RA, R11, RB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four narrowly-scoped fixes against the LCM + Session Manager lane from an external review of the current backend state. R2 (failed-restore lifecycle stranding) is intentionally deferred to PR #15, which already closes it via the new OnSpawnInitiated path; R3 also stays on that PR. - R1 (BLOCKER): Manager.Spawn never persisted AgentSessionID, so Manager.Restore's hard-required metadata key was always missing and every restore failed. Persist the assembled launch prompt as MetaPrompt at spawn time and add a fresh-launch fallback to Restore that uses Agent.GetLaunchCommand with the seeded prompt when the captured agent session id is absent (the id-capture hook is a separate path that may never have run). Restore still fails fast when neither the id nor a prompt is on hand — there is nothing to relaunch from. - RA (BLOCKER): adapters/workspace/gitworktree/commands.go's worktreeRemoveForceArgs passed --force, which deletes uncommitted agent work. Renamed to worktreeRemoveArgs and dropped --force so the post-prune "still registered" guard in Workspace.Destroy surfaces the refusal to Manager.Cleanup, which routes the session to Skipped instead of destroying in-progress changes. - R11 (SHOULD-FIX): reactions.go's two Notifier.Notify call sites (executeReaction's notify and escalate) built OrchestratorEvent without ProjectID. Captured projectID on the transition (via a store.Get in mutate) and on reactionTracker (so TickEscalations can still populate it on duration-based escalations), and threaded it through executeReaction/sendToAgent/escalate. - RB (SHOULD-FIX): gitworktree.Workspace.managedPath used filepath.Join which cleans .. segments before validateManagedPath ran, so session=\"../other\" stayed inside managedRoot while breaking per-project isolation. validateConfig now rejects path separators and the . / .. components on ProjectID and SessionID at the source. go build ./..., go vet ./..., and go test -race ./... all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workspace/gitworktree/commands.go | 9 +- .../workspace/gitworktree/workspace.go | 23 +++- .../workspace/gitworktree/workspace_test.go | 66 +++++++++- backend/internal/lifecycle/manager.go | 23 +++- backend/internal/lifecycle/reactions.go | 36 ++++-- backend/internal/lifecycle/reactions_test.go | 117 ++++++++++++++++++ backend/internal/ports/facts.go | 6 + backend/internal/session/manager.go | 28 +++-- backend/internal/session/manager_test.go | 45 ++++++- 9 files changed, 322 insertions(+), 31 deletions(-) diff --git a/backend/internal/adapters/workspace/gitworktree/commands.go b/backend/internal/adapters/workspace/gitworktree/commands.go index 739616c94f..5a417dd7f8 100644 --- a/backend/internal/adapters/workspace/gitworktree/commands.go +++ b/backend/internal/adapters/workspace/gitworktree/commands.go @@ -16,8 +16,13 @@ func worktreeAddNewBranchArgs(repo, branch, path, baseRef string) []string { return []string{"-C", repo, "worktree", "add", "-b", branch, path, baseRef} } -func worktreeRemoveForceArgs(repo, path string) []string { - return []string{"-C", repo, "worktree", "remove", "--force", path} +// worktreeRemoveArgs intentionally omits --force: a dirty worktree (uncommitted +// agent work) MUST cause `git worktree remove` to fail, so the post-prune +// "still registered" check in Destroy surfaces the refusal to the Session +// Manager's Cleanup, which routes the session to Skipped rather than deleting +// the agent's in-progress changes. +func worktreeRemoveArgs(repo, path string) []string { + return []string{"-C", repo, "worktree", "remove", path} } func worktreePruneArgs(repo string) []string { diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index e90db12c9a..da6d2d8321 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -119,7 +119,7 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error if err != nil { return err } - _, removeErr := w.run(ctx, w.binary, worktreeRemoveForceArgs(repo, path)...) + _, removeErr := w.run(ctx, w.binary, worktreeRemoveArgs(repo, path)...) if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil { return fmt.Errorf("gitworktree: worktree prune: %w", err) } @@ -304,15 +304,36 @@ func validateConfig(cfg ports.WorkspaceConfig) error { if cfg.ProjectID == "" { return errors.New("gitworktree: project id is required") } + if err := validatePathComponent("project id", string(cfg.ProjectID)); err != nil { + return err + } if cfg.SessionID == "" { return errors.New("gitworktree: session id is required") } + if err := validatePathComponent("session id", string(cfg.SessionID)); err != nil { + return err + } if cfg.Branch == "" { return errors.New("gitworktree: branch is required") } return nil } +// validatePathComponent rejects id values that could escape the managed root +// once joined into a path. filepath.Join cleans `..` before validateManagedPath +// runs, so a session id of "../other" would otherwise resolve back inside +// managedRoot while breaking per-project isolation. Reject any path separator +// or the special `.`/`..` components at the source. +func validatePathComponent(name, value string) error { + if strings.ContainsAny(value, `/\`) { + return fmt.Errorf("%w: %s %q must not contain path separators", ErrUnsafePath, name, value) + } + if value == "." || value == ".." { + return fmt.Errorf("%w: %s %q must not be a path-traversal component", ErrUnsafePath, name, value) + } + return nil +} + func (w *Workspace) managedPath(project domain.ProjectID, session domain.SessionID) (string, error) { path := filepath.Join(w.managedRoot, string(project), string(session)) return w.validateManagedPath(path) diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_test.go index 7e56529de1..afa7872f41 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_test.go @@ -27,7 +27,10 @@ func TestCommandArgs(t *testing.T) { {"rev parse", revParseVerifyArgs(repo, "origin/main"), []string{"-C", repo, "rev-parse", "--verify", "--quiet", "origin/main"}}, {"add existing", chooseWorktreeAddArgs(repo, path, branch, "", true), []string{"-C", repo, "worktree", "add", path, branch}}, {"add new", chooseWorktreeAddArgs(repo, path, branch, "origin/main", false), []string{"-C", repo, "worktree", "add", "-b", branch, path, "origin/main"}}, - {"remove", worktreeRemoveForceArgs(repo, path), []string{"-C", repo, "worktree", "remove", "--force", path}}, + // No --force: a dirty worktree must cause `git worktree remove` to fail so + // the post-prune safety check surfaces the refusal instead of deleting + // uncommitted agent work (review item RA). + {"remove", worktreeRemoveArgs(repo, path), []string{"-C", repo, "worktree", "remove", path}}, {"prune", worktreePruneArgs(repo), []string{"-C", repo, "worktree", "prune"}}, {"list", worktreeListPorcelainArgs(repo), []string{"-C", repo, "worktree", "list", "--porcelain"}}, } @@ -126,6 +129,57 @@ func TestManagedPathSafety(t *testing.T) { } } +// TestValidateConfigRejectsPathEscapingIDs covers review item RB: filepath.Join +// in managedPath cleans `..` segments before validateManagedPath sees them, so a +// session id of "../other" would stay inside managedRoot while jumping projects. +// validateConfig must reject these at the source — before any path is composed. +func TestValidateConfigRejectsPathEscapingIDs(t *testing.T) { + root := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": root}}) + if err != nil { + t.Fatalf("new: %v", err) + } + cases := []struct { + name string + cfg ports.WorkspaceConfig + }{ + {"session contains slash escapes project root", ports.WorkspaceConfig{ProjectID: "proj", SessionID: "../other", Branch: "main"}}, + {"session is .. is rejected", ports.WorkspaceConfig{ProjectID: "proj", SessionID: "..", Branch: "main"}}, + {"session is . is rejected", ports.WorkspaceConfig{ProjectID: "proj", SessionID: ".", Branch: "main"}}, + {"session contains backslash is rejected", ports.WorkspaceConfig{ProjectID: "proj", SessionID: `evil\sess`, Branch: "main"}}, + {"project contains slash escapes managed root", ports.WorkspaceConfig{ProjectID: "../proj", SessionID: "sess", Branch: "main"}}, + {"project is .. is rejected", ports.WorkspaceConfig{ProjectID: "..", SessionID: "sess", Branch: "main"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Create rejects it directly through validateConfig. + if _, err := ws.Create(context.Background(), tc.cfg); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("Create err = %v, want ErrUnsafePath", err) + } + // Restore also goes through validateConfig, so the same guarantee holds. + if _, err := ws.Restore(context.Background(), tc.cfg); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("Restore err = %v, want ErrUnsafePath", err) + } + }) + } +} + +// TestValidateConfigAcceptsBenignIDs is a positive guard so the rejection rule +// above does not creep into normal session/project naming. Hyphens, underscores, +// dots inside (e.g. "foo.bar"), and digits all stay allowed. +func TestValidateConfigAcceptsBenignIDs(t *testing.T) { + cases := []ports.WorkspaceConfig{ + {ProjectID: "proj-1", SessionID: "sess_2", Branch: "main"}, + {ProjectID: "foo.bar", SessionID: "abc-42", Branch: "main"}, + {ProjectID: "p", SessionID: "..hidden", Branch: "main"}, // leading dots != ".." + } + for i, cfg := range cases { + if err := validateConfig(cfg); err != nil { + t.Errorf("case %d %+v: unexpected error: %v", i, cfg, err) + } + } +} + func TestRestoreRefusesNonEmptyUnregisteredPath(t *testing.T) { root := t.TempDir() repo := t.TempDir() @@ -157,10 +211,12 @@ func TestDestroyRefusesStillRegisteredPathAndPreservesDirectory(t *testing.T) { if err := mkdirFile(path, "keep.txt"); err != nil { t.Fatalf("seed path: %v", err) } + var removeArgs []string ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { joined := strings.Join(args, " ") switch { case strings.Contains(joined, "worktree remove"): + removeArgs = append([]string{}, args...) return []byte("locked"), errors.New("remove failed") case strings.Contains(joined, "worktree prune"): return nil, nil @@ -177,6 +233,14 @@ func TestDestroyRefusesStillRegisteredPathAndPreservesDirectory(t *testing.T) { if _, statErr := os.Stat(filepath.Join(path, "keep.txt")); statErr != nil { t.Fatalf("expected directory to be preserved: %v", statErr) } + // Belt-and-braces: --force must NEVER be passed to `git worktree remove` from + // Destroy. If it ever is, dirty worktrees would be deleted instead of routed + // to Skipped by the Session Manager's Cleanup (review item RA). + for _, a := range removeArgs { + if a == "--force" || a == "-f" { + t.Fatalf("git worktree remove was called with %q; --force must never be passed", a) + } + } } func mkdirFile(dir, name string) error { diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index bedfb3b1fd..23d6a9b90d 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -22,12 +22,17 @@ import ( ) // Metadata keys OnSpawnCompleted records for the spawned session's handles. +// +// MetaPrompt is the assembled launch prompt, persisted so a Restore that finds +// no captured agent session id can still fall back to a fresh launch with the +// same prompt rather than failing. const ( MetaBranch = "branch" MetaWorkspacePath = "workspacePath" MetaRuntimeHandleID = "runtimeHandleId" MetaRuntimeName = "runtimeName" MetaAgentSessionID = "agentSessionId" + MetaPrompt = "prompt" ) // Manager is the LCM. The Apply* pipeline persists a transition and then fires @@ -114,9 +119,15 @@ func (m *Manager) withLock(id domain.SessionID, fn func() error) error { // transition is what a persisted write produced: the canonical before and after // the full-row upsert. The ACT layer (react) derives the reaction from these. It // is nil when the pipeline made no write. +// +// projectID is captured so reaction events fired downstream (Notifier.Notify in +// executeReaction and escalate) can populate OrchestratorEvent.ProjectID — the +// human-facing event router groups events by project. Empty when the record has +// no ProjectID (e.g. test-only seeded records that omit identity). type transition struct { - beforeLC domain.CanonicalSessionLifecycle - afterLC domain.CanonicalSessionLifecycle + beforeLC domain.CanonicalSessionLifecycle + afterLC domain.CanonicalSessionLifecycle + projectID domain.ProjectID } // mutate runs the shared pipeline: load full row -> build next canonical -> @@ -150,7 +161,10 @@ func (m *Manager) mutate( if err := m.store.Upsert(ctx, rec, classifyEventType(cur, rec.Lifecycle, false)); err != nil { return err } - tr = &transition{beforeLC: cur, afterLC: rec.Lifecycle} + // ProjectID is captured straight from the record we already loaded at the + // top of this closure — identity is set once at OnSpawnInitiated and never + // mutated, so no second store roundtrip is needed for reaction events. + tr = &transition{beforeLC: cur, afterLC: rec.Lifecycle, projectID: rec.ProjectID} return nil }) return tr, err @@ -484,5 +498,8 @@ func spawnMetadata(o ports.SpawnOutcome) map[string]string { if o.AgentSessionID != "" { meta[MetaAgentSessionID] = o.AgentSessionID } + if o.Prompt != "" { + meta[MetaPrompt] = o.Prompt + } return meta } diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index 761ac4a4a0..26dea5627f 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -190,10 +190,16 @@ type trackerKey struct { // a few extra agent retries before re-escalating — never a missed human // notification. Keeping it out of the canonical store preserves the // truth-vs-policy split (the store holds session truth; this is ACT policy). +// +// projectID is captured at first attempt so TickEscalations — which fires from +// the reaper and has no transition on hand — can still populate ProjectID on +// the escalation event. It is set once and never overwritten; reaction-bearing +// transitions for a given session id always carry the same projectID. type reactionTracker struct { attempts int escalated bool firstAttemptAt time.Time + projectID domain.ProjectID } // react fires the ACT layer after a persisted transition: clear the tracker for @@ -239,7 +245,7 @@ func (m *Manager) react(ctx context.Context, id domain.SessionID, tr *transition } if hasAfter && (!hadBefore || changed) { - return m.executeReaction(ctx, id, afterKey, rc) + return m.executeReaction(ctx, id, tr.projectID, afterKey, rc) } return nil } @@ -272,7 +278,7 @@ func recovered(l domain.CanonicalSessionLifecycle) bool { } } -func (m *Manager) executeReaction(ctx context.Context, id domain.SessionID, key reactionKey, rc reactionContext) error { +func (m *Manager) executeReaction(ctx context.Context, id domain.SessionID, projectID domain.ProjectID, key reactionKey, rc reactionContext) error { cfg := defaultReactions[key] switch cfg.action { case actionNotify: @@ -282,6 +288,7 @@ func (m *Manager) executeReaction(ctx context.Context, id domain.SessionID, key Type: cfg.eventType, Priority: cfg.priority, SessionID: id, + ProjectID: projectID, Message: cfg.message, }) case actionAutoMerge: @@ -289,7 +296,7 @@ func (m *Manager) executeReaction(ctx context.Context, id domain.SessionID, key // later PR. An opt-in config could route a reaction here. return nil case actionSendToAgent: - return m.sendToAgent(ctx, id, key, cfg, rc) + return m.sendToAgent(ctx, id, projectID, key, cfg, rc) } return nil } @@ -297,9 +304,16 @@ func (m *Manager) executeReaction(ctx context.Context, id domain.SessionID, key // sendToAgent runs the escalation engine for an auto send-to-agent reaction: // count the attempt, escalate when the numeric cap or duration is exceeded // (silencing further auto-dispatch), else inject the message via the messenger. -func (m *Manager) sendToAgent(ctx context.Context, id domain.SessionID, key reactionKey, cfg reactionConfig, rc reactionContext) error { +func (m *Manager) sendToAgent(ctx context.Context, id domain.SessionID, projectID domain.ProjectID, key reactionKey, cfg reactionConfig, rc reactionContext) error { m.trackerMu.Lock() tk := m.trackerFor(id, key) + // Capture projectID once so the duration-based TickEscalations path — which + // has no transition on hand — can still populate ProjectID on the escalation + // event. A non-empty incoming projectID always wins, in case the tracker was + // first created from an observation that lacked one. + if projectID != "" { + tk.projectID = projectID + } if tk.escalated { m.trackerMu.Unlock() return nil // silenced until the condition clears the tracker @@ -313,7 +327,7 @@ func (m *Manager) sendToAgent(ctx context.Context, id domain.SessionID, key reac if shouldEscalate(tk, cfg, now) { tk.escalated = true m.trackerMu.Unlock() - return m.escalate(ctx, id, key) + return m.escalate(ctx, id, tk.projectID, key) } m.trackerMu.Unlock() @@ -349,11 +363,12 @@ func shouldEscalate(tk *reactionTracker, cfg reactionConfig, now time.Time) bool // escalate emits reaction.escalated and notifies the human. The caller has // already set tracker.escalated under the lock, which silences further // auto-dispatch for this reaction until the tracker clears. -func (m *Manager) escalate(ctx context.Context, id domain.SessionID, key reactionKey) error { +func (m *Manager) escalate(ctx context.Context, id domain.SessionID, projectID domain.ProjectID, key reactionKey) error { return m.notifier.Notify(ctx, ports.OrchestratorEvent{ Type: "reaction.escalated", Priority: ports.PriorityUrgent, SessionID: id, + ProjectID: projectID, Message: fmt.Sprintf("auto-handling of %q is exhausted and needs a human.", key), Data: map[string]any{"reaction": string(key)}, }) @@ -403,8 +418,9 @@ func (m *Manager) clearSessionTrackers(id domain.SessionID) { // sent outside the lock so agent/notifier latency never blocks tracker access. func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { type due struct { - id domain.SessionID - key reactionKey + id domain.SessionID + projectID domain.ProjectID + key reactionKey } var fire []due @@ -416,13 +432,13 @@ func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { cfg := defaultReactions[k.key] if cfg.escalateAfter > 0 && !tk.firstAttemptAt.IsZero() && now.Sub(tk.firstAttemptAt) >= cfg.escalateAfter { tk.escalated = true - fire = append(fire, due{id: k.id, key: k.key}) + fire = append(fire, due{id: k.id, projectID: tk.projectID, key: k.key}) } } m.trackerMu.Unlock() for _, d := range fire { - if err := m.escalate(ctx, d.id, d.key); err != nil { + if err := m.escalate(ctx, d.id, d.projectID, d.key); err != nil { return err } } diff --git a/backend/internal/lifecycle/reactions_test.go b/backend/internal/lifecycle/reactions_test.go index 942bc339bc..637b1e5bd0 100644 --- a/backend/internal/lifecycle/reactions_test.go +++ b/backend/internal/lifecycle/reactions_test.go @@ -446,6 +446,123 @@ func TestReaction_IncidentOverClearsAllSessionTrackers(t *testing.T) { } } +// ---- ProjectID propagation (review R11) ---- + +// TestReaction_ProjectIDOnNotifyAndEscalateEvents asserts that both Notify call +// sites in reactions.go (executeReaction's notify and escalate) carry the +// record's ProjectID. The human-facing event router groups by project, so a +// missing id would land events in the wrong bucket. +func TestReaction_ProjectIDOnNotifyAndEscalateEvents(t *testing.T) { + const proj domain.ProjectID = "acme" + + t.Run("notify path -> ProjectID populated", func(t *testing.T) { + m, store, notf, _ := newReactive() + // Seed via Upsert (not the lifecycle-only seed helper) so the record carries + // the ProjectID that mutate's transition then propagates to react. + if err := store.Upsert(ctx(), domain.SessionRecord{ + ID: sid, ProjectID: proj, Lifecycle: lcOpenPR(domain.PRReasonReviewPending), + }, ports.EventSessionCreated); err != nil { + t.Fatalf("upsert: %v", err) + } + + // approved-and-green is a notify reaction; it fires once via executeReaction. + err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewApproved, + Mergeability: ports.Mergeability{Mergeable: true}, PRNumber: 7, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + + notf.mu.Lock() + defer notf.mu.Unlock() + var got *ports.OrchestratorEvent + for i := range notf.events { + if notf.events[i].Type == "reaction.approved-and-green" { + got = ¬f.events[i] + break + } + } + if got == nil { + t.Fatalf("expected approved-and-green notify, got events: %+v", notf.events) + } + if got.ProjectID != proj { + t.Errorf("notify ProjectID = %q, want %q", got.ProjectID, proj) + } + if got.SessionID != sid { + t.Errorf("notify SessionID = %q, want %q", got.SessionID, sid) + } + }) + + t.Run("escalate path -> ProjectID populated (numeric cap)", func(t *testing.T) { + m, store, notf, _ := newReactive() + if err := store.Upsert(ctx(), domain.SessionRecord{ + ID: sid, ProjectID: proj, Lifecycle: lcOpenPR(domain.PRReasonReviewPending), + }, ports.EventSessionCreated); err != nil { + t.Fatalf("upsert: %v", err) + } + + // Drain the ci-failed budget to numeric escalation (sendToAgent -> escalate). + for i := 0; i < 4; i++ { + failCI(t, m) + pendingCI(t, m) + } + + notf.mu.Lock() + defer notf.mu.Unlock() + var got *ports.OrchestratorEvent + for i := range notf.events { + if notf.events[i].Type == "reaction.escalated" { + got = ¬f.events[i] + break + } + } + if got == nil { + t.Fatalf("expected reaction.escalated event, got events: %+v", notf.events) + } + if got.ProjectID != proj { + t.Errorf("escalate ProjectID = %q, want %q", got.ProjectID, proj) + } + }) + + t.Run("escalate path -> ProjectID populated (TickEscalations duration)", func(t *testing.T) { + m, store, notf, _ := newReactive() + if err := store.Upsert(ctx(), domain.SessionRecord{ + ID: sid, ProjectID: proj, Lifecycle: lcOpenPR(domain.PRReasonReviewPending), + }, ports.EventSessionCreated); err != nil { + t.Fatalf("upsert: %v", err) + } + + // changes-requested creates a duration-based tracker on the first send; + // TickEscalations fires escalate from a path with no transition on hand, + // so the tracker's captured ProjectID is what must surface on the event. + if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewChangesRequested, PRNumber: 7, + }); err != nil { + t.Fatalf("apply: %v", err) + } + if err := m.TickEscalations(ctx(), t0.Add(30*time.Minute)); err != nil { + t.Fatalf("tick: %v", err) + } + + notf.mu.Lock() + defer notf.mu.Unlock() + var got *ports.OrchestratorEvent + for i := range notf.events { + if notf.events[i].Type == "reaction.escalated" { + got = ¬f.events[i] + break + } + } + if got == nil { + t.Fatalf("expected duration-escalated event, got events: %+v", notf.events) + } + if got.ProjectID != proj { + t.Errorf("tick-escalate ProjectID = %q, want %q", got.ProjectID, proj) + } + }) +} + func sessionTrackerCount(m *Manager, id domain.SessionID) int { m.trackerMu.Lock() defer m.trackerMu.Unlock() diff --git a/backend/internal/ports/facts.go b/backend/internal/ports/facts.go index f1b0c702e7..e1854facc4 100644 --- a/backend/internal/ports/facts.go +++ b/backend/internal/ports/facts.go @@ -123,11 +123,17 @@ const ( // SpawnOutcome is what the Session Manager reports to the LCM after a spawn. // RuntimeHandle is the same structured handle the Runtime port returns, so no // ad-hoc string encoding is needed for later Destroy/SendMessage calls. +// +// Prompt is the assembled launch prompt persisted as metadata so Restore can +// fall back to a fresh launch (Agent.GetLaunchCommand) when the agent's native +// session id was never captured — without it Restore would have nothing to +// resume and nothing to re-seed a fresh run with. type SpawnOutcome struct { Branch string WorkspacePath string RuntimeHandle RuntimeHandle AgentSessionID string + Prompt string } // KillReason is what the Session Manager reports to the LCM when a kill is diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index 4d9157398d..e764f6a31d 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -134,7 +134,10 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess return domain.Session{}, fmt.Errorf("spawn %s: on spawn initiated: %w", id, err) } - outcome := ports.SpawnOutcome{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle} + // Prompt is persisted via OnSpawnCompleted -> spawnMetadata so a later Restore + // can fall back to a fresh launch if the agent's native session id was never + // captured (the capture path is a separate hook that may never have run). + outcome := ports.SpawnOutcome{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle, Prompt: agentCfg.Prompt} if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { // The record is seeded but the runtime/workspace are about to be torn // down. The store has no delete, so route the orphan to a terminal @@ -270,13 +273,15 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess return domain.Session{}, fmt.Errorf("restore %s: metadata: %w", id, err) } - // Resume is only possible with the agent's captured session id. Without it, - // GetRestoreCommand would produce an ambiguous "resume nothing" launch, and - // we have no stored prompt to fall back to a fresh launch — so fail early, - // before any I/O. + // Resume is only possible with the agent's captured session id; without it we + // fall back to a fresh launch using the seeded prompt persisted at spawn time + // (the agent's id-capture path is a separate hook that may never have run, so + // "no id" is the common case rather than an error). If neither is available + // there is nothing to relaunch from — fail early, before any I/O. agentSessionID := meta[lifecycle.MetaAgentSessionID] - if agentSessionID == "" { - return domain.Session{}, fmt.Errorf("restore %s: missing agent session id (cannot resume)", id) + seededPrompt := meta[lifecycle.MetaPrompt] + if agentSessionID == "" && seededPrompt == "" { + return domain.Session{}, fmt.Errorf("restore %s: no agent session id or seeded prompt (cannot resume or relaunch)", id) } ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ @@ -288,11 +293,15 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess return domain.Session{}, fmt.Errorf("restore %s: workspace restore: %w", id, err) } - agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path} + agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: seededPrompt} + launchCommand := m.agent.GetRestoreCommand(agentSessionID) + if agentSessionID == "" { + launchCommand = m.agent.GetLaunchCommand(agentCfg) + } handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ SessionID: id, WorkspacePath: ws.Path, - LaunchCommand: m.agent.GetRestoreCommand(agentSessionID), + LaunchCommand: launchCommand, Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, rec.ProjectID, rec.IssueID), }) if err != nil { @@ -317,6 +326,7 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess WorkspacePath: ws.Path, RuntimeHandle: handle, AgentSessionID: agentSessionID, + Prompt: seededPrompt, } if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { m.rollbackRuntime(ctx, handle) diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index 381fb57e06..5bb20d07b9 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -82,13 +82,16 @@ func TestSpawn_HappyPath(t *testing.T) { } } - // Handles persisted to metadata for later teardown/restore. + // Handles persisted to metadata for later teardown/restore. The prompt is + // persisted too so a later Restore that finds no captured agent session id + // can still fall back to a fresh launch using the same prompt. meta, _ := h.store.GetMetadata(ctx, "sess-1") for k, want := range map[string]string{ lifecycle.MetaBranch: "feat/42", lifecycle.MetaWorkspacePath: "/tmp/ws/sess-1", lifecycle.MetaRuntimeHandleID: "rt-sess-1", lifecycle.MetaRuntimeName: "tmux", + lifecycle.MetaPrompt: "do the thing\n\nbe careful", } { if meta[k] != want { t.Errorf("meta[%q] = %q, want %q", k, meta[k], want) @@ -431,7 +434,7 @@ func TestRestore_RelaunchesWithResumeCommand(t *testing.T) { } } -func TestRestore_MissingAgentSessionID_Errors(t *testing.T) { +func TestRestore_NoAgentSessionID_FreshLaunchFallback(t *testing.T) { h := newHarness("sess-1") ctx := context.Background() if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { @@ -440,13 +443,45 @@ func TestRestore_MissingAgentSessionID_Errors(t *testing.T) { if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); err != nil { t.Fatalf("kill: %v", err) } - // No agent session id was ever captured (spawn leaves it empty) — resume is - // impossible, so Restore must fail early without touching workspace/runtime. + // No agent session id was ever captured (the capture hook is a separate + // path that may never have run), but Spawn persisted the prompt, so Restore + // must fall back to a fresh launch instead of failing. + createdBefore := len(h.runtime.created) + + sess, err := h.sm.Restore(ctx, "sess-1") + if err != nil { + t.Fatalf("restore: %v", err) + } + if sess.Status != domain.StatusSpawning { + t.Errorf("status = %q, want spawning", sess.Status) + } + if len(h.runtime.created) != createdBefore+1 { + t.Fatalf("runtime.created grew by %d, want 1 (fresh-launch fallback)", len(h.runtime.created)-createdBefore) + } + // Fresh launch uses GetLaunchCommand (returns "claude" in the fake) — not + // the resume command, which would have read "claude --resume ". + if got := h.runtime.created[createdBefore].LaunchCommand; got != "claude" { + t.Errorf("restore launch command = %q, want fresh-launch %q", got, "claude") + } +} + +func TestRestore_NoIDAndNoPrompt_Errors(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + // Seed a terminal record directly without any metadata — no agent session id, + // no prompt. Restore has nothing to resume and nothing to relaunch from, so + // it must fail early without touching workspace/runtime. + if err := h.store.Upsert(ctx, domain.SessionRecord{ + ID: "sess-1", ProjectID: testProject, + Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), + }, ports.EventSessionCreated); err != nil { + t.Fatalf("upsert: %v", err) + } beforeRestores := len(h.workspace.restoredID) beforeCreated := len(h.runtime.created) if _, err := h.sm.Restore(ctx, "sess-1"); err == nil { - t.Fatal("restore: want error for missing agent session id, got nil") + t.Fatal("restore: want error for missing agent session id and prompt, got nil") } if len(h.workspace.restoredID) != beforeRestores { t.Error("workspace was touched despite a doomed restore") From 11475fbc720b8ea1fd184f92257d1b4b3a7713fa Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 29 May 2026 22:10:29 +0530 Subject: [PATCH 042/250] feat(observe): reaper for liveness probe + TickEscalations heartbeat (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reaper sits OUTSIDE the LCM's per-session serial loop. On every tick it: 1. Fires lcm.TickEscalations(now) — the duration-based escalation heartbeat a non-polling LCM cannot wake itself to drive. 2. Asks lcm.RunningSessions for the snapshot of sessions whose runtime axis is alive, then calls runtime.IsAlive(handle) per session via a RuntimeRegistry that dispatches by RuntimeHandle.RuntimeName (so a single reaper covers tmux + zellij side by side). 3. Reports any non-alive result back as a fact via ApplyRuntimeObservation — dead -> RuntimeProbeDead, probe error -> RuntimeProbeFailed (never collapsed to alive: failed probe ≠ dead, but it ≠ alive either). Steady- state alive is skipped so we don't churn the LCM with no-op load/diff work. The reaper REPORTS facts; the LCM owns DECIDE (anti-flap Detecting quarantine, terminal-session rules). The reaper never writes. Open-question resolution: add RunningSessions(ctx) to ports.LifecycleManager (option a). The Manager implements it via an injectable session lister (Manager.WithSessionLister) so the LCM itself does not require a new LifecycleStore method — Tom's store contract is untouched, daemon wiring (lane #10) will inject the production lister at startup. Scope: reaper goroutine + the minimum LCM seam. No activity ingest, no FS watcher, no daemon wiring, no new schema fields, no store changes. --- backend/internal/lifecycle/manager.go | 44 +++ backend/internal/observe/reaper/reaper.go | 212 +++++++++++ .../internal/observe/reaper/reaper_test.go | 334 ++++++++++++++++++ backend/internal/ports/inbound.go | 8 + backend/internal/session/fakes_test.go | 4 + 5 files changed, 602 insertions(+) create mode 100644 backend/internal/observe/reaper/reaper.go create mode 100644 backend/internal/observe/reaper/reaper_test.go diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 23d6a9b90d..defa3b643d 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -52,6 +52,14 @@ type Manager struct { trackers map[trackerKey]*reactionTracker trackerMu sync.Mutex clock func() time.Time + + // sessionLister returns every session known to persistence so RunningSessions + // can filter by runtime axis without coupling the LCM to a cross-project + // store API the Tom-store does not yet expose. The daemon (lane #10) injects + // the production lister via WithSessionLister; until then, the call returns + // no sessions so a reaper attached to an unwired Manager is a clean no-op + // rather than a panic. + sessionLister func(ctx context.Context) ([]domain.SessionRecord, error) } var _ ports.LifecycleManager = (*Manager)(nil) @@ -67,6 +75,13 @@ func New(store ports.LifecycleStore, notifier ports.Notifier, messenger ports.Ag } } +// WithSessionLister injects the function the LCM uses to enumerate all +// persisted sessions for RunningSessions. The daemon wires this against the +// store at startup. Calling it more than once replaces the previous lister. +func (m *Manager) WithSessionLister(fn func(ctx context.Context) ([]domain.SessionRecord, error)) { + m.sessionLister = fn +} + // ---- per-session serialisation ---- // keyedMutex hands out one lock per session id so the load->decide->persist @@ -409,6 +424,35 @@ func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, r po return nil } +// ---- read-snapshot helpers ---- + +// RunningSessions returns a snapshot of every persisted session whose runtime +// axis is alive. It exists so the reaper (OBSERVE) can decide whom to probe +// without taking on a LifecycleStore dependency or knowing the LCM's internal +// state. Because the call only reads and copies, it does not break the +// single-writer invariant; concurrent Apply* calls may move sessions in or out +// of "alive" between snapshots, which is correct — the next tick re-reads. +// +// When no lister has been wired (e.g. tests construct a bare Manager), the +// method returns an empty slice so a goroutine attached to such a Manager +// degrades to a no-op rather than panicking. +func (m *Manager) RunningSessions(ctx context.Context) ([]domain.SessionRecord, error) { + if m.sessionLister == nil { + return nil, nil + } + all, err := m.sessionLister(ctx) + if err != nil { + return nil, err + } + out := make([]domain.SessionRecord, 0, len(all)) + for _, rec := range all { + if rec.Lifecycle.Runtime.State == domain.RuntimeAlive { + out = append(out, rec) + } + } + return out, nil +} + // ---- diff helpers ---- // setSessionIfChanged sets next.Session only when the decided sub-state differs diff --git a/backend/internal/observe/reaper/reaper.go b/backend/internal/observe/reaper/reaper.go new file mode 100644 index 0000000000..5bd4b0d70b --- /dev/null +++ b/backend/internal/observe/reaper/reaper.go @@ -0,0 +1,212 @@ +// Package reaper implements the OBSERVE-layer polling timer that supplies the +// LCM with the two facts the LCM cannot wake itself to discover: a periodic +// duration-based escalation heartbeat, and per-session runtime liveness probes. +// +// The reaper sits OUTSIDE the LCM's per-session serial loop. It only REPORTS +// facts — it never decides whether a session is "truly" dead. The decider +// (anti-flap Detecting quarantine, terminal-session rules) is owned by the LCM +// and consumes these facts through the regular ApplyRuntimeObservation entry +// point. A probe error is reported as a probe-failure fact, never collapsed to +// "alive" or "dead", so the LCM's failed-probe ≠ dead invariant holds. +package reaper + +import ( + "context" + "log/slog" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// DefaultTickInterval is the cadence used when Config.Tick is zero. It mirrors +// the design doc's 5s sampling window for runtime liveness. +const DefaultTickInterval = 5 * time.Second + +// RuntimeRegistry resolves a runtime adapter by the RuntimeName recorded in a +// session's RuntimeHandle. The reaper looks the runtime up per-session so a +// single reaper instance can probe tmux- and zellij-backed sessions side by +// side without knowing about either at construction. +type RuntimeRegistry interface { + Runtime(name string) (ports.Runtime, bool) +} + +// MapRegistry is the trivial RuntimeRegistry: a name->runtime map. Callers +// that need dynamic registration can implement RuntimeRegistry themselves. +type MapRegistry map[string]ports.Runtime + +// Runtime implements RuntimeRegistry. +func (m MapRegistry) Runtime(name string) (ports.Runtime, bool) { + rt, ok := m[name] + return rt, ok +} + +// Config holds the externally-tunable knobs for a Reaper. Every field is +// optional; zero values fall back to safe defaults so production wiring (which +// only needs to inject the LCM and registry) and tests (which inject a clock +// plus a fast tick) can both stay terse. +type Config struct { + // Tick is the interval between ticks. <=0 means DefaultTickInterval. + Tick time.Duration + // Clock supplies ObservedAt and TickEscalations now stamps. nil means + // time.Now. Injected in tests so assertions don't race wallclock. + Clock func() time.Time + // Logger receives operational diagnostics (probe errors, skipped sessions, + // LCM call failures). The reaper logs but does not propagate these errors + // because a single failed probe must not kill the loop. nil means + // slog.Default. + Logger *slog.Logger +} + +// Reaper is the polling timer. Construct it with New; start the background +// goroutine with Start, or drive a single cycle synchronously with Tick. +type Reaper struct { + lcm ports.LifecycleManager + registry RuntimeRegistry + tick time.Duration + clock func() time.Time + logger *slog.Logger +} + +// New constructs a Reaper. The LCM is the sole writer destination (the reaper +// reports facts via ApplyRuntimeObservation and TickEscalations); the registry +// resolves the runtime adapter to use per session. +func New(lcm ports.LifecycleManager, registry RuntimeRegistry, cfg Config) *Reaper { + r := &Reaper{ + lcm: lcm, + registry: registry, + tick: cfg.Tick, + clock: cfg.Clock, + logger: cfg.Logger, + } + if r.tick <= 0 { + r.tick = DefaultTickInterval + } + if r.clock == nil { + r.clock = time.Now + } + if r.logger == nil { + r.logger = slog.Default() + } + return r +} + +// Start launches the background goroutine and returns a channel that closes +// once the loop has exited. The loop exits on ctx cancellation; the channel +// gives the daemon a clean shutdown hook (wait on it after cancel to confirm +// the reaper has stopped before tearing down dependencies). +func (r *Reaper) Start(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + go r.loop(ctx, done) + return done +} + +func (r *Reaper) loop(ctx context.Context, done chan<- struct{}) { + defer close(done) + t := time.NewTicker(r.tick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := r.Tick(ctx); err != nil { + r.logger.Error("reaper: tick failed", "err", err) + } + } + } +} + +// Tick runs one observation cycle: it always fires TickEscalations first (the +// duration-based escalation heartbeat, which the synchronous LCM cannot wake +// itself to drive), then enumerates the LCM's running sessions, probes each +// one's runtime, and reports any non-alive result back as a fact. +// +// Tick is exported so the daemon (and tests) can drive cycles synchronously, +// and so the Start goroutine has a single chokepoint to log against. +// +// Errors: only the RunningSessions failure is propagated, since it short- +// circuits the rest of the cycle. TickEscalations and per-session +// ApplyRuntimeObservation failures are logged but never propagated — one +// failed call must not bring down the loop. +func (r *Reaper) Tick(ctx context.Context) error { + now := r.clock() + + // Heartbeat is best-effort and runs before enumeration so duration-based + // escalations still fire if the running-set lookup is the thing that + // errored. The LCM's TickEscalations is itself idempotent (no canonical + // writes) — at worst we miss escalating once and pick it up next tick. + if err := r.lcm.TickEscalations(ctx, now); err != nil { + r.logger.Error("reaper: TickEscalations failed", "err", err) + } + + sessions, err := r.lcm.RunningSessions(ctx) + if err != nil { + return err + } + + for _, sess := range sessions { + r.probeOne(ctx, sess, now) + } + return nil +} + +// probeOne handles a single session's probe + fact-report. It is intentionally +// silent on the alive case: a probe that confirms the steady "alive" state is +// not a fact worth re-reporting (the runtime axis is already alive, so the LCM +// would diff to a no-op anyway). Dead and probe-failure ARE reported. +func (r *Reaper) probeOne(ctx context.Context, sess domain.SessionRecord, now time.Time) { + handle, ok := handleFromRecord(sess) + if !ok { + r.logger.Debug("reaper: session has no runtime handle metadata, skipping", + "session", sess.ID) + return + } + rt, ok := r.registry.Runtime(handle.RuntimeName) + if !ok { + r.logger.Warn("reaper: no runtime registered for session, skipping", + "session", sess.ID, "runtime", handle.RuntimeName) + return + } + + alive, probeErr := rt.IsAlive(ctx, handle) + facts := ports.RuntimeFacts{ObservedAt: now} + switch { + case probeErr != nil: + // Failed probe must NOT be collapsed to alive — that would let a + // transient tmux/zellij outage hide a really-dead session, and a + // transient adapter bug terminate a really-alive one. Report failed + // and let the LCM's detecting quarantine arbitrate. + facts.RuntimeState = ports.RuntimeProbeFailed + facts.ProcessState = ports.ProcessProbeFailed + r.logger.Debug("reaper: probe error reported as failed fact", + "session", sess.ID, "runtime", handle.RuntimeName, "err", probeErr) + case alive: + // Steady-state alive carries no new information — skip the call so we + // don't churn the LCM with no-op load/diff/persist work on every tick. + // Recovery from detecting via probe still flows through this path + // whenever the probe is NOT alive (failure or death). + return + default: + facts.RuntimeState = ports.RuntimeProbeDead + facts.ProcessState = ports.ProcessProbeDead + } + + if err := r.lcm.ApplyRuntimeObservation(ctx, sess.ID, facts); err != nil { + r.logger.Error("reaper: ApplyRuntimeObservation failed", + "session", sess.ID, "err", err) + } +} + +// handleFromRecord reconstructs the RuntimeHandle stored on the session by +// OnSpawnCompleted. Both keys are required; either being empty is the +// "session lacks a probable handle" signal that probeOne uses to skip. +func handleFromRecord(rec domain.SessionRecord) (ports.RuntimeHandle, bool) { + id := rec.Metadata[lifecycle.MetaRuntimeHandleID] + name := rec.Metadata[lifecycle.MetaRuntimeName] + if id == "" || name == "" { + return ports.RuntimeHandle{}, false + } + return ports.RuntimeHandle{ID: id, RuntimeName: name}, true +} diff --git a/backend/internal/observe/reaper/reaper_test.go b/backend/internal/observe/reaper/reaper_test.go new file mode 100644 index 0000000000..783fbad730 --- /dev/null +++ b/backend/internal/observe/reaper/reaper_test.go @@ -0,0 +1,334 @@ +package reaper_test + +import ( + "context" + "errors" + "reflect" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// ---- fakes ---- + +type aliveResult struct { + alive bool + err error +} + +// fakeRuntime is a programmable ports.Runtime. The reaper only calls IsAlive, +// but the interface requires the other methods so we stub them. +type fakeRuntime struct { + mu sync.Mutex + results map[string]aliveResult + probed []string +} + +var _ ports.Runtime = (*fakeRuntime)(nil) + +func (f *fakeRuntime) IsAlive(_ context.Context, h ports.RuntimeHandle) (bool, error) { + f.mu.Lock() + f.probed = append(f.probed, h.ID) + f.mu.Unlock() + r, ok := f.results[h.ID] + if !ok { + return false, errors.New("fakeRuntime: no programmed response for " + h.ID) + } + return r.alive, r.err +} + +func (f *fakeRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) { + return ports.RuntimeHandle{}, nil +} +func (f *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil } +func (f *fakeRuntime) SendMessage(context.Context, ports.RuntimeHandle, string) error { + return nil +} +func (f *fakeRuntime) GetOutput(context.Context, ports.RuntimeHandle, int) (string, error) { + return "", nil +} + +// fakeLCM records every reaper-facing call in order so tests can assert the +// exact sequence (TickEscalations -> RunningSessions -> ApplyRuntimeObservation). +type fakeLCM struct { + mu sync.Mutex + sessions []domain.SessionRecord + calls []call + + runErr error + tickErr error + obsErr error +} + +type call struct { + Kind string + Now time.Time + Session domain.SessionID + Facts ports.RuntimeFacts +} + +var _ ports.LifecycleManager = (*fakeLCM)(nil) + +func (l *fakeLCM) RunningSessions(_ context.Context) ([]domain.SessionRecord, error) { + l.mu.Lock() + defer l.mu.Unlock() + l.calls = append(l.calls, call{Kind: "RunningSessions"}) + if l.runErr != nil { + return nil, l.runErr + } + out := make([]domain.SessionRecord, len(l.sessions)) + copy(out, l.sessions) + return out, nil +} + +func (l *fakeLCM) TickEscalations(_ context.Context, now time.Time) error { + l.mu.Lock() + defer l.mu.Unlock() + l.calls = append(l.calls, call{Kind: "TickEscalations", Now: now}) + return l.tickErr +} + +func (l *fakeLCM) ApplyRuntimeObservation(_ context.Context, id domain.SessionID, f ports.RuntimeFacts) error { + l.mu.Lock() + defer l.mu.Unlock() + l.calls = append(l.calls, call{Kind: "ApplyRuntimeObservation", Session: id, Facts: f}) + return l.obsErr +} + +// unused methods on the LCM port — the reaper never invokes them. +func (l *fakeLCM) ApplySCMObservation(context.Context, domain.SessionID, ports.SCMFacts) error { + return nil +} +func (l *fakeLCM) ApplyActivitySignal(context.Context, domain.SessionID, ports.ActivitySignal) error { + return nil +} +func (l *fakeLCM) OnSpawnInitiated(context.Context, domain.SessionRecord) error { return nil } +func (l *fakeLCM) OnSpawnCompleted(context.Context, domain.SessionID, ports.SpawnOutcome) error { + return nil +} +func (l *fakeLCM) OnKillRequested(context.Context, domain.SessionID, ports.KillReason) error { + return nil +} + +// ---- helpers ---- + +func aliveSessionWith(id domain.SessionID, runtimeName, handleID string) domain.SessionRecord { + return domain.SessionRecord{ + ID: id, + Lifecycle: domain.CanonicalSessionLifecycle{ + Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, + }, + Metadata: map[string]string{ + lifecycle.MetaRuntimeHandleID: handleID, + lifecycle.MetaRuntimeName: runtimeName, + }, + } +} + +// ---- tests ---- + +func TestReaper_Tick(t *testing.T) { + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + + type runtimeProbes struct { + name string + results map[string]aliveResult + } + + tests := []struct { + name string + sessions []domain.SessionRecord + runtimes []runtimeProbes + wantCalls []call + wantProbe map[string][]string // runtime name -> handle IDs probed, in order + }{ + { + name: "alive session: no death applied, but tick still fires", + sessions: []domain.SessionRecord{aliveSessionWith("s1", "tmux", "h1")}, + runtimes: []runtimeProbes{{name: "tmux", results: map[string]aliveResult{"h1": {alive: true}}}}, + wantCalls: []call{ + {Kind: "TickEscalations", Now: now}, + {Kind: "RunningSessions"}, + }, + wantProbe: map[string][]string{"tmux": {"h1"}}, + }, + { + name: "dead session: exactly one ApplyRuntimeObservation with Dead facts", + sessions: []domain.SessionRecord{aliveSessionWith("s1", "tmux", "h1")}, + runtimes: []runtimeProbes{{name: "tmux", results: map[string]aliveResult{"h1": {alive: false}}}}, + wantCalls: []call{ + {Kind: "TickEscalations", Now: now}, + {Kind: "RunningSessions"}, + { + Kind: "ApplyRuntimeObservation", + Session: "s1", + Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeDead, ProcessState: ports.ProcessProbeDead}, + }, + }, + wantProbe: map[string][]string{"tmux": {"h1"}}, + }, + { + name: "probe error: reported as failed fact, NOT collapsed to alive", + sessions: []domain.SessionRecord{aliveSessionWith("s1", "tmux", "h1")}, + runtimes: []runtimeProbes{{name: "tmux", results: map[string]aliveResult{"h1": {err: errors.New("boom")}}}}, + wantCalls: []call{ + {Kind: "TickEscalations", Now: now}, + {Kind: "RunningSessions"}, + { + Kind: "ApplyRuntimeObservation", + Session: "s1", + Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeFailed, ProcessState: ports.ProcessProbeFailed}, + }, + }, + wantProbe: map[string][]string{"tmux": {"h1"}}, + }, + { + name: "multi-runtime dispatch: tmux + zellij in same tick", + sessions: []domain.SessionRecord{ + aliveSessionWith("s1", "tmux", "ht"), + aliveSessionWith("s2", "zellij", "hz"), + }, + runtimes: []runtimeProbes{ + {name: "tmux", results: map[string]aliveResult{"ht": {alive: false}}}, + {name: "zellij", results: map[string]aliveResult{"hz": {alive: true}}}, + }, + wantCalls: []call{ + {Kind: "TickEscalations", Now: now}, + {Kind: "RunningSessions"}, + { + Kind: "ApplyRuntimeObservation", + Session: "s1", + Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeDead, ProcessState: ports.ProcessProbeDead}, + }, + }, + wantProbe: map[string][]string{"tmux": {"ht"}, "zellij": {"hz"}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + lcm := &fakeLCM{sessions: tc.sessions} + registry := reaper.MapRegistry{} + byName := map[string]*fakeRuntime{} + for _, r := range tc.runtimes { + rt := &fakeRuntime{results: r.results} + registry[r.name] = rt + byName[r.name] = rt + } + rp := reaper.New(lcm, registry, reaper.Config{Clock: clock, Tick: time.Hour}) + + if err := rp.Tick(context.Background()); err != nil { + t.Fatalf("Tick error: %v", err) + } + + if !reflect.DeepEqual(lcm.calls, tc.wantCalls) { + t.Errorf("LCM call log mismatch:\n got %#v\n want %#v", lcm.calls, tc.wantCalls) + } + + for name, want := range tc.wantProbe { + got := byName[name].probed + if !reflect.DeepEqual(got, want) { + t.Errorf("runtime %q probed handles mismatch: got %v want %v", name, got, want) + } + } + }) + } +} + +// TestReaper_Loop verifies the background goroutine actually drives ticks and +// exits on context cancel without leaking. +func TestReaper_Loop(t *testing.T) { + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + lcm := &fakeLCM{} + rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Clock: clock, Tick: 5 * time.Millisecond}) + + ctx, cancel := context.WithCancel(context.Background()) + done := rp.Start(ctx) + + // Wait for at least two ticks so we know the loop is actually firing. + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + lcm.mu.Lock() + n := countKind(lcm.calls, "TickEscalations") + lcm.mu.Unlock() + if n >= 2 { + break + } + time.Sleep(2 * time.Millisecond) + } + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("reaper goroutine did not exit within 1s of ctx cancel") + } + + lcm.mu.Lock() + defer lcm.mu.Unlock() + if got := countKind(lcm.calls, "TickEscalations"); got < 2 { + t.Errorf("expected at least 2 TickEscalations calls during loop, got %d", got) + } +} + +func countKind(calls []call, kind string) int { + n := 0 + for _, c := range calls { + if c.Kind == kind { + n++ + } + } + return n +} + +// TestReaper_SkipsUnknownRuntime verifies the reaper does not panic and does not +// report a fact when a session references an unregistered runtime — the reaper +// only reports what it actually probed. +func TestReaper_SkipsUnknownRuntime(t *testing.T) { + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + lcm := &fakeLCM{sessions: []domain.SessionRecord{aliveSessionWith("s1", "ghost", "h1")}} + rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Clock: clock, Tick: time.Hour}) + + if err := rp.Tick(context.Background()); err != nil { + t.Fatalf("Tick error: %v", err) + } + + for _, c := range lcm.calls { + if c.Kind == "ApplyRuntimeObservation" { + t.Fatalf("unexpected ApplyRuntimeObservation for unknown-runtime session: %+v", c) + } + } +} + +// TestReaper_SkipsMissingHandle verifies the reaper does not probe (and does not +// report) for sessions whose runtime handle metadata is missing — probing +// nothing returns no fact. +func TestReaper_SkipsMissingHandle(t *testing.T) { + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + sess := aliveSessionWith("s1", "tmux", "h1") + delete(sess.Metadata, lifecycle.MetaRuntimeHandleID) + lcm := &fakeLCM{sessions: []domain.SessionRecord{sess}} + rt := &fakeRuntime{results: map[string]aliveResult{}} + rp := reaper.New(lcm, reaper.MapRegistry{"tmux": rt}, reaper.Config{Clock: clock, Tick: time.Hour}) + + if err := rp.Tick(context.Background()); err != nil { + t.Fatalf("Tick error: %v", err) + } + if len(rt.probed) != 0 { + t.Errorf("expected no probes for session without handle id, got %v", rt.probed) + } + for _, c := range lcm.calls { + if c.Kind == "ApplyRuntimeObservation" { + t.Fatalf("unexpected ApplyRuntimeObservation: %+v", c) + } + } +} diff --git a/backend/internal/ports/inbound.go b/backend/internal/ports/inbound.go index 6508845c04..58ec2015f1 100644 --- a/backend/internal/ports/inbound.go +++ b/backend/internal/ports/inbound.go @@ -28,6 +28,14 @@ type LifecycleManager interface { // Reaper heartbeat that drives duration-based escalation (a non-polling // LCM can't wake itself to fire a "30m elapsed" escalation). TickEscalations(ctx context.Context, now time.Time) error + + // RunningSessions returns a snapshot of every session whose runtime axis is + // alive. The reaper calls it once per tick to decide whom to probe. It is a + // read snapshot — the slice and its elements are safe for the caller to + // iterate without holding any LCM lock — and does not violate the + // single-writer invariant (the reaper never writes; it reports facts back + // through ApplyRuntimeObservation). + RunningSessions(ctx context.Context) ([]domain.SessionRecord, error) } // SessionManager is the inbound contract called by the API layer and CLI. It diff --git a/backend/internal/session/fakes_test.go b/backend/internal/session/fakes_test.go index 1796e509e0..71eaa4afd9 100644 --- a/backend/internal/session/fakes_test.go +++ b/backend/internal/session/fakes_test.go @@ -328,6 +328,10 @@ func (l *recordingLCM) TickEscalations(ctx context.Context, now time.Time) error return l.inner.TickEscalations(ctx, now) } +func (l *recordingLCM) RunningSessions(ctx context.Context) ([]domain.SessionRecord, error) { + return l.inner.RunningSessions(ctx) +} + // ---- harness: wires the SM against the fakes + the real LCM ---- type harness struct { From 1eaaa4ce1d88702ede82d2fe8bb4aae507dc6912 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 29 May 2026 22:18:54 +0530 Subject: [PATCH 043/250] fix(observe): broaden reaper poll set + always report probe fact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address blocker found in self-review (B1 + I1): - Manager.RunningSessions previously filtered to runtime.State == RuntimeAlive, but a session enters Detecting with runtime axis = RuntimeProbeFailed (failed probe path, decide_bridge.go:72) or RuntimeMissing (detectingLC in manager_test.go:539). The filter silently parked every Detecting session, so the recovery path proved by manager_test.go:59 ("healthy probe recovers liveness-owned detecting -> working") and the terminal path proved by manager_test.go:79 ("dead+dead with no recent activity concludes killed") were both unreachable through the reaper. Broaden the predicate to "session is not in a terminal state" (mirrors the LCM's existing isTerminal helper) and document the wider semantics. - reaper.probeOne now reports every probe result — including alive — back to the LCM as ApplyRuntimeObservation facts. The previous skip-alive optimization was a layering violation: the reaper has no business deciding what counts as a no-op. The LCM's ApplyRuntimeObservation already diffs against canonical and only Upserts on actual change, so steady-state alive stays cheap. With the broadened poll set, an alive probe for a Detecting session IS the recovery fact. - Add unit tests for Manager.RunningSessions covering: nil-lister no-op, lister error propagation, and the full canonical state matrix (working/idle/ needs_input/detecting-probefailed/detecting-missing/not_started included; terminated/done excluded). - Update reaper tests: alive case now asserts the alive fact is reported; new "detecting session: alive probe reported so LCM can recover from quarantine" case locks in the recovery path; multi-runtime case now asserts both runtime facts flow through. - Bump "session in poll set without handle metadata" log from Debug to Warn — it is an anomaly (OnSpawnCompleted should have written both keys), not a routine event. - Document WithSessionLister must be called before any reaper attached to the Manager starts running (it is a bare field read; concurrent re-injection is meaningless anyway). --- backend/internal/lifecycle/manager.go | 35 ++++++--- backend/internal/lifecycle/manager_test.go | 73 +++++++++++++++++++ backend/internal/observe/reaper/reaper.go | 23 +++--- .../internal/observe/reaper/reaper_test.go | 54 +++++++++++++- 4 files changed, 164 insertions(+), 21 deletions(-) diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index defa3b643d..b5751e8670 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -77,7 +77,10 @@ func New(store ports.LifecycleStore, notifier ports.Notifier, messenger ports.Ag // WithSessionLister injects the function the LCM uses to enumerate all // persisted sessions for RunningSessions. The daemon wires this against the -// store at startup. Calling it more than once replaces the previous lister. +// store at startup; it must be called BEFORE any reaper attached to this +// Manager starts running, since concurrent calls would race the bare-field +// read in RunningSessions. Calling it more than once replaces the previous +// lister. func (m *Manager) WithSessionLister(fn func(ctx context.Context) ([]domain.SessionRecord, error)) { m.sessionLister = fn } @@ -426,16 +429,28 @@ func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, r po // ---- read-snapshot helpers ---- -// RunningSessions returns a snapshot of every persisted session whose runtime -// axis is alive. It exists so the reaper (OBSERVE) can decide whom to probe -// without taking on a LifecycleStore dependency or knowing the LCM's internal -// state. Because the call only reads and copies, it does not break the -// single-writer invariant; concurrent Apply* calls may move sessions in or out -// of "alive" between snapshots, which is correct — the next tick re-reads. +// RunningSessions returns a snapshot of every persisted session worth probing +// in the next reaper tick. "Worth probing" is wider than "runtime axis alive": +// it includes sessions in the Detecting quarantine, because a fresh probe is +// the only fact that can recover them (back to working) or escalate them +// (terminal killed). Filtering to runtime-axis-alive would silently park every +// Detecting session — a single failed probe would never get a second chance +// and recovery via runtime probe would be unreachable. +// +// The predicate is "not a final session state". Terminal session states (done, +// terminated) are excluded because Restore is the only path back; observations +// must not reopen them (#1 invariant). Sessions in earlier states — not_started, +// working, idle, needs_input, stuck, detecting — are all included. Those that +// lack runtime handle metadata (e.g. not_started before OnSpawnCompleted) are +// returned and harmlessly skipped by the reaper's per-session handle guard. +// +// The call only reads and copies, so it does not break the single-writer +// invariant; concurrent Apply* calls may move sessions in or out of the probe +// set between snapshots, which is correct — the next tick re-reads. // // When no lister has been wired (e.g. tests construct a bare Manager), the -// method returns an empty slice so a goroutine attached to such a Manager -// degrades to a no-op rather than panicking. +// method returns nil so a goroutine attached to such a Manager degrades to a +// no-op rather than panicking. func (m *Manager) RunningSessions(ctx context.Context) ([]domain.SessionRecord, error) { if m.sessionLister == nil { return nil, nil @@ -446,7 +461,7 @@ func (m *Manager) RunningSessions(ctx context.Context) ([]domain.SessionRecord, } out := make([]domain.SessionRecord, 0, len(all)) for _, rec := range all { - if rec.Lifecycle.Runtime.State == domain.RuntimeAlive { + if !isTerminal(rec.Lifecycle.Session.State) { out = append(out, rec) } } diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index e93a33c5da..6a2cc1d1a1 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -2,6 +2,7 @@ package lifecycle import ( "context" + "errors" "sync" "testing" "time" @@ -525,6 +526,78 @@ func TestPerSessionSerialization(t *testing.T) { } } +// ---- RunningSessions (reaper poll-set) ---- + +func TestRunningSessions_NoListerWired_ReturnsEmpty(t *testing.T) { + m, _ := newManager() + got, err := m.RunningSessions(context.Background()) + if err != nil { + t.Fatalf("RunningSessions: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected empty slice when no lister wired, got %d records", len(got)) + } +} + +func TestRunningSessions_ListerErrorPropagates(t *testing.T) { + m, _ := newManager() + wantErr := errors.New("boom") + m.WithSessionLister(func(_ context.Context) ([]domain.SessionRecord, error) { + return nil, wantErr + }) + _, err := m.RunningSessions(context.Background()) + if !errors.Is(err, wantErr) { + t.Fatalf("expected lister error to propagate, got %v", err) + } +} + +// TestRunningSessions_FilterIncludesProbableExcludesTerminal locks in the +// reaper poll-set predicate. The bug we are guarding against is filtering to +// "runtime.State == RuntimeAlive": detecting sessions (RuntimeMissing / +// RuntimeProbeFailed) would be silently parked, breaking the probe-driven +// recovery path proved by manager_test.go:59 and the dead+dead -> killed path +// proved by manager_test.go:79. +func TestRunningSessions_FilterIncludesProbableExcludesTerminal(t *testing.T) { + m, _ := newManager() + records := []domain.SessionRecord{ + {ID: "working-alive", Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)}, + {ID: "detecting-probefailed", Lifecycle: lc(domain.SessionDetecting, domain.ReasonProbeFailure, domain.RuntimeProbeFailed)}, + {ID: "detecting-missing", Lifecycle: lc(domain.SessionDetecting, domain.ReasonRuntimeLost, domain.RuntimeMissing)}, + {ID: "idle-alive", Lifecycle: lc(domain.SessionIdle, domain.ReasonResearchComplete, domain.RuntimeAlive)}, + {ID: "needs-input-alive", Lifecycle: lc(domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, domain.RuntimeAlive)}, + {ID: "not-started", Lifecycle: lc(domain.SessionNotStarted, domain.ReasonSpawnRequested, domain.RuntimeUnknown)}, + {ID: "terminated", Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.RuntimeExited)}, + {ID: "done", Lifecycle: lc(domain.SessionDone, domain.ReasonPRMerged, domain.RuntimeExited)}, + } + m.WithSessionLister(func(_ context.Context) ([]domain.SessionRecord, error) { + return records, nil + }) + + got, err := m.RunningSessions(context.Background()) + if err != nil { + t.Fatalf("RunningSessions: %v", err) + } + gotIDs := map[domain.SessionID]bool{} + for _, r := range got { + gotIDs[r.ID] = true + } + wantIncluded := []domain.SessionID{ + "working-alive", "detecting-probefailed", "detecting-missing", + "idle-alive", "needs-input-alive", "not-started", + } + for _, id := range wantIncluded { + if !gotIDs[id] { + t.Errorf("expected %q in poll set, missing", id) + } + } + wantExcluded := []domain.SessionID{"terminated", "done"} + for _, id := range wantExcluded { + if gotIDs[id] { + t.Errorf("expected %q NOT in poll set, found", id) + } + } +} + // ---- helpers ---- func lc(state domain.SessionState, reason domain.SessionReason, rt domain.RuntimeState) domain.CanonicalSessionLifecycle { diff --git a/backend/internal/observe/reaper/reaper.go b/backend/internal/observe/reaper/reaper.go index 5bd4b0d70b..66456ea6e4 100644 --- a/backend/internal/observe/reaper/reaper.go +++ b/backend/internal/observe/reaper/reaper.go @@ -152,14 +152,20 @@ func (r *Reaper) Tick(ctx context.Context) error { return nil } -// probeOne handles a single session's probe + fact-report. It is intentionally -// silent on the alive case: a probe that confirms the steady "alive" state is -// not a fact worth re-reporting (the runtime axis is already alive, so the LCM -// would diff to a no-op anyway). Dead and probe-failure ARE reported. +// probeOne handles a single session's probe + fact-report. Every probe result — +// alive, dead, or failed — is reported as a fact to the LCM. The reaper does +// not optimize away the "alive" case, because a session in Detecting (whose +// runtime axis is NOT alive) is included in the running set and needs the +// alive probe to recover; the reaper has no business deciding what counts as +// a no-op. The LCM's ApplyRuntimeObservation diffs against canonical and +// only Upserts on actual change, so steady-state alive is already cheap. func (r *Reaper) probeOne(ctx context.Context, sess domain.SessionRecord, now time.Time) { handle, ok := handleFromRecord(sess) if !ok { - r.logger.Debug("reaper: session has no runtime handle metadata, skipping", + // A session in the running-set without a handle is an anomaly worth + // surfacing (OnSpawnCompleted should have set both keys). Warn rather + // than Debug so it doesn't hide behind a noisy log level. + r.logger.Warn("reaper: session has no runtime handle metadata, skipping", "session", sess.ID) return } @@ -183,11 +189,8 @@ func (r *Reaper) probeOne(ctx context.Context, sess domain.SessionRecord, now ti r.logger.Debug("reaper: probe error reported as failed fact", "session", sess.ID, "runtime", handle.RuntimeName, "err", probeErr) case alive: - // Steady-state alive carries no new information — skip the call so we - // don't churn the LCM with no-op load/diff/persist work on every tick. - // Recovery from detecting via probe still flows through this path - // whenever the probe is NOT alive (failure or death). - return + facts.RuntimeState = ports.RuntimeProbeAlive + facts.ProcessState = ports.ProcessProbeAlive default: facts.RuntimeState = ports.RuntimeProbeDead facts.ProcessState = ports.ProcessProbeDead diff --git a/backend/internal/observe/reaper/reaper_test.go b/backend/internal/observe/reaper/reaper_test.go index 783fbad730..d6b88efdfe 100644 --- a/backend/internal/observe/reaper/reaper_test.go +++ b/backend/internal/observe/reaper/reaper_test.go @@ -121,6 +121,7 @@ func aliveSessionWith(id domain.SessionID, runtimeName, handleID string) domain. return domain.SessionRecord{ ID: id, Lifecycle: domain.CanonicalSessionLifecycle{ + Session: domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress}, Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, }, Metadata: map[string]string{ @@ -130,6 +131,23 @@ func aliveSessionWith(id domain.SessionID, runtimeName, handleID string) domain. } } +// detectingSessionWith returns a session in the Detecting quarantine, the +// shape `Manager.RunningSessions` MUST include so a probe-alive can recover it +// (otherwise the reaper traps every session that hiccups once in detecting). +func detectingSessionWith(id domain.SessionID, runtimeName, handleID string) domain.SessionRecord { + return domain.SessionRecord{ + ID: id, + Lifecycle: domain.CanonicalSessionLifecycle{ + Session: domain.SessionSubstate{State: domain.SessionDetecting, Reason: domain.ReasonProbeFailure}, + Runtime: domain.RuntimeSubstate{State: domain.RuntimeProbeFailed, Reason: domain.RuntimeReasonProbeError}, + }, + Metadata: map[string]string{ + lifecycle.MetaRuntimeHandleID: handleID, + lifecycle.MetaRuntimeName: runtimeName, + }, + } +} + // ---- tests ---- func TestReaper_Tick(t *testing.T) { @@ -149,12 +167,41 @@ func TestReaper_Tick(t *testing.T) { wantProbe map[string][]string // runtime name -> handle IDs probed, in order }{ { - name: "alive session: no death applied, but tick still fires", + // "No death applied" per the spec: the LCM does not receive a + // death-causing fact. It still receives the alive fact, because + // the reaper reports what it probed and the LCM is the one that + // diffs against canonical (a no-op when runtime is already alive, + // a recovery when the session was in Detecting). + name: "alive session: alive fact reported, no death applied, tick still fires", sessions: []domain.SessionRecord{aliveSessionWith("s1", "tmux", "h1")}, runtimes: []runtimeProbes{{name: "tmux", results: map[string]aliveResult{"h1": {alive: true}}}}, wantCalls: []call{ {Kind: "TickEscalations", Now: now}, {Kind: "RunningSessions"}, + { + Kind: "ApplyRuntimeObservation", + Session: "s1", + Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeAlive, ProcessState: ports.ProcessProbeAlive}, + }, + }, + wantProbe: map[string][]string{"tmux": {"h1"}}, + }, + { + // Recovery path: a session in Detecting+probe_failed must be in + // the poll set so an alive probe can flow through and recover it. + // If the reaper filtered to runtime-axis-alive only, this session + // would be trapped in Detecting forever. + name: "detecting session: alive probe reported so LCM can recover from quarantine", + sessions: []domain.SessionRecord{detectingSessionWith("s1", "tmux", "h1")}, + runtimes: []runtimeProbes{{name: "tmux", results: map[string]aliveResult{"h1": {alive: true}}}}, + wantCalls: []call{ + {Kind: "TickEscalations", Now: now}, + {Kind: "RunningSessions"}, + { + Kind: "ApplyRuntimeObservation", + Session: "s1", + Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeAlive, ProcessState: ports.ProcessProbeAlive}, + }, }, wantProbe: map[string][]string{"tmux": {"h1"}}, }, @@ -206,6 +253,11 @@ func TestReaper_Tick(t *testing.T) { Session: "s1", Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeDead, ProcessState: ports.ProcessProbeDead}, }, + { + Kind: "ApplyRuntimeObservation", + Session: "s2", + Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeAlive, ProcessState: ports.ProcessProbeAlive}, + }, }, wantProbe: map[string][]string{"tmux": {"ht"}, "zellij": {"hz"}}, }, From e5919c79989d4d15932c4711571406bdd89548aa Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sat, 30 May 2026 13:11:15 +0530 Subject: [PATCH 044/250] feat(tracker): Tracker port + GitHub adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference implementation; GitLab and Linear follow in separate PRs. Issue observer loop (poll + ApplyTrackerFacts) is deferred to #35. Three-layer split mirrors the SCM layout adil is landing in PR #28: - domain/tracker.go — value types (TrackerProvider, TrackerID, NormalizedIssueState, Issue) - ports/tracker.go — the Tracker interface - adapters/tracker/github/ — REST-backed adapter v1 is write-mostly: Get, Comment, Transition. No cache, no inflight dedup, no polling. State mapping is documented in the package doc and exercised by table-driven tests against an httptest fake — no real GitHub traffic from CI. Co-Authored-By: Claude Opus 4.7 --- .../internal/adapters/tracker/github/auth.go | 52 ++ .../internal/adapters/tracker/github/doc.go | 50 ++ .../adapters/tracker/github/tracker.go | 458 +++++++++++++++ .../adapters/tracker/github/tracker_test.go | 540 ++++++++++++++++++ backend/internal/domain/tracker.go | 49 ++ backend/internal/ports/tracker.go | 25 + 6 files changed, 1174 insertions(+) create mode 100644 backend/internal/adapters/tracker/github/auth.go create mode 100644 backend/internal/adapters/tracker/github/doc.go create mode 100644 backend/internal/adapters/tracker/github/tracker.go create mode 100644 backend/internal/adapters/tracker/github/tracker_test.go create mode 100644 backend/internal/domain/tracker.go create mode 100644 backend/internal/ports/tracker.go diff --git a/backend/internal/adapters/tracker/github/auth.go b/backend/internal/adapters/tracker/github/auth.go new file mode 100644 index 0000000000..9aa810dff3 --- /dev/null +++ b/backend/internal/adapters/tracker/github/auth.go @@ -0,0 +1,52 @@ +package github + +import ( + "context" + "errors" + "os" + "strings" +) + +// TokenSource yields a GitHub bearer token on demand. It is intentionally +// tiny so tests can inject a static token and production can layer env-var or +// gh-CLI fallbacks behind the same surface. The Tracker calls Token once at +// construction (fail-fast) and again per request (so rotated tokens are +// picked up without restart). +type TokenSource interface { + Token(ctx context.Context) (string, error) +} + +// ErrNoToken is returned when no token source could yield a non-empty token. +var ErrNoToken = errors.New("github tracker: no token configured") + +// StaticTokenSource is a literal token, typically used in tests. +type StaticTokenSource string + +func (s StaticTokenSource) Token(context.Context) (string, error) { + t := strings.TrimSpace(string(s)) + if t == "" { + return "", ErrNoToken + } + return t, nil +} + +// EnvTokenSource reads the first non-empty value from the listed env vars, +// falling back to GITHUB_TOKEN. The order matters: a project-configured +// token (e.g. AO_GITHUB_TOKEN) should be preferred over the global default, +// matching the pattern PR #28 uses on the SCM side so both adapters honor +// the same precedence. +type EnvTokenSource struct { + EnvVars []string +} + +func (s EnvTokenSource) Token(context.Context) (string, error) { + for _, name := range s.EnvVars { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return v, nil + } + } + if v := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); v != "" { + return v, nil + } + return "", ErrNoToken +} diff --git a/backend/internal/adapters/tracker/github/doc.go b/backend/internal/adapters/tracker/github/doc.go new file mode 100644 index 0000000000..98bda7c975 --- /dev/null +++ b/backend/internal/adapters/tracker/github/doc.go @@ -0,0 +1,50 @@ +// Package github implements the ports.Tracker outbound port for GitHub +// Issues. v1 is write-mostly: Get returns a normalized Issue snapshot, +// Comment posts an issue comment, and Transition projects the cross-provider +// state vocabulary onto GitHub's open/closed + state_reason + labels surface. +// There is no observer loop or cache — those arrive with issue #35. +// +// # Normalized state mapping +// +// GitHub Issues only have two native states (open, closed) plus a +// state_reason on closed issues (completed, not_planned, reopened). The +// orchestrator's lifecycle vocabulary is richer, so the adapter uses two +// well-known labels — "in-progress" and "in-review" — to project the extra +// states onto open issues. +// +// Normalized state | GitHub API calls performed by Transition +// -----------------+------------------------------------------------------- +// open | PATCH state=open; DELETE labels {in-progress,in-review} +// in_progress | PATCH state=open; POST label in-progress; +// | DELETE label in-review +// review | PATCH state=open; POST label in-review; +// | DELETE label in-progress +// done | PATCH state=closed,state_reason=completed; +// | DELETE labels {in-progress,in-review} +// cancelled | PATCH state=closed,state_reason=not_planned; +// | DELETE labels {in-progress,in-review} +// +// Reverse mapping (Get): GitHub state=closed maps to done if state_reason is +// completed or empty, and to cancelled if state_reason is not_planned. For +// open issues, an "in-review" label wins over "in-progress" (the workflow is +// progress -> review -> done), and the absence of both maps to open. +// +// # Label hygiene and partial failures +// +// DELETE on a label that the issue does not carry returns 404; Transition +// treats that as success so the operation is idempotent. +// +// Transition issues 2-3 HTTP requests sequentially (PATCH, optional POST +// label, DELETE label) and is NOT atomic. If the PATCH succeeds but a +// subsequent label call fails, the issue is left in an intermediate state +// (e.g. closed without the status label cleared). Re-invoking Transition +// with the same target state is safe and converges — callers should treat +// the operation as eventually-consistent and retry on transport errors. +// +// # Out of scope +// +// - No webhook receiver, no polling goroutine, no fact projection into LCM +// (see issue #35 for the observer-loop work). +// - No richer per-provider metadata on Issue (milestones, project boards, +// reactions); the port only carries fields all three v1 providers can fill. +package github diff --git a/backend/internal/adapters/tracker/github/tracker.go b/backend/internal/adapters/tracker/github/tracker.go new file mode 100644 index 0000000000..62d8c89046 --- /dev/null +++ b/backend/internal/adapters/tracker/github/tracker.go @@ -0,0 +1,458 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + defaultBaseURL = "https://api.github.com" + defaultUserAgent = "ao-agent-orchestrator/tracker-github" + + labelInProgress = "in-progress" + labelInReview = "in-review" + + stateOpenGH = "open" + stateClosedGH = "closed" + reasonComplete = "completed" + reasonNotPlan = "not_planned" +) + +// Sentinel errors. Adapter-level callers should match on these via +// errors.Is; the orchestrator's lifecycle code is intentionally insulated +// from raw HTTP status codes. +var ( + ErrNotFound = errors.New("github tracker: issue not found") + ErrRateLimited = errors.New("github tracker: rate limited") + ErrEmptyBody = errors.New("github tracker: comment body is empty") + ErrWrongProvider = errors.New("github tracker: id is not a github tracker id") + ErrUnknownState = errors.New("github tracker: unknown normalized state") + ErrBadID = errors.New("github tracker: malformed native id") +) + +// RateLimitError is returned when GitHub reports the request was rate-limited. +// Callers that want to back off intelligently can extract ResetAt / +// RetryAfter via errors.As; callers that only need the category can use +// errors.Is(err, ErrRateLimited). +type RateLimitError struct { + ResetAt time.Time + RetryAfter time.Duration + Message string +} + +func (e *RateLimitError) Error() string { + if e == nil { + return ErrRateLimited.Error() + } + if e.Message != "" { + return "github tracker: rate limited: " + e.Message + } + return ErrRateLimited.Error() +} + +func (e *RateLimitError) Is(target error) bool { return target == ErrRateLimited } + +// Options configures a Tracker. All fields except Token are optional — +// production code typically sets Token alone; tests inject HTTPClient and +// BaseURL to point at an httptest fake. +type Options struct { + Token TokenSource + HTTPClient *http.Client + BaseURL string + UserAgent string +} + +// Tracker implements ports.Tracker against the GitHub REST API. +// +// Construction performs a fail-fast token presence check (no network call — +// validating the token's authorization scope against GitHub requires a real +// request, and that is the first operation any caller will make anyway). +type Tracker struct { + http *http.Client + tokens TokenSource + baseURL string + userAgent string +} + +// New returns a Tracker. It fails fast when no token can be obtained so +// daemons crash at startup rather than at first issue lookup. +func New(opts Options) (*Tracker, error) { + src := opts.Token + if src == nil { + return nil, ErrNoToken + } + if _, err := src.Token(context.Background()); err != nil { + return nil, err + } + t := &Tracker{ + http: opts.HTTPClient, + tokens: src, + baseURL: opts.BaseURL, + userAgent: opts.UserAgent, + } + if t.http == nil { + t.http = &http.Client{Timeout: 30 * time.Second} + } + if t.baseURL == "" { + t.baseURL = defaultBaseURL + } + if t.userAgent == "" { + t.userAgent = defaultUserAgent + } + return t, nil +} + +// Statically assert Tracker satisfies the port. If this stops compiling, the +// port shape changed and the adapter needs to follow. +var _ ports.Tracker = (*Tracker)(nil) + +// --------------------------------------------------------------------------- +// Get +// --------------------------------------------------------------------------- + +// ghIssue is the subset of fields we read off the REST issue payload. +type ghIssue struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + StateReason string `json:"state_reason"` + HTMLURL string `json:"html_url"` + Labels []ghLabel `json:"labels"` + Assignees []ghUser `json:"assignees"` +} + +type ghLabel struct { + Name string `json:"name"` +} + +type ghUser struct { + Login string `json:"login"` +} + +func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) { + owner, repo, number, err := t.parseID(id) + if err != nil { + return domain.Issue{}, err + } + path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, number) + + resp, err := t.do(ctx, http.MethodGet, path, nil) + if err != nil { + return domain.Issue{}, err + } + var raw ghIssue + if err := json.Unmarshal(resp, &raw); err != nil { + return domain.Issue{}, fmt.Errorf("github tracker: decode issue: %w", err) + } + labels := make([]string, 0, len(raw.Labels)) + for _, l := range raw.Labels { + labels = append(labels, l.Name) + } + assignees := make([]string, 0, len(raw.Assignees)) + for _, a := range raw.Assignees { + assignees = append(assignees, a.Login) + } + out := domain.Issue{ + // Canonicalize Provider so the returned Issue always re-routes back + // to this adapter, even if the caller built id with a zero Provider. + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: id.Native}, + Title: raw.Title, + Body: raw.Body, + State: mapStateFromGitHub(raw.State, raw.StateReason, labels), + URL: raw.HTMLURL, + Labels: labels, + Assignees: assignees, + } + if len(out.Labels) == 0 { + out.Labels = nil + } + if len(out.Assignees) == 0 { + out.Assignees = nil + } + return out, nil +} + +// mapStateFromGitHub projects GitHub's open/closed + state_reason + labels +// surface onto the normalized state. "in-review" wins over "in-progress" +// when both labels are present (the workflow is progress -> review -> done). +func mapStateFromGitHub(state, reason string, labels []string) domain.NormalizedIssueState { + switch strings.ToLower(state) { + case stateClosedGH: + if strings.EqualFold(reason, reasonNotPlan) { + return domain.IssueCancelled + } + return domain.IssueDone + } + var hasProgress, hasReview bool + for _, l := range labels { + switch l { + case labelInProgress: + hasProgress = true + case labelInReview: + hasReview = true + } + } + switch { + case hasReview: + return domain.IssueInReview + case hasProgress: + return domain.IssueInProgress + default: + return domain.IssueOpen + } +} + +// --------------------------------------------------------------------------- +// Comment +// --------------------------------------------------------------------------- + +func (t *Tracker) Comment(ctx context.Context, id domain.TrackerID, body string) error { + if strings.TrimSpace(body) == "" { + return ErrEmptyBody + } + owner, repo, number, err := t.parseID(id) + if err != nil { + return err + } + path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, number) + _, err = t.do(ctx, http.MethodPost, path, map[string]string{"body": body}) + return err +} + +// --------------------------------------------------------------------------- +// Transition +// --------------------------------------------------------------------------- + +// transitionPlan is the per-target-state list of mutations to apply. Every +// transition issues exactly one PATCH on the issue, optionally adds one +// status label, and removes any other status labels the issue may carry. +type transitionPlan struct { + patch map[string]any + addLabel string // "" means none + removeLabel []string +} + +func planForState(state domain.NormalizedIssueState) (transitionPlan, error) { + switch state { + case domain.IssueOpen: + return transitionPlan{ + patch: map[string]any{"state": stateOpenGH}, + removeLabel: []string{labelInProgress, labelInReview}, + }, nil + case domain.IssueInProgress: + return transitionPlan{ + patch: map[string]any{"state": stateOpenGH}, + addLabel: labelInProgress, + removeLabel: []string{labelInReview}, + }, nil + case domain.IssueInReview: + return transitionPlan{ + patch: map[string]any{"state": stateOpenGH}, + addLabel: labelInReview, + removeLabel: []string{labelInProgress}, + }, nil + case domain.IssueDone: + return transitionPlan{ + patch: map[string]any{"state": stateClosedGH, "state_reason": reasonComplete}, + removeLabel: []string{labelInProgress, labelInReview}, + }, nil + case domain.IssueCancelled: + return transitionPlan{ + patch: map[string]any{"state": stateClosedGH, "state_reason": reasonNotPlan}, + removeLabel: []string{labelInProgress, labelInReview}, + }, nil + default: + return transitionPlan{}, fmt.Errorf("%w: %q", ErrUnknownState, state) + } +} + +func (t *Tracker) Transition(ctx context.Context, id domain.TrackerID, state domain.NormalizedIssueState) error { + plan, err := planForState(state) + if err != nil { + return err + } + owner, repo, number, err := t.parseID(id) + if err != nil { + return err + } + issuePath := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, number) + + // 1. Patch state (+ state_reason for closed transitions). + if _, err := t.do(ctx, http.MethodPatch, issuePath, plan.patch); err != nil { + return err + } + // 2. Add the target status label (no-op when target is open/done/cancelled). + if plan.addLabel != "" { + body := map[string][]string{"labels": {plan.addLabel}} + if _, err := t.do(ctx, http.MethodPost, issuePath+"/labels", body); err != nil { + return err + } + } + // 3. Remove the other status labels. 404 from GitHub means "label is not + // on this issue", which is the success state we want — swallow it so + // the operation is idempotent. + for _, label := range plan.removeLabel { + labelPath := issuePath + "/labels/" + url.PathEscape(label) + if _, err := t.do(ctx, http.MethodDelete, labelPath, nil); err != nil { + if errors.Is(err, ErrNotFound) { + continue + } + return err + } + } + return nil +} + +// --------------------------------------------------------------------------- +// HTTP plumbing +// --------------------------------------------------------------------------- + +func (t *Tracker) do(ctx context.Context, method, path string, body any) ([]byte, error) { + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("github tracker: encode body: %w", err) + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, t.baseURL+path, rdr) + if err != nil { + return nil, fmt.Errorf("github tracker: build request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", t.userAgent) + tok, err := t.tokens.Token(ctx) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tok) + + resp, err := t.http.Do(req) + if err != nil { + return nil, fmt.Errorf("github tracker: %s %s: %w", method, path, err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return respBody, nil + } + return respBody, classifyError(resp, respBody) +} + +func classifyError(resp *http.Response, body []byte) error { + msg := githubMessage(body) + switch resp.StatusCode { + case http.StatusNotFound: + return fmt.Errorf("%w: %s", ErrNotFound, msg) + case http.StatusTooManyRequests: + return rateLimited(resp, msg) + case http.StatusForbidden, http.StatusUnauthorized: + // GitHub returns 403 for primary rate-limit exhaustion, for + // secondary/abuse limits, and for genuine auth failures. Three + // signals disambiguate the rate-limit cases from auth: the primary + // limit sets X-RateLimit-Remaining=0; the secondary/abuse limit + // sets Retry-After (and often omits X-RateLimit-Remaining); and + // either case mentions "rate limit" / "abuse" in the body. + if isRateLimited(resp, msg) { + return rateLimited(resp, msg) + } + } + return fmt.Errorf("github tracker: %d %s", resp.StatusCode, msg) +} + +func isRateLimited(resp *http.Response, msg string) bool { + if rem := resp.Header.Get("X-RateLimit-Remaining"); rem != "" { + if n, err := strconv.Atoi(rem); err == nil && n == 0 { + return true + } + } + if resp.Header.Get("Retry-After") != "" { + return true + } + low := strings.ToLower(msg) + return strings.Contains(low, "rate limit") || strings.Contains(low, "abuse detection") +} + +func rateLimited(resp *http.Response, msg string) error { + e := &RateLimitError{Message: msg} + if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" { + if sec, err := strconv.ParseInt(reset, 10, 64); err == nil && sec > 0 { + e.ResetAt = time.Unix(sec, 0) + } + } + if ra := resp.Header.Get("Retry-After"); ra != "" { + if sec, err := strconv.Atoi(ra); err == nil && sec >= 0 { + e.RetryAfter = time.Duration(sec) * time.Second + } + } + return e +} + +func githubMessage(body []byte) string { + var p struct { + Message string `json:"message"` + } + if json.Unmarshal(body, &p) == nil && p.Message != "" { + return p.Message + } + return strings.TrimSpace(string(body)) +} + +// --------------------------------------------------------------------------- +// ID parsing +// --------------------------------------------------------------------------- + +func (t *Tracker) parseID(id domain.TrackerID) (owner, repo string, number int, err error) { + // Strict: the Session Manager picks an adapter by Provider, so reaching + // this adapter with a non-github Provider is a routing bug, not user + // input. Empty Provider is treated the same way — it would round-trip + // to an Issue whose ID can't be re-routed. + if id.Provider != domain.TrackerProviderGitHub { + return "", "", 0, fmt.Errorf("%w: provider=%q", ErrWrongProvider, id.Provider) + } + return parseGitHubID(id.Native) +} + +// parseGitHubID accepts "owner/repo#NUM" and returns the three components. +// Forms like "owner/repo/issues/NUM" or bare numbers are intentionally +// rejected so the rest of the system has one canonical id shape. +func parseGitHubID(native string) (owner, repo string, number int, err error) { + hash := strings.IndexByte(native, '#') + if hash < 0 { + return "", "", 0, fmt.Errorf("%w: missing #issue", ErrBadID) + } + repoPart := native[:hash] + numPart := native[hash+1:] + slash := strings.IndexByte(repoPart, '/') + if slash < 0 { + return "", "", 0, fmt.Errorf("%w: missing owner/repo separator", ErrBadID) + } + owner = repoPart[:slash] + repo = repoPart[slash+1:] + if owner == "" || repo == "" { + return "", "", 0, fmt.Errorf("%w: empty owner or repo", ErrBadID) + } + n, parseErr := strconv.Atoi(numPart) + if parseErr != nil || n <= 0 { + return "", "", 0, fmt.Errorf("%w: bad issue number %q", ErrBadID, numPart) + } + return owner, repo, n, nil +} diff --git a/backend/internal/adapters/tracker/github/tracker_test.go b/backend/internal/adapters/tracker/github/tracker_test.go new file mode 100644 index 0000000000..44f5a6cd7e --- /dev/null +++ b/backend/internal/adapters/tracker/github/tracker_test.go @@ -0,0 +1,540 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// recordedReq captures one inbound HTTP request so tests can assert against +// the exact GitHub API surface the adapter touched. +type recordedReq struct { + Method string + Path string + Body string +} + +// fakeGH is a programmable httptest.Server that matches requests by +// "METHOD path" and records every call. Unmatched requests fail the test — +// that is the point of TDD here, so an accidental extra call is loud. +type fakeGH struct { + t *testing.T + server *httptest.Server + mu sync.Mutex + requests []recordedReq + handlers map[string]http.HandlerFunc +} + +func newFakeGH(t *testing.T) *fakeGH { + t.Helper() + f := &fakeGH{t: t, handlers: map[string]http.HandlerFunc{}} + f.server = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.server.Close) + return f +} + +func (f *fakeGH) on(method, path string, h http.HandlerFunc) { + f.handlers[method+" "+path] = h +} + +func (f *fakeGH) serve(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + f.mu.Lock() + f.requests = append(f.requests, recordedReq{Method: r.Method, Path: r.URL.Path, Body: string(body)}) + f.mu.Unlock() + key := r.Method + " " + r.URL.Path + h, ok := f.handlers[key] + if !ok { + f.t.Errorf("unexpected request: %s", key) + http.Error(w, "no handler", http.StatusNotImplemented) + return + } + r.Body = io.NopCloser(strings.NewReader(string(body))) + h(w, r) +} + +func (f *fakeGH) calls() []recordedReq { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]recordedReq, len(f.requests)) + copy(out, f.requests) + return out +} + +// newTrackerForTest constructs an adapter pointed at the fake server with a +// static dev token. Production code uses EnvTokenSource; tests skip that to +// keep the surface tiny. +func newTrackerForTest(t *testing.T, f *fakeGH) *Tracker { + t.Helper() + tr, err := New(Options{ + BaseURL: f.server.URL, + Token: StaticTokenSource("tkn-test"), + HTTPClient: f.server.Client(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return tr +} + +func ctx() context.Context { return context.Background() } + +func TestNewRejectsMissingToken(t *testing.T) { + if _, err := New(Options{Token: StaticTokenSource("")}); !errors.Is(err, ErrNoToken) { + t.Fatalf("New with empty token = %v, want ErrNoToken", err) + } + if _, err := New(Options{}); !errors.Is(err, ErrNoToken) { + t.Fatalf("New with no source = %v, want ErrNoToken", err) + } +} + +func TestParseID(t *testing.T) { + cases := []struct { + name string + native string + wantOwner string + wantRepo string + wantNum int + wantErr bool + }{ + {"happy", "octocat/hello-world#42", "octocat", "hello-world", 42, false}, + {"missing hash", "octocat/hello-world", "", "", 0, true}, + {"missing slash", "octocat#42", "", "", 0, true}, + {"empty owner", "/repo#1", "", "", 0, true}, + {"empty repo", "owner/#1", "", "", 0, true}, + {"non-numeric", "o/r#abc", "", "", 0, true}, + {"zero", "o/r#0", "", "", 0, true}, + {"negative", "o/r#-1", "", "", 0, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + owner, repo, num, err := parseGitHubID(tc.native) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got %s/%s#%d", owner, repo, num) + } + return + } + if err != nil { + t.Fatalf("parse: %v", err) + } + if owner != tc.wantOwner || repo != tc.wantRepo || num != tc.wantNum { + t.Fatalf("got %s/%s#%d, want %s/%s#%d", owner, repo, num, tc.wantOwner, tc.wantRepo, tc.wantNum) + } + }) + } +} + +func TestGet_HappyPath(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/repos/octocat/hello-world/issues/42", func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer tkn-test" { + t.Errorf("Authorization = %q, want Bearer tkn-test", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "number": 42, + "title": "Found a bug", + "body": "It does not work", + "state": "open", + "html_url": "https://github.com/octocat/hello-world/issues/42", + "labels": [{"name":"bug"},{"name":"in-progress"}], + "assignees": [{"login":"alice"},{"login":"bob"}] + }`)) + }) + tr := newTrackerForTest(t, f) + + issue, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "octocat/hello-world#42"}) + if err != nil { + t.Fatalf("Get: %v", err) + } + want := domain.Issue{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "octocat/hello-world#42"}, + Title: "Found a bug", + Body: "It does not work", + State: domain.IssueInProgress, // the "in-progress" label wins over plain "open" + URL: "https://github.com/octocat/hello-world/issues/42", + Labels: []string{"bug", "in-progress"}, + Assignees: []string{"alice", "bob"}, + } + if !reflect.DeepEqual(issue, want) { + t.Fatalf("issue = %#v\nwant %#v", issue, want) + } +} + +func TestGet_StateMappingFromGitHubFields(t *testing.T) { + cases := []struct { + name string + ghState string + ghReason string + labels []string + wantState domain.NormalizedIssueState + }{ + {"plain open", "open", "", nil, domain.IssueOpen}, + {"open with in-progress label", "open", "", []string{"in-progress"}, domain.IssueInProgress}, + {"open with in-review label", "open", "", []string{"in-review"}, domain.IssueInReview}, + {"review wins over progress when both present", "open", "", []string{"in-progress", "in-review"}, domain.IssueInReview}, + {"closed completed", "closed", "completed", nil, domain.IssueDone}, + {"closed not_planned", "closed", "not_planned", nil, domain.IssueCancelled}, + {"closed unknown reason maps to done", "closed", "", nil, domain.IssueDone}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFakeGH(t) + payload := map[string]any{ + "number": 1, + "title": "t", + "body": "", + "state": tc.ghState, + "html_url": "https://github.com/o/r/issues/1", + } + if tc.ghReason != "" { + payload["state_reason"] = tc.ghReason + } + if tc.labels != nil { + ls := make([]map[string]string, len(tc.labels)) + for i, n := range tc.labels { + ls[i] = map[string]string{"name": n} + } + payload["labels"] = ls + } + b, _ := json.Marshal(payload) + f.on("GET", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(b) + }) + tr := newTrackerForTest(t, f) + issue, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}) + if err != nil { + t.Fatalf("Get: %v", err) + } + if issue.State != tc.wantState { + t.Fatalf("state = %q, want %q", issue.State, tc.wantState) + } + }) + } +} + +func TestGet_NotFound(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestGet_RateLimited(t *testing.T) { + f := newFakeGH(t) + reset := time.Now().Add(2 * time.Minute).Unix() + f.on("GET", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(reset, 10)) + http.Error(w, `{"message":"API rate limit exceeded"}`, http.StatusForbidden) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}) + if !errors.Is(err, ErrRateLimited) { + t.Fatalf("err = %v, want ErrRateLimited", err) + } + var rle *RateLimitError + if !errors.As(err, &rle) { + t.Fatalf("err = %v, want *RateLimitError", err) + } + if got := rle.ResetAt.Unix(); got != reset { + t.Fatalf("ResetAt = %d, want %d", got, reset) + } +} + +// TestGet_SecondaryRateLimit covers the GitHub "abuse detection" +// response — it lacks X-RateLimit-Remaining but sets Retry-After, and the +// body mentions the limit. The classifier must still surface this as +// ErrRateLimited rather than mis-categorizing it as auth failure. +func TestGet_SecondaryRateLimit(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "60") + http.Error(w, `{"message":"You have exceeded a secondary rate limit"}`, http.StatusForbidden) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}) + if !errors.Is(err, ErrRateLimited) { + t.Fatalf("err = %v, want ErrRateLimited", err) + } + var rle *RateLimitError + if !errors.As(err, &rle) { + t.Fatalf("err = %v, want *RateLimitError", err) + } + if rle.RetryAfter != 60*time.Second { + t.Fatalf("RetryAfter = %v, want 60s", rle.RetryAfter) + } +} + +func TestGet_RejectsWrongProvider(t *testing.T) { + f := newFakeGH(t) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitLab, Native: "g/p#1"}) + if !errors.Is(err, ErrWrongProvider) { + t.Fatalf("err = %v, want ErrWrongProvider", err) + } +} + +func TestGet_RejectsEmptyProvider(t *testing.T) { + f := newFakeGH(t) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Native: "o/r#1"}) + if !errors.Is(err, ErrWrongProvider) { + t.Fatalf("err = %v, want ErrWrongProvider", err) + } +} + +// TestGet_CanonicalizesProviderOnOutput pins the contract that returned +// Issues always carry domain.TrackerProviderGitHub, so callers can re-route +// without inspecting which adapter they originally talked to. +func TestGet_CanonicalizesProviderOnOutput(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"number":1,"title":"t","body":"","state":"open","html_url":"https://github.com/o/r/issues/1"}`)) + }) + tr := newTrackerForTest(t, f) + issue, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}) + if err != nil { + t.Fatalf("Get: %v", err) + } + if issue.ID.Provider != domain.TrackerProviderGitHub { + t.Fatalf("issue.ID.Provider = %q, want %q", issue.ID.Provider, domain.TrackerProviderGitHub) + } + if issue.ID.Native != "o/r#1" { + t.Fatalf("issue.ID.Native = %q, want o/r#1", issue.ID.Native) + } +} + +func TestComment_HappyPath(t *testing.T) { + f := newFakeGH(t) + f.on("POST", "/repos/o/r/issues/1/comments", func(w http.ResponseWriter, r *http.Request) { + var got struct { + Body string `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Body != "hello world" { + t.Errorf("body = %q, want %q", got.Body, "hello world") + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":1}`)) + }) + tr := newTrackerForTest(t, f) + if err := tr.Comment(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, "hello world"); err != nil { + t.Fatalf("Comment: %v", err) + } +} + +// TestComment_PreservesMarkdownBody locks in that we POST the body verbatim +// — no trimming, no escape-and-unescape round trip — so multi-line markdown +// notifications from the SM survive. +func TestComment_PreservesMarkdownBody(t *testing.T) { + f := newFakeGH(t) + body := "## status\n\n- step 1: done\n- step 2: **in progress**\n\n```go\nfmt.Println(\"hi\")\n```\n" + f.on("POST", "/repos/o/r/issues/1/comments", func(w http.ResponseWriter, r *http.Request) { + var got struct { + Body string `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Body != body { + t.Errorf("body = %q, want %q", got.Body, body) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":1}`)) + }) + tr := newTrackerForTest(t, f) + if err := tr.Comment(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, body); err != nil { + t.Fatalf("Comment: %v", err) + } +} + +func TestComment_RejectsEmptyBody(t *testing.T) { + f := newFakeGH(t) + tr := newTrackerForTest(t, f) + for _, body := range []string{"", " ", "\n\t"} { + err := tr.Comment(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, body) + if !errors.Is(err, ErrEmptyBody) { + t.Fatalf("body %q: err = %v, want ErrEmptyBody", body, err) + } + } + if calls := f.calls(); len(calls) != 0 { + t.Fatalf("unexpected calls on empty body: %#v", calls) + } +} + +// transitionCall is the normalized record of one GH API call made by +// Transition. The tests compare a sorted slice of these against the expected +// call set so we don't depend on call ordering. +type transitionCall struct { + Method string + Path string + // for PATCH /issues/N — JSON keys we care about + State string + StateReason string + // for POST .../labels — labels added + AddLabels []string +} + +func TestTransition_MapsToCorrectGitHubCalls(t *testing.T) { + cases := []struct { + name string + state domain.NormalizedIssueState + want []transitionCall + }{ + { + name: "open clears status labels and reopens", + state: domain.IssueOpen, + want: []transitionCall{ + {Method: "PATCH", Path: "/repos/o/r/issues/1", State: "open"}, + {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"}, + {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"}, + }, + }, + { + name: "in_progress adds in-progress label, removes in-review", + state: domain.IssueInProgress, + want: []transitionCall{ + {Method: "PATCH", Path: "/repos/o/r/issues/1", State: "open"}, + {Method: "POST", Path: "/repos/o/r/issues/1/labels", AddLabels: []string{"in-progress"}}, + {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"}, + }, + }, + { + name: "review adds in-review label, removes in-progress", + state: domain.IssueInReview, + want: []transitionCall{ + {Method: "PATCH", Path: "/repos/o/r/issues/1", State: "open"}, + {Method: "POST", Path: "/repos/o/r/issues/1/labels", AddLabels: []string{"in-review"}}, + {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"}, + }, + }, + { + name: "done closes as completed and cleans status labels", + state: domain.IssueDone, + want: []transitionCall{ + {Method: "PATCH", Path: "/repos/o/r/issues/1", State: "closed", StateReason: "completed"}, + {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"}, + {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"}, + }, + }, + { + name: "cancelled closes as not_planned and cleans status labels", + state: domain.IssueCancelled, + want: []transitionCall{ + {Method: "PATCH", Path: "/repos/o/r/issues/1", State: "closed", StateReason: "not_planned"}, + {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"}, + {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFakeGH(t) + // PATCH endpoint returns an updated issue body + f.on("PATCH", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"number":1,"state":"open"}`)) + }) + // label-add endpoint + f.on("POST", "/repos/o/r/issues/1/labels", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[]`)) + }) + // label-remove endpoints — return 404 sometimes to confirm we ignore it + f.on("DELETE", "/repos/o/r/issues/1/labels/in-progress", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"Label does not exist"}`, http.StatusNotFound) + }) + f.on("DELETE", "/repos/o/r/issues/1/labels/in-review", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + }) + tr := newTrackerForTest(t, f) + if err := tr.Transition(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, tc.state); err != nil { + t.Fatalf("Transition: %v", err) + } + got := normalizeCalls(t, f.calls()) + want := append([]transitionCall(nil), tc.want...) + sortCalls(got) + sortCalls(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("calls:\n got %#v\n want %#v", got, want) + } + }) + } +} + +func TestTransition_RejectsUnknownState(t *testing.T) { + f := newFakeGH(t) + tr := newTrackerForTest(t, f) + err := tr.Transition(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, domain.NormalizedIssueState("frobnicated")) + if !errors.Is(err, ErrUnknownState) { + t.Fatalf("err = %v, want ErrUnknownState", err) + } + if calls := f.calls(); len(calls) != 0 { + t.Fatalf("unexpected calls: %#v", calls) + } +} + +// normalizeCalls converts the recordedReq slice into transitionCall records +// the test cases assert against, decoding the PATCH/label-add bodies. +func normalizeCalls(t *testing.T, reqs []recordedReq) []transitionCall { + t.Helper() + out := make([]transitionCall, 0, len(reqs)) + for _, r := range reqs { + tc := transitionCall{Method: r.Method, Path: r.Path} + switch { + case r.Method == "PATCH": + var body struct { + State string `json:"state"` + StateReason string `json:"state_reason"` + } + if r.Body != "" { + if err := json.Unmarshal([]byte(r.Body), &body); err != nil { + t.Fatalf("patch body: %v", err) + } + } + tc.State = body.State + tc.StateReason = body.StateReason + case r.Method == "POST" && strings.HasSuffix(r.Path, "/labels"): + var body struct { + Labels []string `json:"labels"` + } + if r.Body != "" { + if err := json.Unmarshal([]byte(r.Body), &body); err != nil { + t.Fatalf("labels body: %v", err) + } + } + tc.AddLabels = body.Labels + } + out = append(out, tc) + } + return out +} + +func sortCalls(s []transitionCall) { + sort.Slice(s, func(i, j int) bool { + if s[i].Method != s[j].Method { + return s[i].Method < s[j].Method + } + return s[i].Path < s[j].Path + }) +} diff --git a/backend/internal/domain/tracker.go b/backend/internal/domain/tracker.go new file mode 100644 index 0000000000..202c6bb179 --- /dev/null +++ b/backend/internal/domain/tracker.go @@ -0,0 +1,49 @@ +package domain + +// TrackerProvider identifies an issue-tracker provider implementation. +// Provider differences (label-driven vs state-machine vs close-reason) are +// absorbed inside each adapter; the rest of the system only sees +// NormalizedIssueState. +type TrackerProvider string + +const ( + TrackerProviderGitHub TrackerProvider = "github" + TrackerProviderGitLab TrackerProvider = "gitlab" + TrackerProviderLinear TrackerProvider = "linear" +) + +// TrackerID identifies a single issue across providers. Native is the +// provider's own canonical form ("owner/repo#123" for GitHub, +// "group/project#456" for GitLab, "ABC-789" for Linear) and is parsed by the +// adapter. Provider is the discriminator the Session Manager uses to pick an +// adapter. +type TrackerID struct { + Provider TrackerProvider `json:"provider"` + Native string `json:"native"` +} + +// NormalizedIssueState is the cross-provider issue-state vocabulary every +// adapter must implement. The closed list is intentional — adding a value +// here is a port-level decision because every adapter must map it. +type NormalizedIssueState string + +const ( + IssueOpen NormalizedIssueState = "open" + IssueInProgress NormalizedIssueState = "in_progress" + IssueInReview NormalizedIssueState = "review" + IssueDone NormalizedIssueState = "done" + IssueCancelled NormalizedIssueState = "cancelled" +) + +// Issue is the minimum projection every tracker can produce. Fields are +// added only when all v1 providers (GitHub, GitLab, Linear) can populate +// them faithfully; richer metadata stays inside provider-specific code paths. +type Issue struct { + ID TrackerID `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + State NormalizedIssueState `json:"state"` + URL string `json:"url"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` +} diff --git a/backend/internal/ports/tracker.go b/backend/internal/ports/tracker.go new file mode 100644 index 0000000000..642b1912a4 --- /dev/null +++ b/backend/internal/ports/tracker.go @@ -0,0 +1,25 @@ +package ports + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// Tracker is the outbound port for issue trackers (GitHub Issues, GitLab +// Issues, Linear). v1 is write-mostly: spawn-bootstrap reads with Get, the +// Session Manager posts status updates with Comment, and lifecycle +// transitions (start, hand-off-to-review, close) propagate with Transition. +// There is no observer loop yet; polling and ApplyTrackerFacts arrive with +// issue #35. +// +// All three v1 providers share this interface. Provider differences (label +// vs state machine vs close reason) are absorbed inside each adapter via +// domain.NormalizedIssueState. Fields on domain.Issue exist only when every +// provider can populate them; richer per-provider metadata belongs behind a +// separate port. +type Tracker interface { + Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) + Comment(ctx context.Context, id domain.TrackerID, body string) error + Transition(ctx context.Context, id domain.TrackerID, state domain.NormalizedIssueState) error +} From f5bc4c7b8c70ff1bf9577bc8611beb016af174c0 Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Sat, 30 May 2026 16:02:07 +0530 Subject: [PATCH 045/250] feat(backend): SQLite storage layer + CDC pipeline, LCM/reaper wiring Add the two real outbound adapters that replace the in-memory fakeStore: internal/storage/sqlite (persistence satisfying ports.LifecycleStore) and internal/cdc (transactional-outbox publisher, JSONL delivery, durable consumer). Wire them into main.go alongside the Lifecycle Manager and reaper so the write path is live end-to-end: LCM.Upsert -> store -> outbox -> JSONL -> broadcaster. Storage (internal/storage/sqlite): - modernc.org/sqlite (pure Go, no CGO) for clean cross-compile; goose embedded migrations; sqlc-generated typed queries under gen/. - Atomic Upsert: session row + change_log + outbox written in one tx. - revision is an optimistic-concurrency (CAS) check: insert requires revision 0 and persists 1; update requires loaded revision == stored and bumps +1; zero rows affected returns a revision-mismatch error. - Metadata is an opaque map in session_metadata, off the CDC path. - Durable reaction_trackers (fixes the in-memory-only escalation budget that re-fired human pages on restart). CDC (internal/cdc): - Publisher drains the outbox to a JSONL log; size-based rotation with a reset marker. - Consumer tails via byte cursor, detects rotation (os.SameFile), resyncs from a full-state snapshot on gaps, and tracks a durable consumer_offsets cursor. - Janitor reclaims acknowledged outbox rows. - Broadcaster is the in-process fan-out port the FE transport will subscribe to (WS/SSE wiring deferred). Composition root (main.go + *_wiring.go): - startCDC stands up publisher/consumer/janitor + broadcaster. - startLifecycle constructs the LCM, makes escalation budgets durable via WithReactionStore, teaches it to enumerate sessions via WithSessionLister, and starts the reaper. - Notifier, AgentMessenger, and the reaper's runtime registry are TEMPORARY no-op/empty stubs (lifecycle_wiring.go) with TODO markers; see the PR description for how to fill them in. Tests: contract-parity, revision CAS, outbox atomicity, CDC ordering and idempotency, rotation/resync, janitor vacuum, reaction durability across a simulated restart, and composition-root adapters. gofmt/build/vet clean and go test -race ./... green. --- .gitignore | 9 + backend/cdc_wiring.go | 143 ++++++++ backend/go.mod | 24 +- backend/go.sum | 66 ++++ backend/internal/cdc/broadcast.go | 44 +++ backend/internal/cdc/cdc_integration_test.go | 256 +++++++++++++++ backend/internal/cdc/consumer.go | 221 +++++++++++++ backend/internal/cdc/event.go | 32 ++ backend/internal/cdc/janitor.go | 84 +++++ backend/internal/cdc/jsonl.go | 109 +++++++ backend/internal/cdc/publisher.go | 115 +++++++ backend/internal/config/config.go | 24 ++ backend/internal/lifecycle/manager.go | 7 +- .../internal/lifecycle/manager_parity_test.go | 144 ++++++++ .../lifecycle/reaction_durability_test.go | 140 ++++++++ backend/internal/lifecycle/reaction_store.go | 94 ++++++ backend/internal/lifecycle/reactions.go | 30 +- backend/internal/storage/sqlite/cdc_store.go | 104 ++++++ backend/internal/storage/sqlite/db.go | 63 ++++ .../internal/storage/sqlite/gen/cdc.sql.go | 199 ++++++++++++ backend/internal/storage/sqlite/gen/db.go | 31 ++ .../storage/sqlite/gen/metadata.sql.go | 59 ++++ backend/internal/storage/sqlite/gen/models.go | 74 +++++ .../internal/storage/sqlite/gen/querier.go | 42 +++ .../storage/sqlite/gen/reactions.sql.go | 100 ++++++ .../storage/sqlite/gen/sessions.sql.go | 307 ++++++++++++++++++ backend/internal/storage/sqlite/mapping.go | 129 ++++++++ .../storage/sqlite/migrations/0001_init.sql | 109 +++++++ .../internal/storage/sqlite/queries/cdc.sql | 42 +++ .../storage/sqlite/queries/metadata.sql | 7 + .../storage/sqlite/queries/reactions.sql | 18 + .../storage/sqlite/queries/sessions.sql | 58 ++++ .../internal/storage/sqlite/reaction_store.go | 80 +++++ backend/internal/storage/sqlite/spike_test.go | 92 ++++++ backend/internal/storage/sqlite/store.go | 118 +++++++ backend/internal/storage/sqlite/store_test.go | 256 +++++++++++++++ backend/internal/storage/sqlite/upsert.go | 113 +++++++ backend/lifecycle_wiring.go | 126 +++++++ backend/main.go | 44 +++ backend/main_test.go | 134 ++++++++ backend/sqlc.yaml | 13 + 41 files changed, 3849 insertions(+), 11 deletions(-) create mode 100644 backend/cdc_wiring.go create mode 100644 backend/internal/cdc/broadcast.go create mode 100644 backend/internal/cdc/cdc_integration_test.go create mode 100644 backend/internal/cdc/consumer.go create mode 100644 backend/internal/cdc/event.go create mode 100644 backend/internal/cdc/janitor.go create mode 100644 backend/internal/cdc/jsonl.go create mode 100644 backend/internal/cdc/publisher.go create mode 100644 backend/internal/lifecycle/manager_parity_test.go create mode 100644 backend/internal/lifecycle/reaction_durability_test.go create mode 100644 backend/internal/lifecycle/reaction_store.go create mode 100644 backend/internal/storage/sqlite/cdc_store.go create mode 100644 backend/internal/storage/sqlite/db.go create mode 100644 backend/internal/storage/sqlite/gen/cdc.sql.go create mode 100644 backend/internal/storage/sqlite/gen/db.go create mode 100644 backend/internal/storage/sqlite/gen/metadata.sql.go create mode 100644 backend/internal/storage/sqlite/gen/models.go create mode 100644 backend/internal/storage/sqlite/gen/querier.go create mode 100644 backend/internal/storage/sqlite/gen/reactions.sql.go create mode 100644 backend/internal/storage/sqlite/gen/sessions.sql.go create mode 100644 backend/internal/storage/sqlite/mapping.go create mode 100644 backend/internal/storage/sqlite/migrations/0001_init.sql create mode 100644 backend/internal/storage/sqlite/queries/cdc.sql create mode 100644 backend/internal/storage/sqlite/queries/metadata.sql create mode 100644 backend/internal/storage/sqlite/queries/reactions.sql create mode 100644 backend/internal/storage/sqlite/queries/sessions.sql create mode 100644 backend/internal/storage/sqlite/reaction_store.go create mode 100644 backend/internal/storage/sqlite/spike_test.go create mode 100644 backend/internal/storage/sqlite/store.go create mode 100644 backend/internal/storage/sqlite/store_test.go create mode 100644 backend/internal/storage/sqlite/upsert.go create mode 100644 backend/lifecycle_wiring.go create mode 100644 backend/main_test.go create mode 100644 backend/sqlc.yaml diff --git a/.gitignore b/.gitignore index e5ea212a2b..425b31d78a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,15 @@ vendor/ /backend/backend agent-orchestrator.yaml +# Backend runtime data artifacts (SQLite store + WAL, CDC event log). +# Created at AO_DATA_DIR (outside the repo by default); ignored here so a +# data dir pointed at the tree never gets committed. +*.db +*.db-shm +*.db-wal +session-events.jsonl +session-events.jsonl.* + # Environment .env .env.* diff --git a/backend/cdc_wiring.go b/backend/cdc_wiring.go new file mode 100644 index 0000000000..89997e7dcb --- /dev/null +++ b/backend/cdc_wiring.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "path/filepath" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// cdcConsumerName is the durable consumer_offsets key for the in-process FE +// broadcast consumer. A second transport (e.g. a cloud relay) would use its own +// key so each tracks an independent cursor. +const cdcConsumerName = "fe-broadcast" + +// cdcPipeline owns the running CDC goroutines and the broadcaster the FE +// transport subscribes to. It is the durable change-delivery substrate: the +// publisher drains the outbox to JSONL, the consumer tails the log and fans out +// through the broadcaster, and the janitor reclaims acknowledged outbox rows. +type cdcPipeline struct { + Broadcaster *cdc.Broadcaster + log *cdc.Log + dones []<-chan struct{} +} + +// startCDC opens the JSONL log and starts the publisher, consumer, and janitor +// against store, returning a handle whose Stop waits for the goroutines to +// drain after ctx is cancelled. The goroutines stop when ctx is cancelled. +func startCDC(ctx context.Context, store *sqlite.Store, dataDir string, logger *slog.Logger) (*cdcPipeline, error) { + log, err := cdc.OpenLog(dataDir, 0) + if err != nil { + return nil, fmt.Errorf("open cdc log: %w", err) + } + + bcast := cdc.NewBroadcaster() + logPath := filepath.Join(dataDir, cdc.LogFileName) + + pub := cdc.NewPublisher(outboxAdapter{store}, log, cdc.PublisherConfig{Logger: logger}) + con := cdc.NewConsumer(cdcConsumerName, logPath, store, bcast, cdc.ConsumerConfig{ + Snapshot: snapshotSource{store}, + Logger: logger, + }) + jan := cdc.NewJanitor(store, cdc.JanitorConfig{Logger: logger}) + + conDone, err := con.Start(ctx) + if err != nil { + log.Close() + return nil, fmt.Errorf("start cdc consumer: %w", err) + } + + return &cdcPipeline{ + Broadcaster: bcast, + log: log, + dones: []<-chan struct{}{pub.Start(ctx), conDone, jan.Start(ctx)}, + }, nil +} + +// Stop waits for every CDC goroutine to exit (the caller must have cancelled the +// ctx passed to startCDC) and closes the log file. +func (p *cdcPipeline) Stop() error { + for _, d := range p.dones { + <-d + } + return p.log.Close() +} + +// outboxAdapter bridges *sqlite.Store's outbox methods to cdc.OutboxStore, +// mapping the storage-native OutboxEvent to the transport's PendingEvent. (The +// offset and vacuum contracts need no adapter — *sqlite.Store satisfies +// cdc.OffsetStore and cdc.Vacuum directly.) +type outboxAdapter struct{ store *sqlite.Store } + +func (a outboxAdapter) ListUnsent(ctx context.Context, limit int) ([]cdc.PendingEvent, error) { + evs, err := a.store.ListUnsent(ctx, limit) + if err != nil { + return nil, err + } + out := make([]cdc.PendingEvent, len(evs)) + for i, e := range evs { + out[i] = cdc.PendingEvent{ + OutboxID: e.OutboxID, + Event: cdc.Event{ + Seq: e.Seq, + SessionID: e.SessionID, + EventType: e.EventType, + Revision: e.Revision, + Payload: e.Payload, + CreatedAt: e.CreatedAt, + }, + } + } + return out, nil +} + +func (a outboxAdapter) MarkSent(ctx context.Context, id int64, at time.Time) error { + return a.store.MarkSent(ctx, id, at) +} + +func (a outboxAdapter) MarkFailed(ctx context.Context, id int64, msg string) error { + return a.store.MarkFailed(ctx, id, msg) +} + +// snapshotSource rebuilds current state from the sessions table after a +// log-rotation gap, emitting one full-state event per session. Each event +// carries the change_log high-water seq so the consumer resumes its cursor +// there; the payload mirrors the canonical change_log payload (metadata +// excluded, version stamped) so subscribers parse snapshot and live events the +// same way. +type snapshotSource struct{ store *sqlite.Store } + +func (s snapshotSource) Snapshot(ctx context.Context) ([]cdc.Event, int64, error) { + recs, err := s.store.ListAll(ctx) + if err != nil { + return nil, 0, err + } + maxSeq, err := s.store.MaxChangeLogSeq(ctx) + if err != nil { + return nil, 0, err + } + events := make([]cdc.Event, 0, len(recs)) + for _, r := range recs { + r.Lifecycle.Version = domain.LifecycleVersion + r.Metadata = nil + blob, err := json.Marshal(r) + if err != nil { + return nil, 0, fmt.Errorf("marshal snapshot %s: %w", r.ID, err) + } + events = append(events, cdc.Event{ + Seq: maxSeq, + SessionID: string(r.ID), + EventType: "session_snapshot", + Revision: int64(r.Lifecycle.Revision), + Payload: string(blob), + CreatedAt: r.UpdatedAt, + }) + } + return events, maxSeq, nil +} diff --git a/backend/go.mod b/backend/go.mod index 311cea2887..88ca590cfb 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,5 +1,25 @@ module github.com/aoagents/agent-orchestrator/backend -go 1.22 +go 1.25.7 -require github.com/go-chi/chi/v5 v5.1.0 +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/pressly/goose/v3 v3.27.1 + modernc.org/sqlite v1.51.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum index 823cdbb1ac..89f839295e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,2 +1,68 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= +modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/cdc/broadcast.go b/backend/internal/cdc/broadcast.go new file mode 100644 index 0000000000..a7458e38db --- /dev/null +++ b/backend/internal/cdc/broadcast.go @@ -0,0 +1,44 @@ +package cdc + +import "sync" + +// Broadcaster is the in-process fan-out the consumer feeds. Subscribers (the +// WS/SSE transport, wired in the frontend task) register a callback; every +// consumed Event is delivered to all current subscribers. It is the single +// seam between the CDC pipeline and live delivery, so the transport can be +// built and swapped without touching the pipeline. +type Broadcaster struct { + mu sync.RWMutex + nextID int + subs map[int]func(Event) +} + +// NewBroadcaster returns an empty Broadcaster ready for subscriptions. +func NewBroadcaster() *Broadcaster { + return &Broadcaster{subs: map[int]func(Event){}} +} + +// Subscribe registers fn and returns an unsubscribe function. fn is called +// synchronously from the consumer loop, so it must not block; a transport that +// needs buffering should push onto its own channel inside fn. +func (b *Broadcaster) Subscribe(fn func(Event)) (unsubscribe func()) { + b.mu.Lock() + id := b.nextID + b.nextID++ + b.subs[id] = fn + b.mu.Unlock() + return func() { + b.mu.Lock() + delete(b.subs, id) + b.mu.Unlock() + } +} + +// Publish delivers e to every current subscriber. +func (b *Broadcaster) Publish(e Event) { + b.mu.RLock() + defer b.mu.RUnlock() + for _, fn := range b.subs { + fn(e) + } +} diff --git a/backend/internal/cdc/cdc_integration_test.go b/backend/internal/cdc/cdc_integration_test.go new file mode 100644 index 0000000000..9390afe012 --- /dev/null +++ b/backend/internal/cdc/cdc_integration_test.go @@ -0,0 +1,256 @@ +package cdc_test + +import ( + "context" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// outboxAdapter bridges sqlite.Store's outbox methods to cdc.OutboxStore. This +// is the same glue the composition root (main.go) installs. +type outboxAdapter struct{ s *sqlite.Store } + +func (a outboxAdapter) ListUnsent(ctx context.Context, limit int) ([]cdc.PendingEvent, error) { + evs, err := a.s.ListUnsent(ctx, limit) + if err != nil { + return nil, err + } + out := make([]cdc.PendingEvent, len(evs)) + for i, e := range evs { + out[i] = cdc.PendingEvent{ + OutboxID: e.OutboxID, + Event: cdc.Event{ + Seq: e.Seq, + SessionID: e.SessionID, + EventType: e.EventType, + Revision: e.Revision, + Payload: e.Payload, + CreatedAt: e.CreatedAt, + }, + } + } + return out, nil +} + +func (a outboxAdapter) MarkSent(ctx context.Context, id int64, at time.Time) error { + return a.s.MarkSent(ctx, id, at) +} +func (a outboxAdapter) MarkFailed(ctx context.Context, id int64, msg string) error { + return a.s.MarkFailed(ctx, id, msg) +} + +func newStore(t *testing.T) *sqlite.Store { + t.Helper() + db, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { db.Close() }) + return sqlite.NewStore(db) +} + +func rec(id string) domain.SessionRecord { + now := time.Now().UTC() + return domain.SessionRecord{ + ID: domain.SessionID(id), ProjectID: "p", Kind: domain.KindWorker, CreatedAt: now, UpdatedAt: now, + Lifecycle: domain.CanonicalSessionLifecycle{ + Session: domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress}, + PR: domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonNotCreated}, + Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, + Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, + }, + } +} + +func TestEndToEndPublishConsume(t *testing.T) { + ctx := context.Background() + store := newStore(t) + dir := t.TempDir() + log, err := cdc.OpenLog(dir, 0) + if err != nil { + t.Fatal(err) + } + defer log.Close() + + // Three canonical writes => three outbox rows, seq 1..3. + r := rec("s1") + if err := store.Upsert(ctx, r, ports.EventSessionCreated); err != nil { + t.Fatal(err) + } + r.Lifecycle.Revision = 1 + if err := store.Upsert(ctx, r, ports.EventSessionStateChanged); err != nil { + t.Fatal(err) + } + r.Lifecycle.Revision = 2 + if err := store.Upsert(ctx, r, ports.EventSessionStateChanged); err != nil { + t.Fatal(err) + } + + pub := cdc.NewPublisher(outboxAdapter{store}, log, cdc.PublisherConfig{}) + if err := pub.Drain(ctx); err != nil { + t.Fatalf("drain: %v", err) + } + + var got []cdc.Event + bc := cdc.NewBroadcaster() + bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) + + con := cdc.NewConsumer("fe", dir+"/"+cdc.LogFileName, store, bc, cdc.ConsumerConfig{}) + if _, err := con.Start(ctx); err != nil { + t.Fatal(err) + } + // Drive one poll synchronously instead of waiting on the goroutine. + if err := con.Poll(ctx); err != nil { + t.Fatalf("poll: %v", err) + } + + if len(got) != 3 { + t.Fatalf("delivered %d events, want 3", len(got)) + } + for i, e := range got { + if e.Seq != int64(i+1) { + t.Fatalf("event %d has seq %d, want %d", i, e.Seq, i+1) + } + } + if got[0].EventType != string(ports.EventSessionCreated) { + t.Fatalf("first event type = %q", got[0].EventType) + } + + // Idempotency: a second poll with no new bytes delivers nothing more. + if err := con.Poll(ctx); err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Fatalf("re-poll delivered extra events: %d", len(got)) + } + + // Offset persisted at seq 3. + off, _ := store.GetOffset(ctx, "fe") + if off != 3 { + t.Fatalf("offset = %d, want 3", off) + } + + // Janitor: consumer ACKed 3, so sent rows with seq < 3 are reclaimed. + jan := cdc.NewJanitor(store, cdc.JanitorConfig{}) + deleted, err := jan.Sweep(ctx) + if err != nil { + t.Fatal(err) + } + if deleted != 2 { + t.Fatalf("janitor deleted %d, want 2 (seq 1,2 < watermark 3)", deleted) + } +} + +func TestConsumerRestartSkipsDelivered(t *testing.T) { + ctx := context.Background() + store := newStore(t) + dir := t.TempDir() + log, _ := cdc.OpenLog(dir, 0) + defer log.Close() + + if err := store.Upsert(ctx, rec("s1"), ports.EventSessionCreated); err != nil { + t.Fatal(err) + } + pub := cdc.NewPublisher(outboxAdapter{store}, log, cdc.PublisherConfig{}) + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + + // Pre-seed the durable offset as if a prior consumer already delivered seq 1. + if err := store.SetOffset(ctx, "fe", 1, time.Now().UTC()); err != nil { + t.Fatal(err) + } + + var got []cdc.Event + bc := cdc.NewBroadcaster() + bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) + con := cdc.NewConsumer("fe", dir+"/"+cdc.LogFileName, store, bc, cdc.ConsumerConfig{}) + if _, err := con.Start(ctx); err != nil { + t.Fatal(err) + } + if err := con.Poll(ctx); err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("restart re-delivered already-acked events: %d", len(got)) + } +} + +// fakeSnapshot stands in for the sessions-table snapshot source on resync. +type fakeSnapshot struct { + events []cdc.Event + maxSeq int64 +} + +func (f fakeSnapshot) Snapshot(context.Context) ([]cdc.Event, int64, error) { + return f.events, f.maxSeq, nil +} + +func TestRotationTriggersResync(t *testing.T) { + ctx := context.Background() + store := newStore(t) + dir := t.TempDir() + // Tiny cap so a couple of writes force a rotation. + log, err := cdc.OpenLog(dir, 80) + if err != nil { + t.Fatal(err) + } + defer log.Close() + + var got []cdc.Event + bc := cdc.NewBroadcaster() + bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) + + snap := fakeSnapshot{events: []cdc.Event{{Seq: 5, SessionID: "s1", EventType: "session_updated"}}, maxSeq: 5} + con := cdc.NewConsumer("fe", dir+"/"+cdc.LogFileName, store, bc, cdc.ConsumerConfig{Snapshot: snap}) + if _, err := con.Start(ctx); err != nil { + t.Fatal(err) + } + + pub := cdc.NewPublisher(outboxAdapter{store}, log, cdc.PublisherConfig{}) + + // First write + drain + poll: consumer reads it and advances its cursor. + if err := store.Upsert(ctx, rec("s1"), ports.EventSessionCreated); err != nil { + t.Fatal(err) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + if err := con.Poll(ctx); err != nil { + t.Fatal(err) + } + cursorBefore := len(got) + + // Force rotation by writing past the cap, then poll: the file shrank, so the + // consumer must resync from the snapshot source. + r := rec("s1") + r.Lifecycle.Revision = 1 + if err := store.Upsert(ctx, r, ports.EventSessionStateChanged); err != nil { + t.Fatal(err) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + if err := con.Poll(ctx); err != nil { + t.Fatal(err) + } + + if len(got) <= cursorBefore { + t.Fatal("expected resync to deliver the snapshot event") + } + // The snapshot event (seq 5) must be among the delivered events. + var sawSnapshot bool + for _, e := range got { + if e.Seq == 5 { + sawSnapshot = true + } + } + if !sawSnapshot { + t.Fatalf("resync did not deliver snapshot event; got %+v", got) + } +} diff --git a/backend/internal/cdc/consumer.go b/backend/internal/cdc/consumer.go new file mode 100644 index 0000000000..00edb0f103 --- /dev/null +++ b/backend/internal/cdc/consumer.go @@ -0,0 +1,221 @@ +package cdc + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "time" +) + +// DefaultPollInterval is how often the consumer checks the log for new bytes. +// Polling (rather than fs-notify) keeps the consumer dependency-free; at this +// cadence live updates stay well under a human-perceptible delay. +const DefaultPollInterval = 100 * time.Millisecond + +// OffsetStore persists the consumer's durable seq cursor (at-least-once). +type OffsetStore interface { + GetOffset(ctx context.Context, consumer string) (int64, error) + SetOffset(ctx context.Context, consumer string, seq int64, at time.Time) error +} + +// SnapshotSource rebuilds current state from the source of truth (the sessions +// table) after a rotation gap, where log lines for unconsumed-but-already-sent +// events were truncated away. It returns one Event per live session plus the +// MAX(change_log seq) the snapshot corresponds to, so the consumer can resume. +type SnapshotSource interface { + Snapshot(ctx context.Context) (events []Event, maxSeq int64, err error) +} + +// Consumer tails the JSONL log, deduplicates by seq, and fans each new event +// out through the Broadcaster, persisting its durable offset as it goes. +type Consumer struct { + name string + path string + offsets OffsetStore + bcast *Broadcaster + snapshot SnapshotSource + interval time.Duration + clock func() time.Time + logger *slog.Logger + + cursor int64 // byte offset into the log + lastSeq int64 // highest seq delivered + prevInfo os.FileInfo // identity of the file last polled (rotation detection) +} + +// ConsumerConfig holds optional knobs and the snapshot source. +type ConsumerConfig struct { + Snapshot SnapshotSource + Interval time.Duration + Clock func() time.Time + Logger *slog.Logger +} + +// NewConsumer constructs a Consumer named name (the consumer_offsets key) over +// the log at path, fanning out through bcast and persisting offsets via offsets. +func NewConsumer(name, path string, offsets OffsetStore, bcast *Broadcaster, cfg ConsumerConfig) *Consumer { + c := &Consumer{ + name: name, + path: path, + offsets: offsets, + bcast: bcast, + snapshot: cfg.Snapshot, + interval: cfg.Interval, + clock: cfg.Clock, + logger: cfg.Logger, + } + if c.interval <= 0 { + c.interval = DefaultPollInterval + } + if c.clock == nil { + c.clock = time.Now + } + if c.logger == nil { + c.logger = slog.Default() + } + return c +} + +// Start loads the durable offset and runs the poll loop until ctx is cancelled; +// the returned channel closes when the loop has exited. +func (c *Consumer) Start(ctx context.Context) (<-chan struct{}, error) { + seq, err := c.offsets.GetOffset(ctx, c.name) + if err != nil { + return nil, fmt.Errorf("load consumer offset: %w", err) + } + c.lastSeq = seq + + done := make(chan struct{}) + go func() { + defer close(done) + t := time.NewTicker(c.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := c.Poll(ctx); err != nil { + c.logger.Error("cdc consumer: poll failed", "err", err) + } + } + } + }() + return done, nil +} + +// Poll reads any new bytes since the last cursor and delivers complete lines. It +// detects rotation (the file shrank below the cursor) and resyncs from the DB +// snapshot before resuming. +func (c *Consumer) Poll(ctx context.Context) error { + f, err := os.Open(c.path) + if err != nil { + if os.IsNotExist(err) { + return nil // publisher has not created the log yet + } + return fmt.Errorf("open cdc log: %w", err) + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return fmt.Errorf("stat cdc log: %w", err) + } + size := info.Size() + + rotated := (c.prevInfo != nil && !os.SameFile(c.prevInfo, info)) || size < c.cursor + c.prevInfo = info + if rotated { + // The previous file's bytes are void. Resync from the DB snapshot (if + // wired), then resume reading the fresh file from the top. + if err := c.resync(ctx); err != nil { + return err + } + c.cursor = 0 + } + if size == c.cursor { + return nil + } + + if _, err := f.Seek(c.cursor, io.SeekStart); err != nil { + return fmt.Errorf("seek cdc log: %w", err) + } + data, err := io.ReadAll(f) + if err != nil { + return fmt.Errorf("read cdc log: %w", err) + } + + consumed, maxSeq := c.processLines(data) + c.cursor += int64(consumed) + + if maxSeq > c.lastSeq { + c.lastSeq = maxSeq + if err := c.offsets.SetOffset(ctx, c.name, c.lastSeq, c.clock().UTC()); err != nil { + return fmt.Errorf("persist consumer offset: %w", err) + } + } + return nil +} + +// processLines delivers each complete (newline-terminated) line, skipping reset +// markers and any event whose seq was already delivered. It returns the number +// of bytes consumed (only complete lines) and the highest seq seen. +func (c *Consumer) processLines(data []byte) (consumed int, maxSeq int64) { + maxSeq = c.lastSeq + for { + nl := bytes.IndexByte(data[consumed:], '\n') + if nl < 0 { + return consumed, maxSeq // partial trailing line: leave for next poll + } + line := data[consumed : consumed+nl] + consumed += nl + 1 + + if isResetMarker(line) { + continue + } + var e Event + if err := json.Unmarshal(line, &e); err != nil { + c.logger.Error("cdc consumer: bad line skipped", "err", err) + continue + } + if e.Seq <= c.lastSeq { + continue // idempotent: already delivered + } + c.bcast.Publish(e) + if e.Seq > maxSeq { + maxSeq = e.Seq + } + } +} + +func (c *Consumer) resync(ctx context.Context) error { + if c.snapshot == nil { + return nil + } + events, maxSeq, err := c.snapshot.Snapshot(ctx) + if err != nil { + return fmt.Errorf("cdc consumer resync: %w", err) + } + for _, e := range events { + c.bcast.Publish(e) + } + if maxSeq > c.lastSeq { + c.lastSeq = maxSeq + if err := c.offsets.SetOffset(ctx, c.name, c.lastSeq, c.clock().UTC()); err != nil { + return fmt.Errorf("persist offset after resync: %w", err) + } + } + return nil +} + +func isResetMarker(line []byte) bool { + var m resetMarker + if err := json.Unmarshal(line, &m); err != nil { + return false + } + return m.Type == "reset" +} diff --git a/backend/internal/cdc/event.go b/backend/internal/cdc/event.go new file mode 100644 index 0000000000..b0eddf9829 --- /dev/null +++ b/backend/internal/cdc/event.go @@ -0,0 +1,32 @@ +// Package cdc is the change-data-capture pipeline that turns the storage layer's +// transactional outbox into a durable, ordered event stream for the frontend. +// +// The flow: the publisher drains the SQLite outbox (sent=0, seq order) and +// appends each change as one JSON line to a rotating log file. The consumer +// tails that file from a durable byte cursor, deduplicates by seq, and fans each +// change out through the Broadcaster to in-process subscribers (the WS/SSE +// transport, wired later). The janitor reclaims outbox rows every consumer has +// acknowledged. Delivery is at-least-once; seq is the idempotency key. +package cdc + +import "time" + +// Event is one change-data-capture record. It is the JSONL line shape and the +// value handed to Broadcaster subscribers. Seq is the monotonic ordering and +// idempotency key (the change_log seq). +type Event struct { + Seq int64 `json:"seq"` + SessionID string `json:"sessionId"` + EventType string `json:"eventType"` + Revision int64 `json:"revision"` + Payload string `json:"payload"` + CreatedAt time.Time `json:"createdAt"` +} + +// resetMarker is written as the first line of a freshly rotated log file. A +// consumer that reads it knows the byte offsets of the previous file are void +// and must snapshot-resync, then resume from the current MAX(seq). +type resetMarker struct { + Type string `json:"type"` // always "reset" + RotatedAt time.Time `json:"rotatedAt"` +} diff --git a/backend/internal/cdc/janitor.go b/backend/internal/cdc/janitor.go new file mode 100644 index 0000000000..3968b2cf41 --- /dev/null +++ b/backend/internal/cdc/janitor.go @@ -0,0 +1,84 @@ +package cdc + +import ( + "context" + "log/slog" + "time" +) + +// DefaultJanitorInterval is the outbox-vacuum cadence. +const DefaultJanitorInterval = 60 * time.Second + +// Vacuum is the janitor's view of storage: the safe deletion watermark and the +// delete itself. +type Vacuum interface { + MinConsumerOffset(ctx context.Context) (int64, error) + DeleteSentOutboxBelow(ctx context.Context, seq int64) (int64, error) +} + +// Janitor reclaims delivered outbox rows every consumer has acknowledged. +// +// Watermark: MIN(consumer_offsets.last_seq). Rows with seq < watermark are sent +// AND past every consumer's cursor, so they are safe to drop. When the watermark +// is 0 (a consumer exists but has acknowledged nothing, or none is registered +// yet) the janitor deletes nothing — it never races ahead of a consumer that +// has not yet read an event. change_log is never touched: it is the durable +// history and the snapshot-resync floor. +type Janitor struct { + store Vacuum + interval time.Duration + logger *slog.Logger +} + +// JanitorConfig holds optional knobs; zero values fall back to defaults. +type JanitorConfig struct { + Interval time.Duration + Logger *slog.Logger +} + +// NewJanitor constructs a Janitor over store. +func NewJanitor(store Vacuum, cfg JanitorConfig) *Janitor { + j := &Janitor{store: store, interval: cfg.Interval, logger: cfg.Logger} + if j.interval <= 0 { + j.interval = DefaultJanitorInterval + } + if j.logger == nil { + j.logger = slog.Default() + } + return j +} + +// Start runs the vacuum loop until ctx is cancelled; the returned channel closes +// when the loop has exited. +func (j *Janitor) Start(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + t := time.NewTicker(j.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if _, err := j.Sweep(ctx); err != nil { + j.logger.Error("cdc janitor: sweep failed", "err", err) + } + } + } + }() + return done +} + +// Sweep deletes delivered outbox rows below the safe watermark and returns the +// number removed. +func (j *Janitor) Sweep(ctx context.Context) (int64, error) { + watermark, err := j.store.MinConsumerOffset(ctx) + if err != nil { + return 0, err + } + if watermark <= 0 { + return 0, nil + } + return j.store.DeleteSentOutboxBelow(ctx, watermark) +} diff --git a/backend/internal/cdc/jsonl.go b/backend/internal/cdc/jsonl.go new file mode 100644 index 0000000000..74dc0695c8 --- /dev/null +++ b/backend/internal/cdc/jsonl.go @@ -0,0 +1,109 @@ +package cdc + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// LogFileName is the active CDC log under the data dir. +const LogFileName = "session-events.jsonl" + +// DefaultMaxBytes is the size at which the log rotates (1 MiB). +const DefaultMaxBytes int64 = 1 << 20 + +// Log is the append-only JSONL sink the publisher writes to. When it grows past +// maxBytes it rotates by truncating in place and writing a reset marker as the +// new first line — the consumer treats a shrunken file as "resync from the DB +// snapshot", so the log itself is not the durable source of truth (SQLite is). +type Log struct { + mu sync.Mutex + path string + maxBytes int64 + f *os.File + size int64 +} + +// OpenLog opens (creating if absent) the JSONL log in dir. maxBytes <= 0 uses +// DefaultMaxBytes. +func OpenLog(dir string, maxBytes int64) (*Log, error) { + if maxBytes <= 0 { + maxBytes = DefaultMaxBytes + } + path := filepath.Join(dir, LogFileName) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, fmt.Errorf("open cdc log: %w", err) + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, fmt.Errorf("stat cdc log: %w", err) + } + return &Log{path: path, maxBytes: maxBytes, f: f, size: info.Size()}, nil +} + +// Append writes one event as a JSON line, flushing to disk. It rotates first if +// the file is already at/over the size cap, so a single oversized burst still +// lands in a fresh segment. +func (l *Log) Append(e Event) error { + l.mu.Lock() + defer l.mu.Unlock() + + if l.size >= l.maxBytes { + if err := l.rotateLocked(); err != nil { + return err + } + } + return l.writeLocked(e) +} + +func (l *Log) writeLocked(v any) error { + line, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("marshal cdc line: %w", err) + } + line = append(line, '\n') + n, err := l.f.Write(line) + l.size += int64(n) + if err != nil { + return fmt.Errorf("write cdc line: %w", err) + } + if err := l.f.Sync(); err != nil { + return fmt.Errorf("sync cdc log: %w", err) + } + return nil +} + +// rotateLocked renames the active file aside and starts a fresh one whose first +// line is a reset marker. Renaming (not truncating in place) gives the file a +// new identity, so a polling consumer reliably detects rotation via +// os.SameFile even if the fresh file grows past its old byte cursor between +// polls. The consumer then resyncs from the DB snapshot. +func (l *Log) rotateLocked() error { + if err := l.f.Close(); err != nil { + return fmt.Errorf("close cdc log for rotate: %w", err) + } + archive := l.path + ".1" + _ = os.Remove(archive) // best-effort: history lives in SQLite, not the log + if err := os.Rename(l.path, archive); err != nil { + return fmt.Errorf("rotate cdc log: %w", err) + } + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("reopen cdc log after rotate: %w", err) + } + l.f = f + l.size = 0 + return l.writeLocked(resetMarker{Type: "reset", RotatedAt: time.Now().UTC()}) +} + +// Close closes the underlying file. +func (l *Log) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + return l.f.Close() +} diff --git a/backend/internal/cdc/publisher.go b/backend/internal/cdc/publisher.go new file mode 100644 index 0000000000..3283a236e0 --- /dev/null +++ b/backend/internal/cdc/publisher.go @@ -0,0 +1,115 @@ +package cdc + +import ( + "context" + "log/slog" + "time" +) + +// DefaultPublishInterval is the outbox drain cadence. +const DefaultPublishInterval = 50 * time.Millisecond + +// DefaultBatchSize bounds how many outbox rows one drain pass handles. +const DefaultBatchSize = 256 + +// PendingEvent is an undelivered outbox row paired with its CDC event payload. +type PendingEvent struct { + OutboxID int64 + Event +} + +// OutboxStore is the publisher's view of the storage layer: read undelivered +// rows in seq order, then mark each delivered or failed. +type OutboxStore interface { + ListUnsent(ctx context.Context, limit int) ([]PendingEvent, error) + MarkSent(ctx context.Context, outboxID int64, at time.Time) error + MarkFailed(ctx context.Context, outboxID int64, errMsg string) error +} + +// Publisher drains the outbox into the JSONL log on a fixed cadence. +type Publisher struct { + src OutboxStore + log *Log + interval time.Duration + batch int + clock func() time.Time + logger *slog.Logger +} + +// PublisherConfig holds optional knobs; zero values fall back to defaults. +type PublisherConfig struct { + Interval time.Duration + Batch int + Clock func() time.Time + Logger *slog.Logger +} + +// NewPublisher constructs a Publisher over src and log. +func NewPublisher(src OutboxStore, log *Log, cfg PublisherConfig) *Publisher { + p := &Publisher{ + src: src, + log: log, + interval: cfg.Interval, + batch: cfg.Batch, + clock: cfg.Clock, + logger: cfg.Logger, + } + if p.interval <= 0 { + p.interval = DefaultPublishInterval + } + if p.batch <= 0 { + p.batch = DefaultBatchSize + } + if p.clock == nil { + p.clock = time.Now + } + if p.logger == nil { + p.logger = slog.Default() + } + return p +} + +// Start runs the drain loop until ctx is cancelled; the returned channel closes +// when the loop has exited. +func (p *Publisher) Start(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + t := time.NewTicker(p.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := p.Drain(ctx); err != nil { + p.logger.Error("cdc publisher: drain failed", "err", err) + } + } + } + }() + return done +} + +// Drain runs one pass: append each undelivered row to the log in seq order, +// marking it sent. A write failure stops the pass (the row is marked failed and +// retried next tick) so ordering is never violated by skipping ahead. +func (p *Publisher) Drain(ctx context.Context) error { + pending, err := p.src.ListUnsent(ctx, p.batch) + if err != nil { + return err + } + for _, pe := range pending { + if err := p.log.Append(pe.Event); err != nil { + p.logger.Error("cdc publisher: append failed", "outboxId", pe.OutboxID, "seq", pe.Seq, "err", err) + if merr := p.src.MarkFailed(ctx, pe.OutboxID, err.Error()); merr != nil { + p.logger.Error("cdc publisher: mark failed errored", "outboxId", pe.OutboxID, "err", merr) + } + return nil + } + if err := p.src.MarkSent(ctx, pe.OutboxID, p.clock().UTC()); err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d6765dba28..68aab00e7e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -47,6 +47,9 @@ type Config struct { // RunFilePath is where the PID + port handshake file (running.json) is // written so the Electron supervisor can discover and reap the daemon. RunFilePath string + // DataDir is the directory holding durable state (the SQLite database and + // the CDC JSONL log). It is created on first use by the storage layer. + DataDir string } // Addr returns the host:port the HTTP server binds. It uses net.JoinHostPort so @@ -65,6 +68,7 @@ func (c Config) Addr() string { // AO_REQUEST_TIMEOUT per-request timeout (Go duration > 0, default 60s) // AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s) // AO_RUN_FILE running.json path (default /running.json) +// AO_DATA_DIR durable state dir (default /data) // // The bind host is not configurable: the daemon is loopback-only by design. func Load() (Config, error) { @@ -108,6 +112,12 @@ func Load() (Config, error) { } cfg.RunFilePath = runFile + dataDir, err := resolveDataDir() + if err != nil { + return Config{}, err + } + cfg.DataDir = dataDir + return cfg, nil } @@ -138,3 +148,17 @@ func resolveRunFilePath() (string, error) { } return filepath.Join(dir, "agent-orchestrator", "running.json"), nil } + +// resolveDataDir picks where durable state (SQLite DB, CDC JSONL) lives. An +// explicit AO_DATA_DIR wins; otherwise it sits under the per-user state +// directory alongside running.json. +func resolveDataDir() (string, error) { + if p, ok := os.LookupEnv("AO_DATA_DIR"); ok && p != "" { + return p, nil + } + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve state dir: %w", err) + } + return filepath.Join(dir, "agent-orchestrator", "data"), nil +} diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index b5751e8670..54e6887f0a 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -53,6 +53,11 @@ type Manager struct { trackerMu sync.Mutex clock func() time.Time + // reactionStore, when wired via WithReactionStore, makes the trackers map a + // write-through cache over durable rows so a restart does not re-fire an + // already-escalated human page. nil keeps the in-memory-only default. + reactionStore ReactionStore + // sessionLister returns every session known to persistence so RunningSessions // can filter by runtime axis without coupling the LCM to a cross-project // store API the Tom-store does not yet expose. The daemon (lane #10) injects @@ -423,7 +428,7 @@ func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, r po // A kill is terminal but bypasses react()'s incident-over cleanup (it fires // no reaction). Drop any escalation trackers here so a later duration-based // TickEscalations can't emit reaction.escalated for a dead session. - m.clearSessionTrackers(id) + m.clearSessionTrackers(ctx, id) return nil } diff --git a/backend/internal/lifecycle/manager_parity_test.go b/backend/internal/lifecycle/manager_parity_test.go new file mode 100644 index 0000000000..146dcc16cd --- /dev/null +++ b/backend/internal/lifecycle/manager_parity_test.go @@ -0,0 +1,144 @@ +package lifecycle + +import ( + "context" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// TestStoreParity is the key contract test from the plan: it drives the REAL +// Lifecycle Manager through identical operation sequences against the in-memory +// fakeStore (the authoritative store semantics) and the SQLite-backed Store, +// then asserts the resulting canonical lifecycle is byte-identical. If the +// SQLite adapter honored the port exactly, the two managers cannot diverge. +// +// Both stores are seeded the same way (via the public Upsert insert path, so +// both start at revision 1) — this makes revision numbers, not just states, +// directly comparable. +func TestStoreParity(t *testing.T) { + seed := lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive) + seed.Activity = domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: t0, Source: domain.SourceNative} + + cases := []struct { + name string + ops []func(*Manager) error + }{ + { + name: "runtime dead then activity signal", + ops: []func(*Manager) error{ + func(m *Manager) error { + return m.ApplyRuntimeObservation(context.Background(), sid, ports.RuntimeFacts{ + RuntimeState: ports.RuntimeProbeDead, ProcessState: ports.ProcessProbeDead, ObservedAt: t0, + }) + }, + func(m *Manager) error { + return m.ApplyActivitySignal(context.Background(), sid, ports.ActivitySignal{ + State: ports.SignalValid, Activity: domain.ActivityActive, Timestamp: t0, Source: domain.SourceHook, + }) + }, + }, + }, + { + name: "scm pr open then changes requested", + ops: []func(*Manager) error{ + func(m *Manager) error { + return m.ApplySCMObservation(context.Background(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, PRNumber: 7, PRURL: "http://x/7", + }) + }, + }, + }, + { + name: "kill request terminates", + ops: []func(*Manager) error{ + func(m *Manager) error { + return m.OnKillRequested(context.Background(), sid, ports.KillReason{Kind: ports.KillManual, Detail: "x"}) + }, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fakeMgr, fakeS := newManager() + sqlMgr, sqlS := newSQLiteManager(t) + + seedViaUpsert(t, fakeS, seed) + seedViaUpsert(t, sqlS, seed) + + for i, op := range tc.ops { + errF := op(fakeMgr) + errS := op(sqlMgr) + if (errF == nil) != (errS == nil) { + t.Fatalf("op %d error divergence: fake=%v sqlite=%v", i, errF, errS) + } + } + + fl, okF, _ := fakeS.Load(context.Background(), sid) + sl, okS, _ := sqlS.Load(context.Background(), sid) + if okF != okS { + t.Fatalf("presence divergence: fake=%v sqlite=%v", okF, okS) + } + assertLifecycleEqual(t, fl, sl) + }) + } +} + +func newSQLiteManager(t *testing.T) (*Manager, *sqlite.Store) { + t.Helper() + db, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { db.Close() }) + store := sqlite.NewStore(db) + return New(store, &recordingNotifier{}, &recordingMessenger{}), store +} + +func seedViaUpsert(t *testing.T, store ports.LifecycleStore, l domain.CanonicalSessionLifecycle) { + t.Helper() + rec := domain.SessionRecord{ + ID: sid, + ProjectID: "proj", + Kind: domain.KindWorker, + CreatedAt: t0, + UpdatedAt: t0, + Lifecycle: l, + } + if err := store.Upsert(context.Background(), rec, ports.EventSessionCreated); err != nil { + t.Fatalf("seed upsert: %v", err) + } +} + +func assertLifecycleEqual(t *testing.T, a, b domain.CanonicalSessionLifecycle) { + t.Helper() + if a.Revision != b.Revision { + t.Errorf("revision: fake=%d sqlite=%d", a.Revision, b.Revision) + } + if a.Session != b.Session { + t.Errorf("session: fake=%+v sqlite=%+v", a.Session, b.Session) + } + if a.PR != b.PR { + t.Errorf("pr: fake=%+v sqlite=%+v", a.PR, b.PR) + } + if a.Runtime != b.Runtime { + t.Errorf("runtime: fake=%+v sqlite=%+v", a.Runtime, b.Runtime) + } + if a.Activity.State != b.Activity.State || a.Activity.Source != b.Activity.Source || + !a.Activity.LastActivityAt.Equal(b.Activity.LastActivityAt) { + t.Errorf("activity: fake=%+v sqlite=%+v", a.Activity, b.Activity) + } + switch { + case a.Detecting == nil && b.Detecting == nil: + case a.Detecting == nil || b.Detecting == nil: + t.Errorf("detecting presence: fake=%v sqlite=%v", a.Detecting, b.Detecting) + default: + if a.Detecting.Attempts != b.Detecting.Attempts || a.Detecting.EvidenceHash != b.Detecting.EvidenceHash || + !a.Detecting.StartedAt.Equal(b.Detecting.StartedAt) { + t.Errorf("detecting: fake=%+v sqlite=%+v", a.Detecting, b.Detecting) + } + } +} diff --git a/backend/internal/lifecycle/reaction_durability_test.go b/backend/internal/lifecycle/reaction_durability_test.go new file mode 100644 index 0000000000..1866c8c977 --- /dev/null +++ b/backend/internal/lifecycle/reaction_durability_test.go @@ -0,0 +1,140 @@ +package lifecycle + +import ( + "context" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// reactionStoreAdapter bridges the concrete *sqlite.Store to the lifecycle +// package's ReactionStore interface (string/row types <-> domain types). This is +// the same glue the composition root installs. +type reactionStoreAdapter struct{ s *sqlite.Store } + +func (a reactionStoreAdapter) LoadReactionTrackers(ctx context.Context) ([]PersistedTracker, error) { + rows, err := a.s.ListReactionTrackers(ctx) + if err != nil { + return nil, err + } + out := make([]PersistedTracker, len(rows)) + for i, r := range rows { + out[i] = PersistedTracker{ + SessionID: domain.SessionID(r.SessionID), + Key: r.ReactionKey, + Attempts: r.Attempts, + Escalated: r.Escalated, + FirstAttemptAt: r.FirstAttemptAt, + ProjectID: domain.ProjectID(r.ProjectID), + } + } + return out, nil +} + +func (a reactionStoreAdapter) SaveReactionTracker(ctx context.Context, t PersistedTracker) error { + return a.s.SaveReactionTracker(ctx, sqlite.ReactionTrackerRow{ + SessionID: string(t.SessionID), + ReactionKey: t.Key, + Attempts: t.Attempts, + Escalated: t.Escalated, + FirstAttemptAt: t.FirstAttemptAt, + ProjectID: string(t.ProjectID), + }) +} + +func (a reactionStoreAdapter) DeleteReactionTracker(ctx context.Context, id domain.SessionID, key string) error { + return a.s.DeleteReactionTracker(ctx, string(id), key) +} + +func (a reactionStoreAdapter) DeleteSessionReactionTrackers(ctx context.Context, id domain.SessionID) error { + return a.s.DeleteSessionReactionTrackers(ctx, string(id)) +} + +// TestReaction_DurabilitySurvivesRestart is the plan's reaction_trackers +// durability check: once a reaction has escalated, a daemon restart (a fresh +// Manager hydrated from the same store) must NOT re-fire the human page — the +// exact failure the in-memory-only version had. +func TestReaction_DurabilitySurvivesRestart(t *testing.T) { + db, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { db.Close() }) + store := sqlite.NewStore(db) + adapter := reactionStoreAdapter{store} + + // --- first process lifetime: drive ci-failed to escalation --- + notf1 := &recordingNotifier{} + m1 := New(store, notf1, &recordingMessenger{}) + m1.clock = func() time.Time { return t0 } + if err := m1.WithReactionStore(context.Background(), adapter); err != nil { + t.Fatalf("hydrate m1: %v", err) + } + seedViaUpsert(t, store, lcOpenPR(domain.PRReasonReviewPending)) + + // ci-failed: retries 2, persistent → escalate on the third failure. + for i := 0; i < 4; i++ { + failCI(t, m1) + pendingCI(t, m1) + } + if c := notifyCount(notf1, "reaction.escalated"); c != 1 { + t.Fatalf("precondition: want one escalation in first lifetime, got %d", c) + } + + // --- simulated restart: a fresh Manager hydrated from the same store --- + notf2 := &recordingNotifier{} + msgr2 := &recordingMessenger{} + m2 := New(store, notf2, msgr2) + m2.clock = func() time.Time { return t0 } + if err := m2.WithReactionStore(context.Background(), adapter); err != nil { + t.Fatalf("hydrate m2: %v", err) + } + + // The ci-failed tracker rehydrates with escalated=true, so further failures + // are silenced: no new send-to-agent, no re-escalation. + failCI(t, m2) + if c := notifyCount(notf2, "reaction.escalated"); c != 0 { + t.Errorf("restart re-fired an already-escalated page: got %d escalations", c) + } + if len(msgr2.sent) != 0 { + t.Errorf("restart re-sent to agent despite escalated budget: got %d sends", len(msgr2.sent)) + } +} + +// TestReaction_DurabilityClearsOnIncidentOver proves the durable rows are +// removed when an incident resolves, so a later unrelated incident starts from a +// fresh budget rather than a stale escalated=true. +func TestReaction_DurabilityClearsOnIncidentOver(t *testing.T) { + db, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { db.Close() }) + store := sqlite.NewStore(db) + adapter := reactionStoreAdapter{store} + + m := New(store, &recordingNotifier{}, &recordingMessenger{}) + m.clock = func() time.Time { return t0 } + if err := m.WithReactionStore(context.Background(), adapter); err != nil { + t.Fatalf("hydrate: %v", err) + } + seedViaUpsert(t, store, lcOpenPR(domain.PRReasonReviewPending)) + + failCI(t, m) + if rows, _ := store.ListReactionTrackers(context.Background()); len(rows) == 0 { + t.Fatalf("precondition: expected a persisted ci-failed tracker") + } + + // Approved+green ends the incident → recovered() clears every tracker. + if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ + Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewApproved, CISummary: ports.CIPassing, PRNumber: 7, + }); err != nil { + t.Fatalf("recover: %v", err) + } + if rows, _ := store.ListReactionTrackers(context.Background()); len(rows) != 0 { + t.Errorf("incident-over must clear durable trackers, got %d rows", len(rows)) + } +} diff --git a/backend/internal/lifecycle/reaction_store.go b/backend/internal/lifecycle/reaction_store.go new file mode 100644 index 0000000000..f8da7415b6 --- /dev/null +++ b/backend/internal/lifecycle/reaction_store.go @@ -0,0 +1,94 @@ +package lifecycle + +// reaction_store.go is the optional durability seam for the escalation engine. +// By default the Manager keeps escalation budgets in memory only (a restart +// resets them, which costs at most a few extra agent retries — never a missed +// human page). When a ReactionStore is wired via WithReactionStore the in-memory +// map becomes a write-through cache over durable rows, so a restart does NOT +// re-fire an already-escalated human notification. +// +// The interface uses lifecycle-local types so the package stays free of any +// storage dependency; the composition root adapts the concrete store to it +// (mirroring the cdc.OutboxStore adapter). + +import ( + "context" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// PersistedTracker is the durable form of one (session,reaction) escalation +// budget — the storage-facing mirror of the in-memory reactionTracker. +type PersistedTracker struct { + SessionID domain.SessionID + Key string + Attempts int + Escalated bool + FirstAttemptAt time.Time + ProjectID domain.ProjectID +} + +// ReactionStore persists escalation budgets so they survive a daemon restart. +type ReactionStore interface { + LoadReactionTrackers(ctx context.Context) ([]PersistedTracker, error) + SaveReactionTracker(ctx context.Context, t PersistedTracker) error + DeleteReactionTracker(ctx context.Context, id domain.SessionID, key string) error + DeleteSessionReactionTrackers(ctx context.Context, id domain.SessionID) error +} + +// WithReactionStore makes escalation budgets durable: it hydrates the in-memory +// trackers from rs and turns on write-through for subsequent mutations. Like +// WithSessionLister it must be called BEFORE any reaper or Apply* dispatch +// starts, since it populates the tracker map without holding trackerMu against +// concurrent reactors. A hydration error is returned so the caller can decide +// whether to proceed with an empty (in-memory) budget set. +func (m *Manager) WithReactionStore(ctx context.Context, rs ReactionStore) error { + m.reactionStore = rs + rows, err := rs.LoadReactionTrackers(ctx) + if err != nil { + return err + } + for _, r := range rows { + m.trackers[trackerKey{id: r.SessionID, key: reactionKey(r.Key)}] = &reactionTracker{ + attempts: r.Attempts, + escalated: r.Escalated, + firstAttemptAt: r.FirstAttemptAt, + projectID: r.ProjectID, + } + } + return nil +} + +// persistTracker write-throughs one tracker's current state. Best-effort: a +// failed write degrades durability to the in-memory default (a restart may +// re-fire one page), so it must not break the synchronous dispatch path. The +// snapshot is taken by the caller under trackerMu and passed by value here so no +// DB I/O happens while the lock is held. +func (m *Manager) persistTracker(ctx context.Context, id domain.SessionID, key reactionKey, snap reactionTracker) { + if m.reactionStore == nil { + return + } + _ = m.reactionStore.SaveReactionTracker(ctx, PersistedTracker{ + SessionID: id, + Key: string(key), + Attempts: snap.attempts, + Escalated: snap.escalated, + FirstAttemptAt: snap.firstAttemptAt, + ProjectID: snap.projectID, + }) +} + +func (m *Manager) deletePersistedTracker(ctx context.Context, id domain.SessionID, key reactionKey) { + if m.reactionStore == nil { + return + } + _ = m.reactionStore.DeleteReactionTracker(ctx, id, string(key)) +} + +func (m *Manager) deletePersistedSessionTrackers(ctx context.Context, id domain.SessionID) { + if m.reactionStore == nil { + return + } + _ = m.reactionStore.DeleteSessionReactionTrackers(ctx, id) +} diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index 26dea5627f..ac4de400ad 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -233,14 +233,14 @@ func (m *Manager) react(ctx context.Context, id domain.SessionID, tr *transition // transition is typically review_pending->approved (beforeKey empty), so // clearing only beforeKey would leak the ci-failed tracker and leave its // escalated=true to silence a future regression. Clear them all. - m.clearSessionTrackers(id) + m.clearSessionTrackers(ctx, id) case hadBefore && (!hasAfter || changed): // Within an unresolved open PR: a normal tracker resets when its state is // left. A persistent one (ci-failed) is NOT cleared here — it must survive // the ambiguous review_pending limbo (the fail->pending->fail flap, §4.2); // it only resets via the recovery/incident-over branch above. if !defaultReactions[beforeKey].persistent { - m.clearTracker(id, beforeKey) + m.clearTracker(ctx, id, beforeKey) } } @@ -324,13 +324,21 @@ func (m *Manager) sendToAgent(ctx context.Context, id domain.SessionID, projectI tk.firstAttemptAt = now } tk.attempts++ - if shouldEscalate(tk, cfg, now) { + escalateNow := shouldEscalate(tk, cfg, now) + if escalateNow { tk.escalated = true - m.trackerMu.Unlock() - return m.escalate(ctx, id, tk.projectID, key) } + snap := *tk m.trackerMu.Unlock() + // Write through the new budget (incl. escalated) before dispatching, so a + // crash between persist and notify re-fires at most the same page on restart. + m.persistTracker(ctx, id, key, snap) + + if escalateNow { + return m.escalate(ctx, id, snap.projectID, key) + } + if err := m.messenger.Send(ctx, id, composeMessage(cfg, rc)); err != nil { // A delivery failure must not consume escalation budget: roll this // attempt back so the next relevant transition retries from the same @@ -341,7 +349,9 @@ func (m *Manager) sendToAgent(ctx context.Context, id domain.SessionID, projectI if freshFirst { tk.firstAttemptAt = time.Time{} } + rolled := *tk m.trackerMu.Unlock() + m.persistTracker(ctx, id, key, rolled) return err } return nil @@ -393,16 +403,17 @@ func (m *Manager) trackerFor(id domain.SessionID, key reactionKey) *reactionTrac return tk } -func (m *Manager) clearTracker(id domain.SessionID, key reactionKey) { +func (m *Manager) clearTracker(ctx context.Context, id domain.SessionID, key reactionKey) { m.trackerMu.Lock() delete(m.trackers, trackerKey{id: id, key: key}) m.trackerMu.Unlock() + m.deletePersistedTracker(ctx, id, key) } // clearSessionTrackers drops every tracker for a session — used when its // incident is over, so no budget (and no stale escalated=true) survives into a // later unrelated incident. -func (m *Manager) clearSessionTrackers(id domain.SessionID) { +func (m *Manager) clearSessionTrackers(ctx context.Context, id domain.SessionID) { m.trackerMu.Lock() for k := range m.trackers { if k.id == id { @@ -410,6 +421,7 @@ func (m *Manager) clearSessionTrackers(id domain.SessionID) { } } m.trackerMu.Unlock() + m.deletePersistedSessionTrackers(ctx, id) } // TickEscalations fires the duration-based escalations the synchronous LCM @@ -421,6 +433,7 @@ func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { id domain.SessionID projectID domain.ProjectID key reactionKey + snap reactionTracker } var fire []due @@ -432,12 +445,13 @@ func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { cfg := defaultReactions[k.key] if cfg.escalateAfter > 0 && !tk.firstAttemptAt.IsZero() && now.Sub(tk.firstAttemptAt) >= cfg.escalateAfter { tk.escalated = true - fire = append(fire, due{id: k.id, projectID: tk.projectID, key: k.key}) + fire = append(fire, due{id: k.id, projectID: tk.projectID, key: k.key, snap: *tk}) } } m.trackerMu.Unlock() for _, d := range fire { + m.persistTracker(ctx, d.id, d.key, d.snap) if err := m.escalate(ctx, d.id, d.projectID, d.key); err != nil { return err } diff --git a/backend/internal/storage/sqlite/cdc_store.go b/backend/internal/storage/sqlite/cdc_store.go new file mode 100644 index 0000000000..3386f98807 --- /dev/null +++ b/backend/internal/storage/sqlite/cdc_store.go @@ -0,0 +1,104 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// OutboxEvent is a single undelivered change, joined from outbox + change_log. +// It is the unit the CDC publisher drains to JSONL. +type OutboxEvent struct { + OutboxID int64 + Seq int64 + SessionID string + EventType string + Revision int64 + Payload string + CreatedAt time.Time +} + +// ListUnsent returns up to limit undelivered events in seq order. +func (s *Store) ListUnsent(ctx context.Context, limit int) ([]OutboxEvent, error) { + rows, err := s.q.ListUnsentOutbox(ctx, int64(limit)) + if err != nil { + return nil, fmt.Errorf("list unsent outbox: %w", err) + } + out := make([]OutboxEvent, 0, len(rows)) + for _, r := range rows { + out = append(out, OutboxEvent{ + OutboxID: r.ID, + Seq: r.ChangeLogSeq, + SessionID: r.SessionID, + EventType: r.EventType, + Revision: r.Revision, + Payload: r.Payload, + CreatedAt: r.CreatedAt, + }) + } + return out, nil +} + +// MarkSent flags an outbox row delivered. +func (s *Store) MarkSent(ctx context.Context, outboxID int64, at time.Time) error { + return s.q.MarkOutboxSent(ctx, gen.MarkOutboxSentParams{ + SentAt: sql.NullTime{Time: at, Valid: true}, + ID: outboxID, + }) +} + +// MarkFailed bumps the attempt count and records the last error for an outbox row. +func (s *Store) MarkFailed(ctx context.Context, outboxID int64, errMsg string) error { + return s.q.MarkOutboxFailed(ctx, gen.MarkOutboxFailedParams{LastError: errMsg, ID: outboxID}) +} + +// GetOffset returns a consumer's last acknowledged seq (0 if it has none). +func (s *Store) GetOffset(ctx context.Context, consumer string) (int64, error) { + seq, err := s.q.GetConsumerOffset(ctx, consumer) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("get consumer offset %s: %w", consumer, err) + } + return seq, nil +} + +// SetOffset durably records a consumer's acknowledged seq. +func (s *Store) SetOffset(ctx context.Context, consumer string, seq int64, at time.Time) error { + return s.q.UpsertConsumerOffset(ctx, gen.UpsertConsumerOffsetParams{ + Consumer: consumer, + LastSeq: seq, + UpdatedAt: at, + }) +} + +// MaxChangeLogSeq returns the highest change_log seq (0 if empty). Used by the +// consumer to resume after a snapshot resync. +func (s *Store) MaxChangeLogSeq(ctx context.Context) (int64, error) { + v, err := s.q.MaxChangeLogSeq(ctx) + if err != nil { + return 0, fmt.Errorf("max change_log seq: %w", err) + } + return v, nil +} + +// MinConsumerOffset returns the lowest acknowledged seq across all consumers +// (0 if none). The janitor uses it as the safe outbox-deletion watermark. +func (s *Store) MinConsumerOffset(ctx context.Context) (int64, error) { + v, err := s.q.MinConsumerOffset(ctx) + if err != nil { + return 0, fmt.Errorf("min consumer offset: %w", err) + } + return v, nil +} + +// DeleteSentOutboxBelow removes delivered outbox rows whose seq is below the +// watermark, returning the number removed. +func (s *Store) DeleteSentOutboxBelow(ctx context.Context, seq int64) (int64, error) { + return s.q.DeleteSentOutboxBelow(ctx, seq) +} diff --git a/backend/internal/storage/sqlite/db.go b/backend/internal/storage/sqlite/db.go new file mode 100644 index 0000000000..78eb3ae9df --- /dev/null +++ b/backend/internal/storage/sqlite/db.go @@ -0,0 +1,63 @@ +// Package sqlite is the durable persistence adapter behind ports.LifecycleStore. +// It owns the SQLite schema (goose migrations), the revision-CAS upsert, and the +// transactional outbox (one txn writes the session row, a change_log entry, and +// the outbox row that the CDC publisher later drains to JSONL). +package sqlite + +import ( + "database/sql" + "embed" + "fmt" + "os" + "path/filepath" + + "github.com/pressly/goose/v3" + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// pragmas are applied on every connection open. WAL + NORMAL gives concurrent +// reads alongside the single writer; busy_timeout absorbs brief writer +// contention; foreign_keys enforces the session_metadata cascade. +const pragmas = "?_pragma=journal_mode(WAL)" + + "&_pragma=busy_timeout(5000)" + + "&_pragma=foreign_keys(ON)" + + "&_pragma=synchronous(NORMAL)" + +// Open opens (creating if absent) the SQLite database under dataDir, applies the +// connection pragmas, and runs all goose migrations up. The returned *sql.DB is +// safe for the single-writer / many-reader workload the LCM and readers impose. +func Open(dataDir string) (*sql.DB, error) { + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + dsn := "file:" + filepath.Join(dataDir, "ao.db") + pragmas + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + // Single writer: serialize all access through one connection so WAL's + // single-writer rule is never violated by the pool handing out a second + // writable conn mid-transaction. + db.SetMaxOpenConns(1) + + if err := migrate(db); err != nil { + db.Close() + return nil, err + } + return db, nil +} + +func migrate(db *sql.DB) error { + goose.SetBaseFS(migrationsFS) + goose.SetLogger(goose.NopLogger()) + if err := goose.SetDialect("sqlite3"); err != nil { + return fmt.Errorf("set goose dialect: %w", err) + } + if err := goose.Up(db, "migrations"); err != nil { + return fmt.Errorf("run migrations: %w", err) + } + return nil +} diff --git a/backend/internal/storage/sqlite/gen/cdc.sql.go b/backend/internal/storage/sqlite/gen/cdc.sql.go new file mode 100644 index 0000000000..c2eedc8c73 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/cdc.sql.go @@ -0,0 +1,199 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: cdc.sql + +package gen + +import ( + "context" + "database/sql" + "time" +) + +const deleteSentOutboxBelow = `-- name: DeleteSentOutboxBelow :execrows +DELETE FROM outbox WHERE sent = 1 AND change_log_seq < ? +` + +func (q *Queries) DeleteSentOutboxBelow(ctx context.Context, changeLogSeq int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteSentOutboxBelow, changeLogSeq) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getConsumerOffset = `-- name: GetConsumerOffset :one +SELECT last_seq FROM consumer_offsets WHERE consumer = ? +` + +func (q *Queries) GetConsumerOffset(ctx context.Context, consumer string) (int64, error) { + row := q.db.QueryRowContext(ctx, getConsumerOffset, consumer) + var last_seq int64 + err := row.Scan(&last_seq) + return last_seq, err +} + +const insertChangeLog = `-- name: InsertChangeLog :one +INSERT INTO change_log (session_id, event_type, revision, payload, created_at) +VALUES (?, ?, ?, ?, ?) +RETURNING seq +` + +type InsertChangeLogParams struct { + SessionID string + EventType string + Revision int64 + Payload string + CreatedAt time.Time +} + +// Appends a canonical-write record and returns its monotonic seq so the same +// transaction can thread it into the outbox row. +func (q *Queries) InsertChangeLog(ctx context.Context, arg InsertChangeLogParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertChangeLog, + arg.SessionID, + arg.EventType, + arg.Revision, + arg.Payload, + arg.CreatedAt, + ) + var seq int64 + err := row.Scan(&seq) + return seq, err +} + +const insertOutbox = `-- name: InsertOutbox :exec +INSERT INTO outbox (change_log_seq, created_at) +VALUES (?, ?) +` + +type InsertOutboxParams struct { + ChangeLogSeq int64 + CreatedAt time.Time +} + +func (q *Queries) InsertOutbox(ctx context.Context, arg InsertOutboxParams) error { + _, err := q.db.ExecContext(ctx, insertOutbox, arg.ChangeLogSeq, arg.CreatedAt) + return err +} + +const listUnsentOutbox = `-- name: ListUnsentOutbox :many +SELECT o.id, o.change_log_seq, o.attempts, + c.session_id, c.event_type, c.revision, c.payload, c.created_at +FROM outbox o +JOIN change_log c ON c.seq = o.change_log_seq +WHERE o.sent = 0 +ORDER BY o.change_log_seq +LIMIT ? +` + +type ListUnsentOutboxRow struct { + ID int64 + ChangeLogSeq int64 + Attempts int64 + SessionID string + EventType string + Revision int64 + Payload string + CreatedAt time.Time +} + +func (q *Queries) ListUnsentOutbox(ctx context.Context, limit int64) ([]ListUnsentOutboxRow, error) { + rows, err := q.db.QueryContext(ctx, listUnsentOutbox, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListUnsentOutboxRow{} + for rows.Next() { + var i ListUnsentOutboxRow + if err := rows.Scan( + &i.ID, + &i.ChangeLogSeq, + &i.Attempts, + &i.SessionID, + &i.EventType, + &i.Revision, + &i.Payload, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markOutboxFailed = `-- name: MarkOutboxFailed :exec +UPDATE outbox SET attempts = attempts + 1, last_error = ? WHERE id = ? +` + +type MarkOutboxFailedParams struct { + LastError string + ID int64 +} + +func (q *Queries) MarkOutboxFailed(ctx context.Context, arg MarkOutboxFailedParams) error { + _, err := q.db.ExecContext(ctx, markOutboxFailed, arg.LastError, arg.ID) + return err +} + +const markOutboxSent = `-- name: MarkOutboxSent :exec +UPDATE outbox SET sent = 1, sent_at = ? WHERE id = ? +` + +type MarkOutboxSentParams struct { + SentAt sql.NullTime + ID int64 +} + +func (q *Queries) MarkOutboxSent(ctx context.Context, arg MarkOutboxSentParams) error { + _, err := q.db.ExecContext(ctx, markOutboxSent, arg.SentAt, arg.ID) + return err +} + +const maxChangeLogSeq = `-- name: MaxChangeLogSeq :one +SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) FROM change_log +` + +func (q *Queries) MaxChangeLogSeq(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, maxChangeLogSeq) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const minConsumerOffset = `-- name: MinConsumerOffset :one +SELECT CAST(COALESCE(MIN(last_seq), 0) AS INTEGER) FROM consumer_offsets +` + +func (q *Queries) MinConsumerOffset(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, minConsumerOffset) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const upsertConsumerOffset = `-- name: UpsertConsumerOffset :exec +INSERT INTO consumer_offsets (consumer, last_seq, updated_at) +VALUES (?, ?, ?) +ON CONFLICT (consumer) DO UPDATE SET last_seq = excluded.last_seq, updated_at = excluded.updated_at +` + +type UpsertConsumerOffsetParams struct { + Consumer string + LastSeq int64 + UpdatedAt time.Time +} + +func (q *Queries) UpsertConsumerOffset(ctx context.Context, arg UpsertConsumerOffsetParams) error { + _, err := q.db.ExecContext(ctx, upsertConsumerOffset, arg.Consumer, arg.LastSeq, arg.UpdatedAt) + return err +} diff --git a/backend/internal/storage/sqlite/gen/db.go b/backend/internal/storage/sqlite/gen/db.go new file mode 100644 index 0000000000..b6fcf6be32 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package gen + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/backend/internal/storage/sqlite/gen/metadata.sql.go b/backend/internal/storage/sqlite/gen/metadata.sql.go new file mode 100644 index 0000000000..96510eb80e --- /dev/null +++ b/backend/internal/storage/sqlite/gen/metadata.sql.go @@ -0,0 +1,59 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: metadata.sql + +package gen + +import ( + "context" +) + +const getMetadata = `-- name: GetMetadata :many +SELECT key, value FROM session_metadata WHERE session_id = ? +` + +type GetMetadataRow struct { + Key string + Value string +} + +func (q *Queries) GetMetadata(ctx context.Context, sessionID string) ([]GetMetadataRow, error) { + rows, err := q.db.QueryContext(ctx, getMetadata, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetMetadataRow{} + for rows.Next() { + var i GetMetadataRow + if err := rows.Scan(&i.Key, &i.Value); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertMetadata = `-- name: UpsertMetadata :exec +INSERT INTO session_metadata (session_id, key, value) +VALUES (?, ?, ?) +ON CONFLICT (session_id, key) DO UPDATE SET value = excluded.value +` + +type UpsertMetadataParams struct { + SessionID string + Key string + Value string +} + +func (q *Queries) UpsertMetadata(ctx context.Context, arg UpsertMetadataParams) error { + _, err := q.db.ExecContext(ctx, upsertMetadata, arg.SessionID, arg.Key, arg.Value) + return err +} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go new file mode 100644 index 0000000000..210fe245ab --- /dev/null +++ b/backend/internal/storage/sqlite/gen/models.go @@ -0,0 +1,74 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package gen + +import ( + "database/sql" + "time" +) + +type ChangeLog struct { + Seq int64 + SessionID string + EventType string + Revision int64 + Payload string + CreatedAt time.Time +} + +type ConsumerOffset struct { + Consumer string + LastSeq int64 + UpdatedAt time.Time +} + +type Outbox struct { + ID int64 + ChangeLogSeq int64 + Sent int64 + SentAt sql.NullTime + Attempts int64 + LastError string + CreatedAt time.Time +} + +type ReactionTracker struct { + SessionID string + ReactionKey string + Attempts int64 + Escalated int64 + FirstAttemptAt sql.NullTime + ProjectID string +} + +type Session struct { + ID string + ProjectID string + IssueID string + Kind string + CreatedAt time.Time + UpdatedAt time.Time + Revision int64 + SessionState string + SessionReason string + PrState string + PrReason string + PrNumber int64 + PrUrl string + RuntimeState string + RuntimeReason string + ActivityState string + ActivityLastAt time.Time + ActivitySource string + DetectingAttempts sql.NullInt64 + DetectingStartedAt sql.NullTime + DetectingEvidenceHash sql.NullString +} + +type SessionMetadatum struct { + SessionID string + Key string + Value string +} diff --git a/backend/internal/storage/sqlite/gen/querier.go b/backend/internal/storage/sqlite/gen/querier.go new file mode 100644 index 0000000000..074fe053c9 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/querier.go @@ -0,0 +1,42 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package gen + +import ( + "context" +) + +type Querier interface { + DeleteReactionTracker(ctx context.Context, arg DeleteReactionTrackerParams) error + DeleteSentOutboxBelow(ctx context.Context, changeLogSeq int64) (int64, error) + DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error + GetConsumerOffset(ctx context.Context, consumer string) (int64, error) + GetMetadata(ctx context.Context, sessionID string) ([]GetMetadataRow, error) + GetSession(ctx context.Context, id string) (Session, error) + GetSessionRevision(ctx context.Context, id string) (int64, error) + // Appends a canonical-write record and returns its monotonic seq so the same + // transaction can thread it into the outbox row. + InsertChangeLog(ctx context.Context, arg InsertChangeLogParams) (int64, error) + InsertOutbox(ctx context.Context, arg InsertOutboxParams) error + // CAS insert: only succeeds for a brand-new id. Incoming revision must be 0; + // the row is persisted at revision 1. + InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) + ListAllSessions(ctx context.Context) ([]Session, error) + ListReactionTrackers(ctx context.Context) ([]ReactionTracker, error) + ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) + ListUnsentOutbox(ctx context.Context, limit int64) ([]ListUnsentOutboxRow, error) + MarkOutboxFailed(ctx context.Context, arg MarkOutboxFailedParams) error + MarkOutboxSent(ctx context.Context, arg MarkOutboxSentParams) error + MaxChangeLogSeq(ctx context.Context) (int64, error) + MinConsumerOffset(ctx context.Context) (int64, error) + // CAS update: succeeds only when the stored revision equals the caller's loaded + // revision (@expected_revision). 0 rows affected => revision mismatch. + UpdateSessionCAS(ctx context.Context, arg UpdateSessionCASParams) (int64, error) + UpsertConsumerOffset(ctx context.Context, arg UpsertConsumerOffsetParams) error + UpsertMetadata(ctx context.Context, arg UpsertMetadataParams) error + UpsertReactionTracker(ctx context.Context, arg UpsertReactionTrackerParams) error +} + +var _ Querier = (*Queries)(nil) diff --git a/backend/internal/storage/sqlite/gen/reactions.sql.go b/backend/internal/storage/sqlite/gen/reactions.sql.go new file mode 100644 index 0000000000..dc7b01c2a5 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/reactions.sql.go @@ -0,0 +1,100 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: reactions.sql + +package gen + +import ( + "context" + "database/sql" +) + +const deleteReactionTracker = `-- name: DeleteReactionTracker :exec +DELETE FROM reaction_trackers WHERE session_id = ? AND reaction_key = ? +` + +type DeleteReactionTrackerParams struct { + SessionID string + ReactionKey string +} + +func (q *Queries) DeleteReactionTracker(ctx context.Context, arg DeleteReactionTrackerParams) error { + _, err := q.db.ExecContext(ctx, deleteReactionTracker, arg.SessionID, arg.ReactionKey) + return err +} + +const deleteSessionReactionTrackers = `-- name: DeleteSessionReactionTrackers :exec +DELETE FROM reaction_trackers WHERE session_id = ? +` + +func (q *Queries) DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error { + _, err := q.db.ExecContext(ctx, deleteSessionReactionTrackers, sessionID) + return err +} + +const listReactionTrackers = `-- name: ListReactionTrackers :many +SELECT session_id, reaction_key, attempts, escalated, first_attempt_at, project_id +FROM reaction_trackers +` + +func (q *Queries) ListReactionTrackers(ctx context.Context) ([]ReactionTracker, error) { + rows, err := q.db.QueryContext(ctx, listReactionTrackers) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ReactionTracker{} + for rows.Next() { + var i ReactionTracker + if err := rows.Scan( + &i.SessionID, + &i.ReactionKey, + &i.Attempts, + &i.Escalated, + &i.FirstAttemptAt, + &i.ProjectID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertReactionTracker = `-- name: UpsertReactionTracker :exec +INSERT INTO reaction_trackers (session_id, reaction_key, attempts, escalated, first_attempt_at, project_id) +VALUES (?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id, reaction_key) DO UPDATE SET + attempts = excluded.attempts, + escalated = excluded.escalated, + first_attempt_at = excluded.first_attempt_at, + project_id = excluded.project_id +` + +type UpsertReactionTrackerParams struct { + SessionID string + ReactionKey string + Attempts int64 + Escalated int64 + FirstAttemptAt sql.NullTime + ProjectID string +} + +func (q *Queries) UpsertReactionTracker(ctx context.Context, arg UpsertReactionTrackerParams) error { + _, err := q.db.ExecContext(ctx, upsertReactionTracker, + arg.SessionID, + arg.ReactionKey, + arg.Attempts, + arg.Escalated, + arg.FirstAttemptAt, + arg.ProjectID, + ) + return err +} diff --git a/backend/internal/storage/sqlite/gen/sessions.sql.go b/backend/internal/storage/sqlite/gen/sessions.sql.go new file mode 100644 index 0000000000..00d97ad663 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/sessions.sql.go @@ -0,0 +1,307 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: sessions.sql + +package gen + +import ( + "context" + "database/sql" + "time" +) + +const getSession = `-- name: GetSession :one +SELECT id, project_id, issue_id, kind, created_at, updated_at, revision, session_state, session_reason, pr_state, pr_reason, pr_number, pr_url, runtime_state, runtime_reason, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash FROM sessions WHERE id = ? +` + +func (q *Queries) GetSession(ctx context.Context, id string) (Session, error) { + row := q.db.QueryRowContext(ctx, getSession, id) + var i Session + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.IssueID, + &i.Kind, + &i.CreatedAt, + &i.UpdatedAt, + &i.Revision, + &i.SessionState, + &i.SessionReason, + &i.PrState, + &i.PrReason, + &i.PrNumber, + &i.PrUrl, + &i.RuntimeState, + &i.RuntimeReason, + &i.ActivityState, + &i.ActivityLastAt, + &i.ActivitySource, + &i.DetectingAttempts, + &i.DetectingStartedAt, + &i.DetectingEvidenceHash, + ) + return i, err +} + +const getSessionRevision = `-- name: GetSessionRevision :one +SELECT revision FROM sessions WHERE id = ? +` + +func (q *Queries) GetSessionRevision(ctx context.Context, id string) (int64, error) { + row := q.db.QueryRowContext(ctx, getSessionRevision, id) + var revision int64 + err := row.Scan(&revision) + return revision, err +} + +const insertSession = `-- name: InsertSession :execrows +INSERT INTO sessions ( + id, project_id, issue_id, kind, created_at, updated_at, + revision, + session_state, session_reason, + pr_state, pr_reason, pr_number, pr_url, + runtime_state, runtime_reason, + activity_state, activity_last_at, activity_source, + detecting_attempts, detecting_started_at, detecting_evidence_hash +) VALUES ( + ?, ?, ?, ?, ?, ?, + 1, + ?, ?, + ?, ?, ?, ?, + ?, ?, + ?, ?, ?, + ?, ?, ? +) +ON CONFLICT (id) DO NOTHING +` + +type InsertSessionParams struct { + ID string + ProjectID string + IssueID string + Kind string + CreatedAt time.Time + UpdatedAt time.Time + SessionState string + SessionReason string + PrState string + PrReason string + PrNumber int64 + PrUrl string + RuntimeState string + RuntimeReason string + ActivityState string + ActivityLastAt time.Time + ActivitySource string + DetectingAttempts sql.NullInt64 + DetectingStartedAt sql.NullTime + DetectingEvidenceHash sql.NullString +} + +// CAS insert: only succeeds for a brand-new id. Incoming revision must be 0; +// the row is persisted at revision 1. +func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertSession, + arg.ID, + arg.ProjectID, + arg.IssueID, + arg.Kind, + arg.CreatedAt, + arg.UpdatedAt, + arg.SessionState, + arg.SessionReason, + arg.PrState, + arg.PrReason, + arg.PrNumber, + arg.PrUrl, + arg.RuntimeState, + arg.RuntimeReason, + arg.ActivityState, + arg.ActivityLastAt, + arg.ActivitySource, + arg.DetectingAttempts, + arg.DetectingStartedAt, + arg.DetectingEvidenceHash, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const listAllSessions = `-- name: ListAllSessions :many +SELECT id, project_id, issue_id, kind, created_at, updated_at, revision, session_state, session_reason, pr_state, pr_reason, pr_number, pr_url, runtime_state, runtime_reason, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash FROM sessions +` + +func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { + rows, err := q.db.QueryContext(ctx, listAllSessions) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Session{} + for rows.Next() { + var i Session + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.IssueID, + &i.Kind, + &i.CreatedAt, + &i.UpdatedAt, + &i.Revision, + &i.SessionState, + &i.SessionReason, + &i.PrState, + &i.PrReason, + &i.PrNumber, + &i.PrUrl, + &i.RuntimeState, + &i.RuntimeReason, + &i.ActivityState, + &i.ActivityLastAt, + &i.ActivitySource, + &i.DetectingAttempts, + &i.DetectingStartedAt, + &i.DetectingEvidenceHash, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSessionsByProject = `-- name: ListSessionsByProject :many +SELECT id, project_id, issue_id, kind, created_at, updated_at, revision, session_state, session_reason, pr_state, pr_reason, pr_number, pr_url, runtime_state, runtime_reason, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash FROM sessions WHERE project_id = ? +` + +func (q *Queries) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) { + rows, err := q.db.QueryContext(ctx, listSessionsByProject, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Session{} + for rows.Next() { + var i Session + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.IssueID, + &i.Kind, + &i.CreatedAt, + &i.UpdatedAt, + &i.Revision, + &i.SessionState, + &i.SessionReason, + &i.PrState, + &i.PrReason, + &i.PrNumber, + &i.PrUrl, + &i.RuntimeState, + &i.RuntimeReason, + &i.ActivityState, + &i.ActivityLastAt, + &i.ActivitySource, + &i.DetectingAttempts, + &i.DetectingStartedAt, + &i.DetectingEvidenceHash, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateSessionCAS = `-- name: UpdateSessionCAS :execrows +UPDATE sessions SET + project_id = ?, + issue_id = ?, + kind = ?, + updated_at = ?, + revision = revision + 1, + session_state = ?, + session_reason = ?, + pr_state = ?, + pr_reason = ?, + pr_number = ?, + pr_url = ?, + runtime_state = ?, + runtime_reason = ?, + activity_state = ?, + activity_last_at = ?, + activity_source = ?, + detecting_attempts = ?, + detecting_started_at = ?, + detecting_evidence_hash = ? +WHERE id = ? AND revision = ? +` + +type UpdateSessionCASParams struct { + ProjectID string + IssueID string + Kind string + UpdatedAt time.Time + SessionState string + SessionReason string + PrState string + PrReason string + PrNumber int64 + PrUrl string + RuntimeState string + RuntimeReason string + ActivityState string + ActivityLastAt time.Time + ActivitySource string + DetectingAttempts sql.NullInt64 + DetectingStartedAt sql.NullTime + DetectingEvidenceHash sql.NullString + ID string + Revision int64 +} + +// CAS update: succeeds only when the stored revision equals the caller's loaded +// revision (@expected_revision). 0 rows affected => revision mismatch. +func (q *Queries) UpdateSessionCAS(ctx context.Context, arg UpdateSessionCASParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateSessionCAS, + arg.ProjectID, + arg.IssueID, + arg.Kind, + arg.UpdatedAt, + arg.SessionState, + arg.SessionReason, + arg.PrState, + arg.PrReason, + arg.PrNumber, + arg.PrUrl, + arg.RuntimeState, + arg.RuntimeReason, + arg.ActivityState, + arg.ActivityLastAt, + arg.ActivitySource, + arg.DetectingAttempts, + arg.DetectingStartedAt, + arg.DetectingEvidenceHash, + arg.ID, + arg.Revision, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/backend/internal/storage/sqlite/mapping.go b/backend/internal/storage/sqlite/mapping.go new file mode 100644 index 0000000000..39ae212754 --- /dev/null +++ b/backend/internal/storage/sqlite/mapping.go @@ -0,0 +1,129 @@ +package sqlite + +import ( + "database/sql" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// recordToInsert maps a domain record to the generated insert params. The +// revision column is fixed to 1 by the query itself (insert path), so it is not +// carried here. +func recordToInsert(rec domain.SessionRecord) gen.InsertSessionParams { + lc := rec.Lifecycle + da, ds, dh := detectingToNull(lc.Detecting) + return gen.InsertSessionParams{ + ID: string(rec.ID), + ProjectID: string(rec.ProjectID), + IssueID: string(rec.IssueID), + Kind: string(rec.Kind), + CreatedAt: rec.CreatedAt, + UpdatedAt: rec.UpdatedAt, + SessionState: string(lc.Session.State), + SessionReason: string(lc.Session.Reason), + PrState: string(lc.PR.State), + PrReason: string(lc.PR.Reason), + PrNumber: int64(lc.PR.Number), + PrUrl: lc.PR.URL, + RuntimeState: string(lc.Runtime.State), + RuntimeReason: string(lc.Runtime.Reason), + ActivityState: string(lc.Activity.State), + ActivityLastAt: lc.Activity.LastActivityAt, + ActivitySource: string(lc.Activity.Source), + DetectingAttempts: da, + DetectingStartedAt: ds, + DetectingEvidenceHash: dh, + } +} + +// recordToUpdate maps a domain record to the CAS update params. expectedRevision +// is the caller's loaded revision, used in the WHERE clause for the CAS check. +func recordToUpdate(rec domain.SessionRecord, expectedRevision int64) gen.UpdateSessionCASParams { + lc := rec.Lifecycle + da, ds, dh := detectingToNull(lc.Detecting) + return gen.UpdateSessionCASParams{ + ProjectID: string(rec.ProjectID), + IssueID: string(rec.IssueID), + Kind: string(rec.Kind), + UpdatedAt: rec.UpdatedAt, + SessionState: string(lc.Session.State), + SessionReason: string(lc.Session.Reason), + PrState: string(lc.PR.State), + PrReason: string(lc.PR.Reason), + PrNumber: int64(lc.PR.Number), + PrUrl: lc.PR.URL, + RuntimeState: string(lc.Runtime.State), + RuntimeReason: string(lc.Runtime.Reason), + ActivityState: string(lc.Activity.State), + ActivityLastAt: lc.Activity.LastActivityAt, + ActivitySource: string(lc.Activity.Source), + DetectingAttempts: da, + DetectingStartedAt: ds, + DetectingEvidenceHash: dh, + ID: string(rec.ID), + Revision: expectedRevision, + } +} + +// rowToRecord maps a stored session row back to a domain record. Metadata is +// deliberately left nil: it is a side-channel (session_metadata) read only by +// GetMetadata, never reconstructed here — mirroring the in-memory fakeStore. +func rowToRecord(row gen.Session) domain.SessionRecord { + return domain.SessionRecord{ + ID: domain.SessionID(row.ID), + ProjectID: domain.ProjectID(row.ProjectID), + IssueID: domain.IssueID(row.IssueID), + Kind: domain.SessionKind(row.Kind), + Lifecycle: rowToLifecycle(row), + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func rowToLifecycle(row gen.Session) domain.CanonicalSessionLifecycle { + return domain.CanonicalSessionLifecycle{ + Version: domain.LifecycleVersion, + Revision: int(row.Revision), + Session: domain.SessionSubstate{ + State: domain.SessionState(row.SessionState), + Reason: domain.SessionReason(row.SessionReason), + }, + PR: domain.PRSubstate{ + State: domain.PRState(row.PrState), + Reason: domain.PRReason(row.PrReason), + Number: int(row.PrNumber), + URL: row.PrUrl, + }, + Runtime: domain.RuntimeSubstate{ + State: domain.RuntimeState(row.RuntimeState), + Reason: domain.RuntimeReason(row.RuntimeReason), + }, + Activity: domain.ActivitySubstate{ + State: domain.ActivityState(row.ActivityState), + LastActivityAt: row.ActivityLastAt, + Source: domain.ActivitySource(row.ActivitySource), + }, + Detecting: nullToDetecting(row), + } +} + +func detectingToNull(d *domain.DetectingState) (sql.NullInt64, sql.NullTime, sql.NullString) { + if d == nil { + return sql.NullInt64{}, sql.NullTime{}, sql.NullString{} + } + return sql.NullInt64{Int64: int64(d.Attempts), Valid: true}, + sql.NullTime{Time: d.StartedAt, Valid: true}, + sql.NullString{String: d.EvidenceHash, Valid: true} +} + +func nullToDetecting(row gen.Session) *domain.DetectingState { + if !row.DetectingAttempts.Valid { + return nil + } + return &domain.DetectingState{ + Attempts: int(row.DetectingAttempts.Int64), + StartedAt: row.DetectingStartedAt.Time, + EvidenceHash: row.DetectingEvidenceHash.String, + } +} diff --git a/backend/internal/storage/sqlite/migrations/0001_init.sql b/backend/internal/storage/sqlite/migrations/0001_init.sql new file mode 100644 index 0000000000..f343e16d13 --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0001_init.sql @@ -0,0 +1,109 @@ +-- +goose Up +-- +goose StatementBegin + +-- sessions holds identity + the canonical lifecycle as typed columns. The +-- display status is NEVER stored (it is derived on read). Metadata is NOT here — +-- it lives in session_metadata, written by a side-channel that bypasses CDC. +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + issue_id TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + -- canonical lifecycle: revision is the optimistic-concurrency (CAS) counter, + -- bumped only by the storage layer's Upsert. + revision INTEGER NOT NULL, + + session_state TEXT NOT NULL, + session_reason TEXT NOT NULL, + + pr_state TEXT NOT NULL, + pr_reason TEXT NOT NULL, + pr_number INTEGER NOT NULL DEFAULT 0, + pr_url TEXT NOT NULL DEFAULT '', + + runtime_state TEXT NOT NULL, + runtime_reason TEXT NOT NULL, + + activity_state TEXT NOT NULL, + activity_last_at TIMESTAMP NOT NULL, + activity_source TEXT NOT NULL, + + -- detecting quarantine memory; NULL when the session is not in detecting. + detecting_attempts INTEGER, + detecting_started_at TIMESTAMP, + detecting_evidence_hash TEXT +); + +CREATE INDEX idx_sessions_project ON sessions (project_id); + +-- session_metadata is the opaque key/value side-channel (branch, workspacePath, +-- runtimeHandleId, runtimeName, agentSessionId, prompt). Written by +-- PatchMetadata; never bumps revision and never emits a CDC event. +CREATE TABLE session_metadata ( + session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (session_id, key) +); + +-- change_log is the durable, ordered record of every canonical write. seq is the +-- monotonic CDC ordering/idempotency key. +CREATE TABLE change_log ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + event_type TEXT NOT NULL, + revision INTEGER NOT NULL, + payload TEXT NOT NULL, + created_at TIMESTAMP NOT NULL +); + +-- outbox is the transactional-outbox: one unsent row per canonical write, drained +-- by the publisher into JSONL. change_log_seq links it to its change_log row. +CREATE TABLE outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + change_log_seq INTEGER NOT NULL REFERENCES change_log (seq), + sent INTEGER NOT NULL DEFAULT 0, + sent_at TIMESTAMP, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL +); + +CREATE INDEX idx_outbox_unsent ON outbox (change_log_seq) WHERE sent = 0; + +-- consumer_offsets is the durable per-consumer cursor (at-least-once delivery). +CREATE TABLE consumer_offsets ( + consumer TEXT PRIMARY KEY, + last_seq INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL +); + +-- reaction_trackers is the durable escalation budget (persisted so a restart does +-- not re-fire human pages). Off the canonical CDC path. Mirrors the LCM's +-- in-memory reactionTracker: attempts (numeric budget), escalated (silences +-- further auto-dispatch), first_attempt_at (duration-escalation anchor), +-- project_id (captured at first attempt for the escalation event). +CREATE TABLE reaction_trackers ( + session_id TEXT NOT NULL, + reaction_key TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + escalated INTEGER NOT NULL DEFAULT 0, + first_attempt_at TIMESTAMP, + project_id TEXT NOT NULL DEFAULT '', + PRIMARY KEY (session_id, reaction_key) +); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE reaction_trackers; +DROP TABLE consumer_offsets; +DROP TABLE outbox; +DROP TABLE change_log; +DROP TABLE session_metadata; +DROP TABLE sessions; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/cdc.sql b/backend/internal/storage/sqlite/queries/cdc.sql new file mode 100644 index 0000000000..b818194a51 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/cdc.sql @@ -0,0 +1,42 @@ +-- name: InsertChangeLog :one +-- Appends a canonical-write record and returns its monotonic seq so the same +-- transaction can thread it into the outbox row. +INSERT INTO change_log (session_id, event_type, revision, payload, created_at) +VALUES (?, ?, ?, ?, ?) +RETURNING seq; + +-- name: InsertOutbox :exec +INSERT INTO outbox (change_log_seq, created_at) +VALUES (?, ?); + +-- name: ListUnsentOutbox :many +SELECT o.id, o.change_log_seq, o.attempts, + c.session_id, c.event_type, c.revision, c.payload, c.created_at +FROM outbox o +JOIN change_log c ON c.seq = o.change_log_seq +WHERE o.sent = 0 +ORDER BY o.change_log_seq +LIMIT ?; + +-- name: MarkOutboxSent :exec +UPDATE outbox SET sent = 1, sent_at = ? WHERE id = ?; + +-- name: MarkOutboxFailed :exec +UPDATE outbox SET attempts = attempts + 1, last_error = ? WHERE id = ?; + +-- name: GetConsumerOffset :one +SELECT last_seq FROM consumer_offsets WHERE consumer = ?; + +-- name: UpsertConsumerOffset :exec +INSERT INTO consumer_offsets (consumer, last_seq, updated_at) +VALUES (?, ?, ?) +ON CONFLICT (consumer) DO UPDATE SET last_seq = excluded.last_seq, updated_at = excluded.updated_at; + +-- name: MaxChangeLogSeq :one +SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) FROM change_log; + +-- name: MinConsumerOffset :one +SELECT CAST(COALESCE(MIN(last_seq), 0) AS INTEGER) FROM consumer_offsets; + +-- name: DeleteSentOutboxBelow :execrows +DELETE FROM outbox WHERE sent = 1 AND change_log_seq < ?; diff --git a/backend/internal/storage/sqlite/queries/metadata.sql b/backend/internal/storage/sqlite/queries/metadata.sql new file mode 100644 index 0000000000..45079bb252 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/metadata.sql @@ -0,0 +1,7 @@ +-- name: GetMetadata :many +SELECT key, value FROM session_metadata WHERE session_id = ?; + +-- name: UpsertMetadata :exec +INSERT INTO session_metadata (session_id, key, value) +VALUES (?, ?, ?) +ON CONFLICT (session_id, key) DO UPDATE SET value = excluded.value; diff --git a/backend/internal/storage/sqlite/queries/reactions.sql b/backend/internal/storage/sqlite/queries/reactions.sql new file mode 100644 index 0000000000..0ccd99c3f0 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/reactions.sql @@ -0,0 +1,18 @@ +-- name: ListReactionTrackers :many +SELECT session_id, reaction_key, attempts, escalated, first_attempt_at, project_id +FROM reaction_trackers; + +-- name: UpsertReactionTracker :exec +INSERT INTO reaction_trackers (session_id, reaction_key, attempts, escalated, first_attempt_at, project_id) +VALUES (?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id, reaction_key) DO UPDATE SET + attempts = excluded.attempts, + escalated = excluded.escalated, + first_attempt_at = excluded.first_attempt_at, + project_id = excluded.project_id; + +-- name: DeleteReactionTracker :exec +DELETE FROM reaction_trackers WHERE session_id = ? AND reaction_key = ?; + +-- name: DeleteSessionReactionTrackers :exec +DELETE FROM reaction_trackers WHERE session_id = ?; diff --git a/backend/internal/storage/sqlite/queries/sessions.sql b/backend/internal/storage/sqlite/queries/sessions.sql new file mode 100644 index 0000000000..48cdcacf14 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/sessions.sql @@ -0,0 +1,58 @@ +-- name: InsertSession :execrows +-- CAS insert: only succeeds for a brand-new id. Incoming revision must be 0; +-- the row is persisted at revision 1. +INSERT INTO sessions ( + id, project_id, issue_id, kind, created_at, updated_at, + revision, + session_state, session_reason, + pr_state, pr_reason, pr_number, pr_url, + runtime_state, runtime_reason, + activity_state, activity_last_at, activity_source, + detecting_attempts, detecting_started_at, detecting_evidence_hash +) VALUES ( + ?, ?, ?, ?, ?, ?, + 1, + ?, ?, + ?, ?, ?, ?, + ?, ?, + ?, ?, ?, + ?, ?, ? +) +ON CONFLICT (id) DO NOTHING; + +-- name: UpdateSessionCAS :execrows +-- CAS update: succeeds only when the stored revision equals the caller's loaded +-- revision (@expected_revision). 0 rows affected => revision mismatch. +UPDATE sessions SET + project_id = ?, + issue_id = ?, + kind = ?, + updated_at = ?, + revision = revision + 1, + session_state = ?, + session_reason = ?, + pr_state = ?, + pr_reason = ?, + pr_number = ?, + pr_url = ?, + runtime_state = ?, + runtime_reason = ?, + activity_state = ?, + activity_last_at = ?, + activity_source = ?, + detecting_attempts = ?, + detecting_started_at = ?, + detecting_evidence_hash = ? +WHERE id = ? AND revision = ?; + +-- name: GetSessionRevision :one +SELECT revision FROM sessions WHERE id = ?; + +-- name: GetSession :one +SELECT * FROM sessions WHERE id = ?; + +-- name: ListSessionsByProject :many +SELECT * FROM sessions WHERE project_id = ?; + +-- name: ListAllSessions :many +SELECT * FROM sessions; diff --git a/backend/internal/storage/sqlite/reaction_store.go b/backend/internal/storage/sqlite/reaction_store.go new file mode 100644 index 0000000000..819d9716b2 --- /dev/null +++ b/backend/internal/storage/sqlite/reaction_store.go @@ -0,0 +1,80 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// ReactionTrackerRow is one persisted escalation budget, the durable mirror of +// the LCM's in-memory reactionTracker. It is the unit the lifecycle Manager +// hydrates on startup and writes through on each mutation. +type ReactionTrackerRow struct { + SessionID string + ReactionKey string + Attempts int + Escalated bool + FirstAttemptAt time.Time + ProjectID string +} + +// ListReactionTrackers returns every persisted escalation budget so the Manager +// can rehydrate its in-memory trackers after a restart. +func (s *Store) ListReactionTrackers(ctx context.Context) ([]ReactionTrackerRow, error) { + rows, err := s.q.ListReactionTrackers(ctx) + if err != nil { + return nil, fmt.Errorf("list reaction trackers: %w", err) + } + out := make([]ReactionTrackerRow, 0, len(rows)) + for _, r := range rows { + var first time.Time + if r.FirstAttemptAt.Valid { + first = r.FirstAttemptAt.Time + } + out = append(out, ReactionTrackerRow{ + SessionID: r.SessionID, + ReactionKey: r.ReactionKey, + Attempts: int(r.Attempts), + Escalated: r.Escalated != 0, + FirstAttemptAt: first, + ProjectID: r.ProjectID, + }) + } + return out, nil +} + +// SaveReactionTracker durably persists one escalation budget (insert or update). +func (s *Store) SaveReactionTracker(ctx context.Context, r ReactionTrackerRow) error { + escalated := int64(0) + if r.Escalated { + escalated = 1 + } + first := sql.NullTime{} + if !r.FirstAttemptAt.IsZero() { + first = sql.NullTime{Time: r.FirstAttemptAt, Valid: true} + } + return s.q.UpsertReactionTracker(ctx, gen.UpsertReactionTrackerParams{ + SessionID: r.SessionID, + ReactionKey: r.ReactionKey, + Attempts: int64(r.Attempts), + Escalated: escalated, + FirstAttemptAt: first, + ProjectID: r.ProjectID, + }) +} + +// DeleteReactionTracker drops one escalation budget. +func (s *Store) DeleteReactionTracker(ctx context.Context, sessionID, reactionKey string) error { + return s.q.DeleteReactionTracker(ctx, gen.DeleteReactionTrackerParams{ + SessionID: sessionID, + ReactionKey: reactionKey, + }) +} + +// DeleteSessionReactionTrackers drops every escalation budget for a session. +func (s *Store) DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error { + return s.q.DeleteSessionReactionTrackers(ctx, sessionID) +} diff --git a/backend/internal/storage/sqlite/spike_test.go b/backend/internal/storage/sqlite/spike_test.go new file mode 100644 index 0000000000..30b43fc7cd --- /dev/null +++ b/backend/internal/storage/sqlite/spike_test.go @@ -0,0 +1,92 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// TestSpikeOutboxTxn de-risks the whole adapter: it proves the sqlc-generated +// Querier composes inside one *sql.Tx and that the change_log seq returned +// mid-transaction threads into the outbox row — the transactional-outbox shape +// the publisher later drains. Step 0 of the implementation plan. +func TestSpikeOutboxTxn(t *testing.T) { + db, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + defer db.Close() + + ctx := context.Background() + now := time.Now().UTC() + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + + q := gen.New(db).WithTx(tx) + + // 1. CAS insert of a brand-new session (revision 0 -> persisted 1). + rows, err := q.InsertSession(ctx, gen.InsertSessionParams{ + ID: "s1", + ProjectID: "p1", + Kind: "worker", + CreatedAt: now, + UpdatedAt: now, + SessionState: "working", + SessionReason: "spawn_requested", + PrState: "none", + PrReason: "not_created", + RuntimeState: "unknown", + RuntimeReason: "spawn_incomplete", + ActivityState: "active", + ActivityLastAt: now, + ActivitySource: "none", + }) + if err != nil { + t.Fatalf("insert session: %v", err) + } + if rows != 1 { + t.Fatalf("insert session affected %d rows, want 1", rows) + } + + // 2. Append the change_log entry and capture its seq mid-transaction. + seq, err := q.InsertChangeLog(ctx, gen.InsertChangeLogParams{ + SessionID: "s1", + EventType: "session_created", + Revision: 1, + Payload: `{"id":"s1"}`, + CreatedAt: now, + }) + if err != nil { + t.Fatalf("insert change_log: %v", err) + } + if seq != 1 { + t.Fatalf("change_log seq = %d, want 1", seq) + } + + // 3. Thread the seq into the outbox row — the key thing the spike validates. + if err := q.InsertOutbox(ctx, gen.InsertOutboxParams{ChangeLogSeq: seq, CreatedAt: now}); err != nil { + t.Fatalf("insert outbox: %v", err) + } + + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + + // Verify the outbox row is visible, unsent, and linked to change_log seq 1. + unsent, err := gen.New(db).ListUnsentOutbox(ctx, 10) + if err != nil { + t.Fatalf("list unsent: %v", err) + } + if len(unsent) != 1 { + t.Fatalf("unsent outbox = %d rows, want 1", len(unsent)) + } + if unsent[0].ChangeLogSeq != 1 || unsent[0].SessionID != "s1" || unsent[0].EventType != "session_created" { + t.Fatalf("unexpected outbox row: %+v", unsent[0]) + } +} diff --git a/backend/internal/storage/sqlite/store.go b/backend/internal/storage/sqlite/store.go new file mode 100644 index 0000000000..bd61e73b38 --- /dev/null +++ b/backend/internal/storage/sqlite/store.go @@ -0,0 +1,118 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// Store is the SQLite-backed ports.LifecycleStore. The LCM is its sole logical +// writer (via Upsert); readers (Session Manager, reaper) use Load/Get/List. +type Store struct { + db *sql.DB + q *gen.Queries +} + +var _ ports.LifecycleStore = (*Store)(nil) + +// NewStore wraps an opened *sql.DB (see Open) as a LifecycleStore. +func NewStore(db *sql.DB) *Store { + return &Store{db: db, q: gen.New(db)} +} + +// Load returns the canonical lifecycle for a session, or ok=false if absent. +func (s *Store) Load(ctx context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) { + row, err := s.q.GetSession(ctx, string(id)) + if errors.Is(err, sql.ErrNoRows) { + return domain.CanonicalSessionLifecycle{}, false, nil + } + if err != nil { + return domain.CanonicalSessionLifecycle{}, false, fmt.Errorf("load session %s: %w", id, err) + } + return rowToLifecycle(row), true, nil +} + +// Get returns the full record (no derived status) for a session. +func (s *Store) Get(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { + row, err := s.q.GetSession(ctx, string(id)) + if errors.Is(err, sql.ErrNoRows) { + return domain.SessionRecord{}, false, nil + } + if err != nil { + return domain.SessionRecord{}, false, fmt.Errorf("get session %s: %w", id, err) + } + return rowToRecord(row), true, nil +} + +// List returns every record for a project (no archive filter — mirrors the +// in-memory store contract; terminal filtering is the caller's job). +func (s *Store) List(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { + rows, err := s.q.ListSessionsByProject(ctx, string(project)) + if err != nil { + return nil, fmt.Errorf("list sessions for %s: %w", project, err) + } + out := make([]domain.SessionRecord, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToRecord(row)) + } + return out, nil +} + +// ListAll returns every persisted session across all projects. The CDC snapshot +// source uses it to rebuild current state after a log-rotation gap. +func (s *Store) ListAll(ctx context.Context) ([]domain.SessionRecord, error) { + rows, err := s.q.ListAllSessions(ctx) + if err != nil { + return nil, fmt.Errorf("list all sessions: %w", err) + } + out := make([]domain.SessionRecord, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToRecord(row)) + } + return out, nil +} + +// GetMetadata returns the opaque key/value metadata for a session. +func (s *Store) GetMetadata(ctx context.Context, id domain.SessionID) (map[string]string, error) { + rows, err := s.q.GetMetadata(ctx, string(id)) + if err != nil { + return nil, fmt.Errorf("get metadata %s: %w", id, err) + } + if len(rows) == 0 { + return nil, nil + } + m := make(map[string]string, len(rows)) + for _, r := range rows { + m[r.Key] = r.Value + } + return m, nil +} + +// PatchMetadata merges kv into the session's metadata. It is outside the +// canonical write path: no revision bump, no CDC event. +func (s *Store) PatchMetadata(ctx context.Context, id domain.SessionID, kv map[string]string) error { + if len(kv) == 0 { + return nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin patch metadata: %w", err) + } + defer tx.Rollback() + qtx := s.q.WithTx(tx) + for k, v := range kv { + if err := qtx.UpsertMetadata(ctx, gen.UpsertMetadataParams{ + SessionID: string(id), + Key: k, + Value: v, + }); err != nil { + return fmt.Errorf("patch metadata %s[%s]: %w", id, k, err) + } + } + return tx.Commit() +} diff --git a/backend/internal/storage/sqlite/store_test.go b/backend/internal/storage/sqlite/store_test.go new file mode 100644 index 0000000000..5457855da0 --- /dev/null +++ b/backend/internal/storage/sqlite/store_test.go @@ -0,0 +1,256 @@ +package sqlite + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + db, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { db.Close() }) + return NewStore(db) +} + +func sampleRecord(id string) domain.SessionRecord { + now := time.Now().UTC().Truncate(time.Second) + return domain.SessionRecord{ + ID: domain.SessionID(id), + ProjectID: "proj", + IssueID: "issue-1", + Kind: domain.KindWorker, + CreatedAt: now, + UpdatedAt: now, + Lifecycle: domain.CanonicalSessionLifecycle{ + Session: domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress}, + PR: domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonNotCreated}, + Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, + Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, + }, + } +} + +func TestUpsertInsertThenUpdateBumpsRevision(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + rec := sampleRecord("s1") + + if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { + t.Fatalf("insert: %v", err) + } + lc, ok, err := s.Load(ctx, "s1") + if err != nil || !ok { + t.Fatalf("load after insert: ok=%v err=%v", ok, err) + } + if lc.Revision != 1 { + t.Fatalf("revision after insert = %d, want 1", lc.Revision) + } + + // Update must carry the loaded revision (1) and persist as 2. + rec.Lifecycle.Revision = 1 + rec.Lifecycle.Session.State = domain.SessionIdle + if err := s.Upsert(ctx, rec, ports.EventSessionStateChanged); err != nil { + t.Fatalf("update: %v", err) + } + lc, _, _ = s.Load(ctx, "s1") + if lc.Revision != 2 { + t.Fatalf("revision after update = %d, want 2", lc.Revision) + } + if lc.Session.State != domain.SessionIdle { + t.Fatalf("state after update = %q, want idle", lc.Session.State) + } +} + +func TestUpsertStaleRevisionMismatch(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + rec := sampleRecord("s1") + if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { + t.Fatalf("insert: %v", err) + } + + // Stored revision is 1; submitting revision 0 (stale) must mismatch and + // write nothing new (no extra outbox/change_log rows). + rec.Lifecycle.Revision = 0 + err := s.Upsert(ctx, rec, ports.EventSessionStateChanged) + if err == nil || !strings.Contains(err.Error(), "revision mismatch") { + t.Fatalf("stale update err = %v, want revision mismatch", err) + } + assertOutboxCount(t, s, ctx, 1) +} + +func TestUpsertInsertNonZeroRevisionErrors(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + rec := sampleRecord("s1") + rec.Lifecycle.Revision = 5 + err := s.Upsert(ctx, rec, ports.EventSessionCreated) + if err == nil || !strings.Contains(err.Error(), "revision mismatch") { + t.Fatalf("insert with revision 5 err = %v, want revision mismatch", err) + } + // Nothing should be persisted. + if _, ok, _ := s.Get(ctx, "s1"); ok { + t.Fatal("session persisted despite revision-mismatch insert") + } + assertOutboxCount(t, s, ctx, 0) +} + +func TestUpsertOutboxAtomicityAndOrdering(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + rec := sampleRecord("s1") + if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { + t.Fatalf("insert: %v", err) + } + rec.Lifecycle.Revision = 1 + if err := s.Upsert(ctx, rec, ports.EventSessionStateChanged); err != nil { + t.Fatalf("update: %v", err) + } + + rows, err := NewStore(s.db).q.ListUnsentOutbox(ctx, 100) + if err != nil { + t.Fatalf("list outbox: %v", err) + } + if len(rows) != 2 { + t.Fatalf("outbox rows = %d, want 2", len(rows)) + } + // seq strictly monotonic, event types verbatim, revisions 1 then 2. + if rows[0].ChangeLogSeq != 1 || rows[1].ChangeLogSeq != 2 { + t.Fatalf("seq not monotonic: %d, %d", rows[0].ChangeLogSeq, rows[1].ChangeLogSeq) + } + if rows[0].EventType != string(ports.EventSessionCreated) || rows[1].EventType != string(ports.EventSessionStateChanged) { + t.Fatalf("event types = %q, %q", rows[0].EventType, rows[1].EventType) + } + if rows[0].Revision != 1 || rows[1].Revision != 2 { + t.Fatalf("revisions = %d, %d, want 1, 2", rows[0].Revision, rows[1].Revision) + } +} + +func TestGetListRoundTrip(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + a := sampleRecord("a") + b := sampleRecord("b") + b.ProjectID = "other" + if err := s.Upsert(ctx, a, ports.EventSessionCreated); err != nil { + t.Fatal(err) + } + if err := s.Upsert(ctx, b, ports.EventSessionCreated); err != nil { + t.Fatal(err) + } + + got, ok, err := s.Get(ctx, "a") + if err != nil || !ok { + t.Fatalf("get a: ok=%v err=%v", ok, err) + } + if got.ID != "a" || got.Lifecycle.Revision != 1 || got.IssueID != "issue-1" { + t.Fatalf("unexpected record: %+v", got) + } + if got.Metadata != nil { + t.Fatalf("Get must not reconstruct metadata, got %v", got.Metadata) + } + + list, err := s.List(ctx, "proj") + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].ID != "a" { + t.Fatalf("List(proj) = %+v, want only a", list) + } +} + +func TestMetadataSideChannel(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { + t.Fatal(err) + } + + if err := s.PatchMetadata(ctx, "s1", map[string]string{"branch": "feat/x", "prompt": "do it"}); err != nil { + t.Fatalf("patch: %v", err) + } + if err := s.PatchMetadata(ctx, "s1", map[string]string{"branch": "feat/y"}); err != nil { + t.Fatalf("patch overwrite: %v", err) + } + + m, err := s.GetMetadata(ctx, "s1") + if err != nil { + t.Fatal(err) + } + if m["branch"] != "feat/y" || m["prompt"] != "do it" { + t.Fatalf("metadata = %v", m) + } + // Metadata writes must not bump revision (off the canonical path). + lc, _, _ := s.Load(ctx, "s1") + if lc.Revision != 1 { + t.Fatalf("revision = %d after metadata patch, want 1 (no bump)", lc.Revision) + } +} + +func TestDetectingRoundTrip(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + rec := sampleRecord("s1") + rec.Lifecycle.Session.State = domain.SessionDetecting + rec.Lifecycle.Detecting = &domain.DetectingState{ + Attempts: 2, + StartedAt: time.Now().UTC().Truncate(time.Second), + EvidenceHash: "abc123", + } + if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { + t.Fatal(err) + } + lc, _, _ := s.Load(ctx, "s1") + if lc.Detecting == nil { + t.Fatal("Detecting lost on round-trip") + } + if lc.Detecting.Attempts != 2 || lc.Detecting.EvidenceHash != "abc123" { + t.Fatalf("detecting = %+v", lc.Detecting) + } + + // Clearing Detecting must null the columns back out. + rec.Lifecycle.Revision = 1 + rec.Lifecycle.Detecting = nil + if err := s.Upsert(ctx, rec, ports.EventSessionStateChanged); err != nil { + t.Fatal(err) + } + lc, _, _ = s.Load(ctx, "s1") + if lc.Detecting != nil { + t.Fatalf("Detecting not cleared: %+v", lc.Detecting) + } +} + +func TestLoadGetMissing(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + if _, ok, err := s.Load(ctx, "nope"); ok || err != nil { + t.Fatalf("Load missing: ok=%v err=%v", ok, err) + } + if _, ok, err := s.Get(ctx, "nope"); ok || err != nil { + t.Fatalf("Get missing: ok=%v err=%v", ok, err) + } + if m, err := s.GetMetadata(ctx, "nope"); err != nil || m != nil { + t.Fatalf("GetMetadata missing: m=%v err=%v", m, err) + } +} + +func assertOutboxCount(t *testing.T, s *Store, ctx context.Context, want int) { + t.Helper() + rows, err := s.q.ListUnsentOutbox(ctx, 1000) + if err != nil { + t.Fatalf("list outbox: %v", err) + } + if len(rows) != want { + t.Fatalf("outbox count = %d, want %d", len(rows), want) + } +} diff --git a/backend/internal/storage/sqlite/upsert.go b/backend/internal/storage/sqlite/upsert.go new file mode 100644 index 0000000000..40944005a1 --- /dev/null +++ b/backend/internal/storage/sqlite/upsert.go @@ -0,0 +1,113 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// Upsert performs the one atomic canonical write: it CAS-checks and persists the +// session row (bumping revision), appends a change_log entry, and enqueues an +// outbox row linked to that entry's seq — all in a single transaction. Only the +// LCM calls this. +// +// Revision CAS (mirrors the in-memory store contract exactly): +// - existing row: rec.Lifecycle.Revision must equal the stored revision, else +// a revision-mismatch error and nothing is written; on match it persists at +// stored+1. +// - insert: rec.Lifecycle.Revision must be 0, persisted as 1. +func (s *Store) Upsert(ctx context.Context, rec domain.SessionRecord, eventType ports.EventType) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin upsert: %w", err) + } + defer tx.Rollback() + qtx := s.q.WithTx(tx) + + newRevision, err := casPersist(ctx, qtx, rec) + if err != nil { + return err + } + + if err := appendOutbox(ctx, qtx, rec, newRevision, eventType); err != nil { + return err + } + + return tx.Commit() +} + +// casPersist applies the revision-CAS insert-or-update and returns the new +// stored revision. +func casPersist(ctx context.Context, q *gen.Queries, rec domain.SessionRecord) (int, error) { + stored, err := q.GetSessionRevision(ctx, string(rec.ID)) + switch { + case errors.Is(err, sql.ErrNoRows): + // Insert path: incoming revision must be 0; row persists at revision 1. + if rec.Lifecycle.Revision != 0 { + return 0, fmt.Errorf("revision mismatch for insert %s: have %d, want 0", rec.ID, rec.Lifecycle.Revision) + } + rows, err := q.InsertSession(ctx, recordToInsert(rec)) + if err != nil { + return 0, fmt.Errorf("insert session %s: %w", rec.ID, err) + } + if rows != 1 { + // Another writer raced us between the revision check and the insert. + // With single-writer this should not happen; treat as a CAS failure. + return 0, fmt.Errorf("revision mismatch for insert %s: row already exists", rec.ID) + } + return 1, nil + case err != nil: + return 0, fmt.Errorf("read revision %s: %w", rec.ID, err) + default: + // Update path: incoming revision must equal the stored revision. + if int64(rec.Lifecycle.Revision) != stored { + return 0, fmt.Errorf("revision mismatch for %s: have %d, want %d", rec.ID, rec.Lifecycle.Revision, stored) + } + rows, err := q.UpdateSessionCAS(ctx, recordToUpdate(rec, stored)) + if err != nil { + return 0, fmt.Errorf("update session %s: %w", rec.ID, err) + } + if rows != 1 { + return 0, fmt.Errorf("revision mismatch for %s: stale revision %d", rec.ID, rec.Lifecycle.Revision) + } + return int(stored) + 1, nil + } +} + +// appendOutbox writes the change_log entry and threads its seq into a fresh +// outbox row. The change_log payload is the persisted record at its new +// revision (metadata excluded — it is not on the canonical path). +func appendOutbox(ctx context.Context, q *gen.Queries, rec domain.SessionRecord, newRevision int, eventType ports.EventType) error { + now := time.Now().UTC() + payload := rec + payload.Lifecycle.Revision = newRevision + payload.Lifecycle.Version = domain.LifecycleVersion + payload.Metadata = nil + blob, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal change_log payload %s: %w", rec.ID, err) + } + + seq, err := q.InsertChangeLog(ctx, gen.InsertChangeLogParams{ + SessionID: string(rec.ID), + EventType: string(eventType), + Revision: int64(newRevision), + Payload: string(blob), + CreatedAt: now, + }) + if err != nil { + return fmt.Errorf("insert change_log %s: %w", rec.ID, err) + } + + if err := q.InsertOutbox(ctx, gen.InsertOutboxParams{ChangeLogSeq: seq, CreatedAt: now}); err != nil { + return fmt.Errorf("insert outbox %s: %w", rec.ID, err) + } + return nil +} diff --git a/backend/lifecycle_wiring.go b/backend/lifecycle_wiring.go new file mode 100644 index 0000000000..3836baf683 --- /dev/null +++ b/backend/lifecycle_wiring.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "log/slog" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// lifecycleStack owns the running LCM + reaper. The LCM is the sole writer into +// the store (every Apply*/On* call ends in store.Upsert, which the CDC pipeline +// then drains); the reaper is the OBSERVE-layer timer that probes live runtimes +// and reports facts back through the LCM. Together with the CDC substrate this +// makes the write path live end-to-end: LCM -> store -> outbox -> JSONL -> +// broadcaster. +type lifecycleStack struct { + LCM *lifecycle.Manager + reaperDone <-chan struct{} +} + +// startLifecycle constructs the LCM over store, makes escalation budgets durable, +// teaches it to enumerate sessions for the reaper, and starts the reaper loop. +// The goroutine stops when ctx is cancelled; Stop waits for it to drain. +// +// TEMPORARY STUBS (replace as the daemon lane lands the real collaborators): +// +// - noopNotifier — swap for the production notifier multiplexer once the +// notifier plugins (desktop/Slack/webhook) are ported. Wire it where +// noopNotifier{} is passed to lifecycle.New below. +// - noopMessenger — swap for the AgentMessenger backed by the runtime/agent +// plugins (it injects a prompt into the live agent pane). Wire it at the +// same lifecycle.New call site. +// - reaper.MapRegistry{} — empty runtime registry, so the reaper probes +// nothing. Register the real runtime adapters (tmux/process) keyed by +// runtime name once those plugins exist: reaper.MapRegistry{"tmux": rt}. +func startLifecycle(ctx context.Context, store *sqlite.Store, logger *slog.Logger) (*lifecycleStack, error) { + // TODO(daemon-lane): replace noopNotifier{}/noopMessenger{} with the real + // notifier multiplexer and the plugin-backed AgentMessenger. + lcm := lifecycle.New(store, noopNotifier{}, noopMessenger{}) + + // Durable escalation budgets (flaw #3 fix): hydrate from the store and turn + // on write-through so a restart does not re-fire an already-escalated page. + // Must run before the reaper starts dispatching TickEscalations. + if err := lcm.WithReactionStore(ctx, lifecycleReactionStore{store}); err != nil { + return nil, err + } + + // The reaper's RunningSessions snapshot needs to see every session; ListAll + // spans all projects (the per-project List would hide cross-project work). + lcm.WithSessionLister(store.ListAll) + + // TODO(daemon-lane): pass the real runtime registry so the reaper actually + // probes live panes. With an empty registry it ticks escalations but probes + // nothing, which is correct until runtimes exist. + rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Logger: logger}) + + return &lifecycleStack{LCM: lcm, reaperDone: rp.Start(ctx)}, nil +} + +// Stop waits for the reaper goroutine to exit (the caller must have cancelled the +// ctx passed to startLifecycle). +func (l *lifecycleStack) Stop() { + <-l.reaperDone +} + +// noopNotifier satisfies ports.Notifier by dropping every event. TEMPORARY: the +// daemon lane replaces this with the notifier multiplexer over the real notifier +// plugins. Until then human-facing notifications are silently discarded — the +// write path and CDC still work, only the human push is absent. +type noopNotifier struct{} + +func (noopNotifier) Notify(context.Context, ports.OrchestratorEvent) error { return nil } + +// noopMessenger satisfies ports.AgentMessenger by dropping every send. TEMPORARY: +// replace with the runtime/agent-plugin-backed messenger that injects prompts +// into the live agent pane. Until then auto-nudge reactions are no-ops. +type noopMessenger struct{} + +func (noopMessenger) Send(context.Context, domain.SessionID, string) error { return nil } + +// lifecycleReactionStore bridges the concrete *sqlite.Store to the lifecycle +// package's ReactionStore interface (string/row types <-> domain types). It is +// the production twin of the reactionStoreAdapter used in the lifecycle tests. +type lifecycleReactionStore struct{ store *sqlite.Store } + +func (a lifecycleReactionStore) LoadReactionTrackers(ctx context.Context) ([]lifecycle.PersistedTracker, error) { + rows, err := a.store.ListReactionTrackers(ctx) + if err != nil { + return nil, err + } + out := make([]lifecycle.PersistedTracker, len(rows)) + for i, r := range rows { + out[i] = lifecycle.PersistedTracker{ + SessionID: domain.SessionID(r.SessionID), + Key: r.ReactionKey, + Attempts: r.Attempts, + Escalated: r.Escalated, + FirstAttemptAt: r.FirstAttemptAt, + ProjectID: domain.ProjectID(r.ProjectID), + } + } + return out, nil +} + +func (a lifecycleReactionStore) SaveReactionTracker(ctx context.Context, t lifecycle.PersistedTracker) error { + return a.store.SaveReactionTracker(ctx, sqlite.ReactionTrackerRow{ + SessionID: string(t.SessionID), + ReactionKey: t.Key, + Attempts: t.Attempts, + Escalated: t.Escalated, + FirstAttemptAt: t.FirstAttemptAt, + ProjectID: string(t.ProjectID), + }) +} + +func (a lifecycleReactionStore) DeleteReactionTracker(ctx context.Context, id domain.SessionID, key string) error { + return a.store.DeleteReactionTracker(ctx, string(id), key) +} + +func (a lifecycleReactionStore) DeleteSessionReactionTrackers(ctx context.Context, id domain.SessionID) error { + return a.store.DeleteSessionReactionTrackers(ctx, string(id)) +} diff --git a/backend/main.go b/backend/main.go index 78a232927b..8db058ea58 100644 --- a/backend/main.go +++ b/backend/main.go @@ -15,6 +15,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) func main() { @@ -46,11 +47,54 @@ func run() error { return err } + // Open the durable store and bring up the CDC substrate (outbox publisher, + // JSONL consumer + broadcaster, outbox janitor). The LCM/Session Manager and + // the HTTP API routes that drive and read this store are owned by the daemon + // lane and are wired there once their collaborators (Notifier, AgentMessenger, + // and the runtime/agent/workspace plugins) have production implementations; + // here we stand up the persistence + change-delivery foundation they build on. + db, err := sqlite.Open(cfg.DataDir) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer db.Close() + store := sqlite.NewStore(db) + // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the // graceful shutdown inside Server.Run. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + cdcPipe, err := startCDC(ctx, store, cfg.DataDir, log) + if err != nil { + return err + } + defer func() { + if err := cdcPipe.Stop(); err != nil { + log.Error("cdc pipeline shutdown", "err", err) + } + }() + + // Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE + // timer). This makes the write path live end-to-end: LCM.Upsert -> store -> + // outbox -> CDC JSONL -> broadcaster. The collaborators it needs that don't + // yet have production implementations (Notifier, AgentMessenger, runtime + // registry) are stubbed in lifecycle_wiring.go with TODO markers. + // + // NOT wired here yet — both await collaborators the daemon lane owns: + // - Session Manager: session.New needs Runtime/Agent/Workspace plugins to + // construct. Stubbing them would make Spawn a silent no-op (a footgun), + // so it's deferred rather than faked. The LCM already exposes the read + // surface (RunningSessions) the SM would wrap. + // - HTTP API routes: httpd.New takes no SM/LCM today; surfacing the store + // over HTTP needs a constructor signature change + handlers, tracked with + // the SM work since the routes call into it. + lcStack, err := startLifecycle(ctx, store, log) + if err != nil { + return fmt.Errorf("start lifecycle: %w", err) + } + defer lcStack.Stop() + return srv.Run(ctx) } diff --git a/backend/main_test.go b/backend/main_test.go new file mode 100644 index 0000000000..1a8d60c3fe --- /dev/null +++ b/backend/main_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// These tests cover the composition-root adapters in cdc_wiring.go directly +// (package main otherwise has no test coverage): the outboxAdapter mapping the +// store's OutboxEvent to cdc.PendingEvent, and the snapshotSource rebuilding +// full-state events from the sessions table. + +func newWiringStore(t *testing.T) *sqlite.Store { + t.Helper() + db, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { db.Close() }) + return sqlite.NewStore(db) +} + +func wiringRec(id string) domain.SessionRecord { + now := time.Now().UTC() + return domain.SessionRecord{ + ID: domain.SessionID(id), ProjectID: "proj", Kind: domain.KindWorker, CreatedAt: now, UpdatedAt: now, + Lifecycle: domain.CanonicalSessionLifecycle{ + Session: domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress}, + PR: domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonNotCreated}, + Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, + Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, + }, + } +} + +func TestOutboxAdapterMapsPendingEvents(t *testing.T) { + ctx := context.Background() + store := newWiringStore(t) + a := outboxAdapter{store} + + if err := store.Upsert(ctx, wiringRec("s1"), ports.EventSessionCreated); err != nil { + t.Fatalf("upsert: %v", err) + } + + pending, err := a.ListUnsent(ctx, 10) + if err != nil { + t.Fatalf("list unsent: %v", err) + } + if len(pending) != 1 { + t.Fatalf("want 1 pending event, got %d", len(pending)) + } + pe := pending[0] + if pe.Seq != 1 || pe.SessionID != "s1" || pe.EventType != string(ports.EventSessionCreated) || pe.Revision != 1 { + t.Fatalf("unexpected mapping: %+v", pe) + } + if pe.Payload == "" { + t.Fatal("payload should carry the marshaled record") + } + + // MarkSent must clear it from the unsent set. + if err := a.MarkSent(ctx, pe.OutboxID, time.Now().UTC()); err != nil { + t.Fatalf("mark sent: %v", err) + } + again, err := a.ListUnsent(ctx, 10) + if err != nil { + t.Fatalf("list unsent 2: %v", err) + } + if len(again) != 0 { + t.Fatalf("sent event should not reappear, got %d", len(again)) + } +} + +func TestSnapshotSourceRebuildsState(t *testing.T) { + ctx := context.Background() + store := newWiringStore(t) + s := snapshotSource{store} + + // Empty store: no events, maxSeq 0. + events, maxSeq, err := s.Snapshot(ctx) + if err != nil { + t.Fatalf("empty snapshot: %v", err) + } + if len(events) != 0 || maxSeq != 0 { + t.Fatalf("empty store should yield no events and maxSeq 0, got %d events maxSeq %d", len(events), maxSeq) + } + + // Two canonical writes (seq 1,2) across two sessions. + if err := store.Upsert(ctx, wiringRec("s1"), ports.EventSessionCreated); err != nil { + t.Fatalf("upsert s1: %v", err) + } + if err := store.Upsert(ctx, wiringRec("s2"), ports.EventSessionCreated); err != nil { + t.Fatalf("upsert s2: %v", err) + } + + events, maxSeq, err = s.Snapshot(ctx) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if maxSeq != 2 { + t.Fatalf("maxSeq = %d, want 2 (change_log high-water)", maxSeq) + } + if len(events) != 2 { + t.Fatalf("want one event per session (2), got %d", len(events)) + } + for _, e := range events { + if e.Seq != maxSeq { + t.Errorf("snapshot event seq = %d, want resume watermark %d", e.Seq, maxSeq) + } + if e.EventType != "session_snapshot" { + t.Errorf("event type = %q, want session_snapshot", e.EventType) + } + // Payload must be a parseable full record at the persisted revision with + // metadata excluded and the schema version stamped. + var rec domain.SessionRecord + if err := json.Unmarshal([]byte(e.Payload), &rec); err != nil { + t.Fatalf("payload not a SessionRecord: %v", err) + } + if rec.Lifecycle.Version != domain.LifecycleVersion { + t.Errorf("payload version = %d, want %d", rec.Lifecycle.Version, domain.LifecycleVersion) + } + if rec.Lifecycle.Revision != 1 { + t.Errorf("payload revision = %d, want 1", rec.Lifecycle.Revision) + } + if rec.Metadata != nil { + t.Errorf("snapshot payload must exclude metadata, got %v", rec.Metadata) + } + } +} diff --git a/backend/sqlc.yaml b/backend/sqlc.yaml new file mode 100644 index 0000000000..9659bf779a --- /dev/null +++ b/backend/sqlc.yaml @@ -0,0 +1,13 @@ +version: "2" +sql: + - engine: "sqlite" + schema: "internal/storage/sqlite/migrations" + queries: "internal/storage/sqlite/queries" + gen: + go: + package: "gen" + out: "internal/storage/sqlite/gen" + emit_json_tags: false + emit_prepared_queries: false + emit_interface: true + emit_empty_slices: true From 23b8fe43cf01fefa0262fe49a30b0776d9b4e612 Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Sat, 30 May 2026 21:53:14 +0530 Subject: [PATCH 046/250] feat(backend): add projects and pr_enrichment tables to SQLite store Migration 0002 adds two tables off the canonical CDC path: - projects: durable registry of managed repos (the twin of the old YAML config). Soft-deletable via archived_at so a session's project_id always resolves; ListProjects returns active rows only, GetProject resolves any. - pr_enrichment: per-session cache of rich SCM facts (CI summary, review decision, mergeability, pending comments, CI log tail) that do not live in the canonical lifecycle. 1:1 with a session, cascades on session delete. Both are written outside the LCM write path: no revision bump, no change_log/outbox event. Store methods mirror the reaction_trackers adapter pattern with storage-local row structs. --- backend/internal/storage/sqlite/gen/models.go | 25 +++ .../storage/sqlite/gen/pr_enrichment.sql.go | 76 +++++++++ .../storage/sqlite/gen/projects.sql.go | 154 ++++++++++++++++++ .../internal/storage/sqlite/gen/querier.go | 8 + .../sqlite/migrations/0002_pr_projects.sql | 50 ++++++ .../storage/sqlite/pr_projects_test.go | 128 +++++++++++++++ backend/internal/storage/sqlite/pr_store.go | 66 ++++++++ .../internal/storage/sqlite/project_store.go | 115 +++++++++++++ .../storage/sqlite/queries/pr_enrichment.sql | 18 ++ .../storage/sqlite/queries/projects.sql | 32 ++++ 10 files changed, 672 insertions(+) create mode 100644 backend/internal/storage/sqlite/gen/pr_enrichment.sql.go create mode 100644 backend/internal/storage/sqlite/gen/projects.sql.go create mode 100644 backend/internal/storage/sqlite/migrations/0002_pr_projects.sql create mode 100644 backend/internal/storage/sqlite/pr_projects_test.go create mode 100644 backend/internal/storage/sqlite/pr_store.go create mode 100644 backend/internal/storage/sqlite/project_store.go create mode 100644 backend/internal/storage/sqlite/queries/pr_enrichment.sql create mode 100644 backend/internal/storage/sqlite/queries/projects.sql diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 210fe245ab..dccf25c463 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -34,6 +34,31 @@ type Outbox struct { CreatedAt time.Time } +type PrEnrichment struct { + SessionID string + CiSummary string + ReviewDecision string + Mergeability string + PendingComments string + CiLogTail string + LastFetchedAt time.Time +} + +type Project struct { + ID string + Path string + RepoOwner string + RepoName string + RepoPlatform string + RepoOriginUrl string + DefaultBranch string + DisplayName string + SessionPrefix string + Source string + RegisteredAt time.Time + ArchivedAt sql.NullTime +} + type ReactionTracker struct { SessionID string ReactionKey string diff --git a/backend/internal/storage/sqlite/gen/pr_enrichment.sql.go b/backend/internal/storage/sqlite/gen/pr_enrichment.sql.go new file mode 100644 index 0000000000..c0643104ba --- /dev/null +++ b/backend/internal/storage/sqlite/gen/pr_enrichment.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: pr_enrichment.sql + +package gen + +import ( + "context" + "time" +) + +const deletePREnrichment = `-- name: DeletePREnrichment :exec +DELETE FROM pr_enrichment WHERE session_id = ? +` + +func (q *Queries) DeletePREnrichment(ctx context.Context, sessionID string) error { + _, err := q.db.ExecContext(ctx, deletePREnrichment, sessionID) + return err +} + +const getPREnrichment = `-- name: GetPREnrichment :one +SELECT session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at +FROM pr_enrichment +WHERE session_id = ? +` + +func (q *Queries) GetPREnrichment(ctx context.Context, sessionID string) (PrEnrichment, error) { + row := q.db.QueryRowContext(ctx, getPREnrichment, sessionID) + var i PrEnrichment + err := row.Scan( + &i.SessionID, + &i.CiSummary, + &i.ReviewDecision, + &i.Mergeability, + &i.PendingComments, + &i.CiLogTail, + &i.LastFetchedAt, + ) + return i, err +} + +const upsertPREnrichment = `-- name: UpsertPREnrichment :exec +INSERT INTO pr_enrichment (session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id) DO UPDATE SET + ci_summary = excluded.ci_summary, + review_decision = excluded.review_decision, + mergeability = excluded.mergeability, + pending_comments = excluded.pending_comments, + ci_log_tail = excluded.ci_log_tail, + last_fetched_at = excluded.last_fetched_at +` + +type UpsertPREnrichmentParams struct { + SessionID string + CiSummary string + ReviewDecision string + Mergeability string + PendingComments string + CiLogTail string + LastFetchedAt time.Time +} + +func (q *Queries) UpsertPREnrichment(ctx context.Context, arg UpsertPREnrichmentParams) error { + _, err := q.db.ExecContext(ctx, upsertPREnrichment, + arg.SessionID, + arg.CiSummary, + arg.ReviewDecision, + arg.Mergeability, + arg.PendingComments, + arg.CiLogTail, + arg.LastFetchedAt, + ) + return err +} diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go new file mode 100644 index 0000000000..33959b765a --- /dev/null +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -0,0 +1,154 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: projects.sql + +package gen + +import ( + "context" + "database/sql" + "time" +) + +const archiveProject = `-- name: ArchiveProject :exec +UPDATE projects SET archived_at = ? WHERE id = ? +` + +type ArchiveProjectParams struct { + ArchivedAt sql.NullTime + ID string +} + +func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error { + _, err := q.db.ExecContext(ctx, archiveProject, arg.ArchivedAt, arg.ID) + return err +} + +const deleteProject = `-- name: DeleteProject :exec +DELETE FROM projects WHERE id = ? +` + +func (q *Queries) DeleteProject(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteProject, id) + return err +} + +const getProject = `-- name: GetProject :one +SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at +FROM projects +WHERE id = ? +` + +func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { + row := q.db.QueryRowContext(ctx, getProject, id) + var i Project + err := row.Scan( + &i.ID, + &i.Path, + &i.RepoOwner, + &i.RepoName, + &i.RepoPlatform, + &i.RepoOriginUrl, + &i.DefaultBranch, + &i.DisplayName, + &i.SessionPrefix, + &i.Source, + &i.RegisteredAt, + &i.ArchivedAt, + ) + return i, err +} + +const listProjects = `-- name: ListProjects :many +SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at +FROM projects +WHERE archived_at IS NULL +ORDER BY id +` + +func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { + rows, err := q.db.QueryContext(ctx, listProjects) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Project{} + for rows.Next() { + var i Project + if err := rows.Scan( + &i.ID, + &i.Path, + &i.RepoOwner, + &i.RepoName, + &i.RepoPlatform, + &i.RepoOriginUrl, + &i.DefaultBranch, + &i.DisplayName, + &i.SessionPrefix, + &i.Source, + &i.RegisteredAt, + &i.ArchivedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertProject = `-- name: UpsertProject :exec +INSERT INTO projects (id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (id) DO UPDATE SET + path = excluded.path, + repo_owner = excluded.repo_owner, + repo_name = excluded.repo_name, + repo_platform = excluded.repo_platform, + repo_origin_url = excluded.repo_origin_url, + default_branch = excluded.default_branch, + display_name = excluded.display_name, + session_prefix = excluded.session_prefix, + source = excluded.source, + registered_at = excluded.registered_at, + archived_at = excluded.archived_at +` + +type UpsertProjectParams struct { + ID string + Path string + RepoOwner string + RepoName string + RepoPlatform string + RepoOriginUrl string + DefaultBranch string + DisplayName string + SessionPrefix string + Source string + RegisteredAt time.Time + ArchivedAt sql.NullTime +} + +func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) error { + _, err := q.db.ExecContext(ctx, upsertProject, + arg.ID, + arg.Path, + arg.RepoOwner, + arg.RepoName, + arg.RepoPlatform, + arg.RepoOriginUrl, + arg.DefaultBranch, + arg.DisplayName, + arg.SessionPrefix, + arg.Source, + arg.RegisteredAt, + arg.ArchivedAt, + ) + return err +} diff --git a/backend/internal/storage/sqlite/gen/querier.go b/backend/internal/storage/sqlite/gen/querier.go index 074fe053c9..76dd1aab58 100644 --- a/backend/internal/storage/sqlite/gen/querier.go +++ b/backend/internal/storage/sqlite/gen/querier.go @@ -9,11 +9,16 @@ import ( ) type Querier interface { + ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error + DeletePREnrichment(ctx context.Context, sessionID string) error + DeleteProject(ctx context.Context, id string) error DeleteReactionTracker(ctx context.Context, arg DeleteReactionTrackerParams) error DeleteSentOutboxBelow(ctx context.Context, changeLogSeq int64) (int64, error) DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error GetConsumerOffset(ctx context.Context, consumer string) (int64, error) GetMetadata(ctx context.Context, sessionID string) ([]GetMetadataRow, error) + GetPREnrichment(ctx context.Context, sessionID string) (PrEnrichment, error) + GetProject(ctx context.Context, id string) (Project, error) GetSession(ctx context.Context, id string) (Session, error) GetSessionRevision(ctx context.Context, id string) (int64, error) // Appends a canonical-write record and returns its monotonic seq so the same @@ -24,6 +29,7 @@ type Querier interface { // the row is persisted at revision 1. InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) ListAllSessions(ctx context.Context) ([]Session, error) + ListProjects(ctx context.Context) ([]Project, error) ListReactionTrackers(ctx context.Context) ([]ReactionTracker, error) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) ListUnsentOutbox(ctx context.Context, limit int64) ([]ListUnsentOutboxRow, error) @@ -36,6 +42,8 @@ type Querier interface { UpdateSessionCAS(ctx context.Context, arg UpdateSessionCASParams) (int64, error) UpsertConsumerOffset(ctx context.Context, arg UpsertConsumerOffsetParams) error UpsertMetadata(ctx context.Context, arg UpsertMetadataParams) error + UpsertPREnrichment(ctx context.Context, arg UpsertPREnrichmentParams) error + UpsertProject(ctx context.Context, arg UpsertProjectParams) error UpsertReactionTracker(ctx context.Context, arg UpsertReactionTrackerParams) error } diff --git a/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql b/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql new file mode 100644 index 0000000000..4421f0ddc1 --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql @@ -0,0 +1,50 @@ +-- +goose Up +-- +goose StatementBegin + +-- projects is the durable registry of repos AO manages, the SQLite twin of the +-- old YAML config (global config.yaml + per-repo agent-orchestrator.yaml). id is +-- the {basename}_{sha256(path:originUrl)[:10]} key the session layer references +-- via sessions.project_id. The relationship is app-enforced, NOT a hard FK: +-- SQLite cannot ALTER ADD a FK without a table rebuild, and an existing-session +-- backfill may land sessions before their project row. +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL, + repo_owner TEXT NOT NULL DEFAULT '', + repo_name TEXT NOT NULL DEFAULT '', + repo_platform TEXT NOT NULL DEFAULT '', + repo_origin_url TEXT NOT NULL DEFAULT '', + default_branch TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + session_prefix TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + registered_at TIMESTAMP NOT NULL, + + -- soft delete: NULL = active. Archiving keeps the row so a session's + -- project_id always resolves (there is no FK to enforce it), avoiding + -- dangling references; active-only reads filter archived_at IS NULL. + archived_at TIMESTAMP +); + +-- pr_enrichment is the SCM observer's per-session cache of the rich PR facts that +-- do NOT live in the canonical lifecycle (which keeps only pr_state/reason/number/ +-- url). It is 1:1 with a session (a PR is always tied to a session by its branch), +-- written by the SCM observer OFF the canonical CDC path (no revision bump, no +-- change_log/outbox event), and cascades away with its session. +CREATE TABLE pr_enrichment ( + session_id TEXT PRIMARY KEY REFERENCES sessions (id) ON DELETE CASCADE, + ci_summary TEXT NOT NULL DEFAULT '', + review_decision TEXT NOT NULL DEFAULT '', + mergeability TEXT NOT NULL DEFAULT '', + pending_comments TEXT NOT NULL DEFAULT '', + ci_log_tail TEXT NOT NULL DEFAULT '', + last_fetched_at TIMESTAMP NOT NULL +); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE pr_enrichment; +DROP TABLE projects; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/pr_projects_test.go b/backend/internal/storage/sqlite/pr_projects_test.go new file mode 100644 index 0000000000..6cdd20bc11 --- /dev/null +++ b/backend/internal/storage/sqlite/pr_projects_test.go @@ -0,0 +1,128 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestProjectUpsertGetListDelete(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + if _, ok, err := s.GetProject(ctx, "p1"); err != nil || ok { + t.Fatalf("get missing: ok=%v err=%v", ok, err) + } + + p := ProjectRow{ + ID: "p1", Path: "/repo", RepoOwner: "acme", RepoName: "widget", + RepoPlatform: "github", RepoOriginURL: "git@github.com:acme/widget.git", + DefaultBranch: "main", DisplayName: "Widget", SessionPrefix: "wid", + Source: "local", RegisteredAt: now, + } + if err := s.UpsertProject(ctx, p); err != nil { + t.Fatalf("upsert: %v", err) + } + + got, ok, err := s.GetProject(ctx, "p1") + if err != nil || !ok { + t.Fatalf("get: ok=%v err=%v", ok, err) + } + if got != p { + t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, p) + } + + // Upsert again with a changed field updates in place (no duplicate). + p.DisplayName = "Widget 2" + if err := s.UpsertProject(ctx, p); err != nil { + t.Fatalf("re-upsert: %v", err) + } + list, err := s.ListProjects(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 1 || list[0].DisplayName != "Widget 2" { + t.Fatalf("list after re-upsert = %+v", list) + } + + if err := s.DeleteProject(ctx, "p1"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, ok, _ := s.GetProject(ctx, "p1"); ok { + t.Fatal("project should be gone after delete") + } +} + +func TestArchiveProjectHidesFromListButGetResolves(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + if err := s.UpsertProject(ctx, ProjectRow{ID: "p1", Path: "/repo", RegisteredAt: now}); err != nil { + t.Fatalf("upsert: %v", err) + } + if err := s.ArchiveProject(ctx, "p1", now); err != nil { + t.Fatalf("archive: %v", err) + } + + // Active-only list hides it. + list, err := s.ListProjects(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 0 { + t.Fatalf("archived project should not appear in ListProjects, got %+v", list) + } + + // Get still resolves it (a session's project_id must not dangle) and reports + // the archived marker. + got, ok, err := s.GetProject(ctx, "p1") + if err != nil || !ok { + t.Fatalf("get archived: ok=%v err=%v", ok, err) + } + if got.ArchivedAt.IsZero() { + t.Fatal("archived project should carry a non-zero ArchivedAt") + } +} + +func TestPREnrichmentUpsertGetDelete(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + // pr_enrichment FKs sessions(id); seed the session first. + if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { + t.Fatalf("seed session: %v", err) + } + + if _, ok, err := s.GetPREnrichment(ctx, "s1"); err != nil || ok { + t.Fatalf("get missing: ok=%v err=%v", ok, err) + } + + e := PREnrichmentRow{ + SessionID: "s1", CISummary: "3 passing, 1 failing", ReviewDecision: "changes_requested", + Mergeability: "blocked", PendingComments: `[{"path":"a.go"}]`, CILogTail: "FAIL TestX", + LastFetchedAt: now, + } + if err := s.UpsertPREnrichment(ctx, e); err != nil { + t.Fatalf("upsert: %v", err) + } + + got, ok, err := s.GetPREnrichment(ctx, "s1") + if err != nil || !ok { + t.Fatalf("get: ok=%v err=%v", ok, err) + } + if got != e { + t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, e) + } + + if err := s.DeletePREnrichment(ctx, "s1"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, ok, _ := s.GetPREnrichment(ctx, "s1"); ok { + t.Fatal("enrichment should be gone after delete") + } +} diff --git a/backend/internal/storage/sqlite/pr_store.go b/backend/internal/storage/sqlite/pr_store.go new file mode 100644 index 0000000000..70efb7ce73 --- /dev/null +++ b/backend/internal/storage/sqlite/pr_store.go @@ -0,0 +1,66 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// PREnrichmentRow is the SCM observer's cache of the rich PR facts that do not +// live in the canonical lifecycle (which keeps only pr_state/reason/number/url). +// It is 1:1 with a session and written OFF the canonical CDC path: upserting it +// never bumps revision and never emits a change_log/outbox event. pending_comments +// and ci_log_tail are opaque blobs the SCM observer serializes. +type PREnrichmentRow struct { + SessionID string + CISummary string + ReviewDecision string + Mergeability string + PendingComments string + CILogTail string + LastFetchedAt time.Time +} + +// UpsertPREnrichment inserts or replaces the cached PR facts for one session. +func (s *Store) UpsertPREnrichment(ctx context.Context, r PREnrichmentRow) error { + return s.q.UpsertPREnrichment(ctx, gen.UpsertPREnrichmentParams{ + SessionID: r.SessionID, + CiSummary: r.CISummary, + ReviewDecision: r.ReviewDecision, + Mergeability: r.Mergeability, + PendingComments: r.PendingComments, + CiLogTail: r.CILogTail, + LastFetchedAt: r.LastFetchedAt, + }) +} + +// GetPREnrichment returns the cached PR facts for one session. ok is false when +// no row exists (the SCM observer has not yet fetched, or the session has no PR). +func (s *Store) GetPREnrichment(ctx context.Context, sessionID string) (PREnrichmentRow, bool, error) { + e, err := s.q.GetPREnrichment(ctx, sessionID) + if errors.Is(err, sql.ErrNoRows) { + return PREnrichmentRow{}, false, nil + } + if err != nil { + return PREnrichmentRow{}, false, fmt.Errorf("get pr enrichment: %w", err) + } + return PREnrichmentRow{ + SessionID: e.SessionID, + CISummary: e.CiSummary, + ReviewDecision: e.ReviewDecision, + Mergeability: e.Mergeability, + PendingComments: e.PendingComments, + CILogTail: e.CiLogTail, + LastFetchedAt: e.LastFetchedAt, + }, true, nil +} + +// DeletePREnrichment drops the cached PR facts for one session. Normally +// unnecessary (the FK cascades on session delete), exposed for explicit eviction. +func (s *Store) DeletePREnrichment(ctx context.Context, sessionID string) error { + return s.q.DeletePREnrichment(ctx, sessionID) +} diff --git a/backend/internal/storage/sqlite/project_store.go b/backend/internal/storage/sqlite/project_store.go new file mode 100644 index 0000000000..fb75e18aee --- /dev/null +++ b/backend/internal/storage/sqlite/project_store.go @@ -0,0 +1,115 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// ProjectRow is one registered repo, the durable twin of the old YAML config +// entry. It is the unit the registration path upserts and cross-project readers +// list. Off the canonical CDC path: writing a project never emits a change_log +// or outbox event. +type ProjectRow struct { + ID string + Path string + RepoOwner string + RepoName string + RepoPlatform string + RepoOriginURL string + DefaultBranch string + DisplayName string + SessionPrefix string + Source string + RegisteredAt time.Time + // ArchivedAt is the soft-delete marker; zero means active. GetProject returns + // it regardless of state (so a session can resolve its archived project); + // ListProjects returns only rows where it is zero. + ArchivedAt time.Time +} + +// UpsertProject inserts or updates one registered project. +func (s *Store) UpsertProject(ctx context.Context, r ProjectRow) error { + return s.q.UpsertProject(ctx, gen.UpsertProjectParams{ + ID: r.ID, + Path: r.Path, + RepoOwner: r.RepoOwner, + RepoName: r.RepoName, + RepoPlatform: r.RepoPlatform, + RepoOriginUrl: r.RepoOriginURL, + DefaultBranch: r.DefaultBranch, + DisplayName: r.DisplayName, + SessionPrefix: r.SessionPrefix, + Source: r.Source, + RegisteredAt: r.RegisteredAt, + ArchivedAt: nullTime(r.ArchivedAt), + }) +} + +// ArchiveProject soft-deletes one project, keeping the row so a session's +// project_id still resolves. Active-only reads (ListProjects) then hide it. +func (s *Store) ArchiveProject(ctx context.Context, id string, t time.Time) error { + return s.q.ArchiveProject(ctx, gen.ArchiveProjectParams{ + ArchivedAt: nullTime(t), + ID: id, + }) +} + +// GetProject returns one project by id. ok is false when no row exists. +func (s *Store) GetProject(ctx context.Context, id string) (ProjectRow, bool, error) { + p, err := s.q.GetProject(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return ProjectRow{}, false, nil + } + if err != nil { + return ProjectRow{}, false, fmt.Errorf("get project: %w", err) + } + return projectRowFromGen(p), true, nil +} + +// ListProjects returns every registered project, ordered by id. +func (s *Store) ListProjects(ctx context.Context) ([]ProjectRow, error) { + rows, err := s.q.ListProjects(ctx) + if err != nil { + return nil, fmt.Errorf("list projects: %w", err) + } + out := make([]ProjectRow, 0, len(rows)) + for _, p := range rows { + out = append(out, projectRowFromGen(p)) + } + return out, nil +} + +// DeleteProject removes one project by id. +func (s *Store) DeleteProject(ctx context.Context, id string) error { + return s.q.DeleteProject(ctx, id) +} + +func projectRowFromGen(p gen.Project) ProjectRow { + return ProjectRow{ + ID: p.ID, + Path: p.Path, + RepoOwner: p.RepoOwner, + RepoName: p.RepoName, + RepoPlatform: p.RepoPlatform, + RepoOriginURL: p.RepoOriginUrl, + DefaultBranch: p.DefaultBranch, + DisplayName: p.DisplayName, + SessionPrefix: p.SessionPrefix, + Source: p.Source, + RegisteredAt: p.RegisteredAt, + ArchivedAt: p.ArchivedAt.Time, + } +} + +// nullTime maps a zero time.Time to a NULL column, else a valid timestamp. +func nullTime(t time.Time) sql.NullTime { + if t.IsZero() { + return sql.NullTime{} + } + return sql.NullTime{Time: t, Valid: true} +} diff --git a/backend/internal/storage/sqlite/queries/pr_enrichment.sql b/backend/internal/storage/sqlite/queries/pr_enrichment.sql new file mode 100644 index 0000000000..7c2ac0a030 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/pr_enrichment.sql @@ -0,0 +1,18 @@ +-- name: UpsertPREnrichment :exec +INSERT INTO pr_enrichment (session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id) DO UPDATE SET + ci_summary = excluded.ci_summary, + review_decision = excluded.review_decision, + mergeability = excluded.mergeability, + pending_comments = excluded.pending_comments, + ci_log_tail = excluded.ci_log_tail, + last_fetched_at = excluded.last_fetched_at; + +-- name: GetPREnrichment :one +SELECT session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at +FROM pr_enrichment +WHERE session_id = ?; + +-- name: DeletePREnrichment :exec +DELETE FROM pr_enrichment WHERE session_id = ?; diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql new file mode 100644 index 0000000000..054b8f0e8d --- /dev/null +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -0,0 +1,32 @@ +-- name: UpsertProject :exec +INSERT INTO projects (id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (id) DO UPDATE SET + path = excluded.path, + repo_owner = excluded.repo_owner, + repo_name = excluded.repo_name, + repo_platform = excluded.repo_platform, + repo_origin_url = excluded.repo_origin_url, + default_branch = excluded.default_branch, + display_name = excluded.display_name, + session_prefix = excluded.session_prefix, + source = excluded.source, + registered_at = excluded.registered_at, + archived_at = excluded.archived_at; + +-- name: GetProject :one +SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at +FROM projects +WHERE id = ?; + +-- name: ListProjects :many +SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at +FROM projects +WHERE archived_at IS NULL +ORDER BY id; + +-- name: ArchiveProject :exec +UPDATE projects SET archived_at = ? WHERE id = ?; + +-- name: DeleteProject :exec +DELETE FROM projects WHERE id = ?; From 6e4ec499fb905a34099325bbfcd1373a92a6b307 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sat, 30 May 2026 21:53:44 +0530 Subject: [PATCH 047/250] refactor(tracker): drop Comment + Transition; v1 is read-only Get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope correction: mirroring agent lifecycle onto the tracker (status comments, label/state updates) is not wanted in the current rewrite. That work is now tracked as issue #40 and will land once we decide on the opt-out knob, label setup, and Linear's workflow-state fit. Removes from the port and the GitHub adapter: - Comment(ctx, id, body) and ErrEmptyBody - Transition(ctx, id, state) and ErrUnknownState - planForState / transitionPlan and the forward state mapping - reasonComplete constant (only the Get reverse mapping is kept) - 11 tests + the transitionCall normalization helpers Kept (still load-bearing for Get): - All 5 NormalizedIssueState values — Get reports them faithfully when a repo carries the in-progress / in-review labels. - The reverse mapping in mapStateFromGitHub. - RateLimitError with ResetAt + RetryAfter (#35 will use it). Co-Authored-By: Claude Opus 4.7 --- .../internal/adapters/tracker/github/doc.go | 59 ++--- .../adapters/tracker/github/tracker.go | 114 +-------- .../adapters/tracker/github/tracker_test.go | 217 ------------------ backend/internal/ports/tracker.go | 15 +- 4 files changed, 32 insertions(+), 373 deletions(-) diff --git a/backend/internal/adapters/tracker/github/doc.go b/backend/internal/adapters/tracker/github/doc.go index 98bda7c975..f2114334ae 100644 --- a/backend/internal/adapters/tracker/github/doc.go +++ b/backend/internal/adapters/tracker/github/doc.go @@ -1,50 +1,31 @@ // Package github implements the ports.Tracker outbound port for GitHub -// Issues. v1 is write-mostly: Get returns a normalized Issue snapshot, -// Comment posts an issue comment, and Transition projects the cross-provider -// state vocabulary onto GitHub's open/closed + state_reason + labels surface. -// There is no observer loop or cache — those arrive with issue #35. +// Issues. v1 is read-only: Get returns a normalized Issue snapshot that the +// Session Manager uses to hydrate the agent prompt during spawn-bootstrap. +// Writing back to the tracker (Comment, Transition) is deferred to issue +// #40; the observer/polling loop is deferred to issue #35. // -// # Normalized state mapping +// # Reverse state mapping // // GitHub Issues only have two native states (open, closed) plus a -// state_reason on closed issues (completed, not_planned, reopened). The -// orchestrator's lifecycle vocabulary is richer, so the adapter uses two -// well-known labels — "in-progress" and "in-review" — to project the extra -// states onto open issues. +// state_reason on closed issues (completed, not_planned, reopened). Get +// projects them onto the normalized state vocabulary as follows: // -// Normalized state | GitHub API calls performed by Transition -// -----------------+------------------------------------------------------- -// open | PATCH state=open; DELETE labels {in-progress,in-review} -// in_progress | PATCH state=open; POST label in-progress; -// | DELETE label in-review -// review | PATCH state=open; POST label in-review; -// | DELETE label in-progress -// done | PATCH state=closed,state_reason=completed; -// | DELETE labels {in-progress,in-review} -// cancelled | PATCH state=closed,state_reason=not_planned; -// | DELETE labels {in-progress,in-review} +// - closed + state_reason=not_planned -> cancelled +// - closed + (completed | empty | other) -> done +// - open + "in-review" label -> review (wins when +// both status labels are present; the workflow is progress -> review) +// - open + "in-progress" label -> in_progress +// - otherwise -> open // -// Reverse mapping (Get): GitHub state=closed maps to done if state_reason is -// completed or empty, and to cancelled if state_reason is not_planned. For -// open issues, an "in-review" label wins over "in-progress" (the workflow is -// progress -> review -> done), and the absence of both maps to open. -// -// # Label hygiene and partial failures -// -// DELETE on a label that the issue does not carry returns 404; Transition -// treats that as success so the operation is idempotent. -// -// Transition issues 2-3 HTTP requests sequentially (PATCH, optional POST -// label, DELETE label) and is NOT atomic. If the PATCH succeeds but a -// subsequent label call fails, the issue is left in an intermediate state -// (e.g. closed without the status label cleared). Re-invoking Transition -// with the same target state is safe and converges — callers should treat -// the operation as eventually-consistent and retry on transport errors. +// The "in-progress" and "in-review" labels are recognized because humans +// (and other tooling) commonly apply them. The adapter does NOT write them +// in v1 — see issue #40 for the write-side work. // // # Out of scope // -// - No webhook receiver, no polling goroutine, no fact projection into LCM -// (see issue #35 for the observer-loop work). +// - No Comment, no Transition (issue #40). +// - No webhook receiver, no polling goroutine, no fact projection into +// LCM (issue #35). // - No richer per-provider metadata on Issue (milestones, project boards, -// reactions); the port only carries fields all three v1 providers can fill. +// reactions); the port only carries fields all v1 providers can fill. package github diff --git a/backend/internal/adapters/tracker/github/tracker.go b/backend/internal/adapters/tracker/github/tracker.go index 62d8c89046..64e4438923 100644 --- a/backend/internal/adapters/tracker/github/tracker.go +++ b/backend/internal/adapters/tracker/github/tracker.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strconv" "strings" "time" @@ -21,13 +20,15 @@ const ( defaultBaseURL = "https://api.github.com" defaultUserAgent = "ao-agent-orchestrator/tracker-github" + // Status labels used by humans (and other tooling) on GitHub Issues. + // Get's reverse mapping recognizes them so an externally-labeled issue + // reports as in_progress / review. The adapter does NOT write these + // labels in v1 — see issue #40 for the write-side work. labelInProgress = "in-progress" labelInReview = "in-review" - stateOpenGH = "open" - stateClosedGH = "closed" - reasonComplete = "completed" - reasonNotPlan = "not_planned" + stateClosedGH = "closed" + reasonNotPlan = "not_planned" ) // Sentinel errors. Adapter-level callers should match on these via @@ -36,9 +37,7 @@ const ( var ( ErrNotFound = errors.New("github tracker: issue not found") ErrRateLimited = errors.New("github tracker: rate limited") - ErrEmptyBody = errors.New("github tracker: comment body is empty") ErrWrongProvider = errors.New("github tracker: id is not a github tracker id") - ErrUnknownState = errors.New("github tracker: unknown normalized state") ErrBadID = errors.New("github tracker: malformed native id") ) @@ -215,107 +214,6 @@ func mapStateFromGitHub(state, reason string, labels []string) domain.Normalized } } -// --------------------------------------------------------------------------- -// Comment -// --------------------------------------------------------------------------- - -func (t *Tracker) Comment(ctx context.Context, id domain.TrackerID, body string) error { - if strings.TrimSpace(body) == "" { - return ErrEmptyBody - } - owner, repo, number, err := t.parseID(id) - if err != nil { - return err - } - path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, number) - _, err = t.do(ctx, http.MethodPost, path, map[string]string{"body": body}) - return err -} - -// --------------------------------------------------------------------------- -// Transition -// --------------------------------------------------------------------------- - -// transitionPlan is the per-target-state list of mutations to apply. Every -// transition issues exactly one PATCH on the issue, optionally adds one -// status label, and removes any other status labels the issue may carry. -type transitionPlan struct { - patch map[string]any - addLabel string // "" means none - removeLabel []string -} - -func planForState(state domain.NormalizedIssueState) (transitionPlan, error) { - switch state { - case domain.IssueOpen: - return transitionPlan{ - patch: map[string]any{"state": stateOpenGH}, - removeLabel: []string{labelInProgress, labelInReview}, - }, nil - case domain.IssueInProgress: - return transitionPlan{ - patch: map[string]any{"state": stateOpenGH}, - addLabel: labelInProgress, - removeLabel: []string{labelInReview}, - }, nil - case domain.IssueInReview: - return transitionPlan{ - patch: map[string]any{"state": stateOpenGH}, - addLabel: labelInReview, - removeLabel: []string{labelInProgress}, - }, nil - case domain.IssueDone: - return transitionPlan{ - patch: map[string]any{"state": stateClosedGH, "state_reason": reasonComplete}, - removeLabel: []string{labelInProgress, labelInReview}, - }, nil - case domain.IssueCancelled: - return transitionPlan{ - patch: map[string]any{"state": stateClosedGH, "state_reason": reasonNotPlan}, - removeLabel: []string{labelInProgress, labelInReview}, - }, nil - default: - return transitionPlan{}, fmt.Errorf("%w: %q", ErrUnknownState, state) - } -} - -func (t *Tracker) Transition(ctx context.Context, id domain.TrackerID, state domain.NormalizedIssueState) error { - plan, err := planForState(state) - if err != nil { - return err - } - owner, repo, number, err := t.parseID(id) - if err != nil { - return err - } - issuePath := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, number) - - // 1. Patch state (+ state_reason for closed transitions). - if _, err := t.do(ctx, http.MethodPatch, issuePath, plan.patch); err != nil { - return err - } - // 2. Add the target status label (no-op when target is open/done/cancelled). - if plan.addLabel != "" { - body := map[string][]string{"labels": {plan.addLabel}} - if _, err := t.do(ctx, http.MethodPost, issuePath+"/labels", body); err != nil { - return err - } - } - // 3. Remove the other status labels. 404 from GitHub means "label is not - // on this issue", which is the success state we want — swallow it so - // the operation is idempotent. - for _, label := range plan.removeLabel { - labelPath := issuePath + "/labels/" + url.PathEscape(label) - if _, err := t.do(ctx, http.MethodDelete, labelPath, nil); err != nil { - if errors.Is(err, ErrNotFound) { - continue - } - return err - } - } - return nil -} - // --------------------------------------------------------------------------- // HTTP plumbing // --------------------------------------------------------------------------- diff --git a/backend/internal/adapters/tracker/github/tracker_test.go b/backend/internal/adapters/tracker/github/tracker_test.go index 44f5a6cd7e..23424f023f 100644 --- a/backend/internal/adapters/tracker/github/tracker_test.go +++ b/backend/internal/adapters/tracker/github/tracker_test.go @@ -8,7 +8,6 @@ import ( "net/http" "net/http/httptest" "reflect" - "sort" "strconv" "strings" "sync" @@ -322,219 +321,3 @@ func TestGet_CanonicalizesProviderOnOutput(t *testing.T) { t.Fatalf("issue.ID.Native = %q, want o/r#1", issue.ID.Native) } } - -func TestComment_HappyPath(t *testing.T) { - f := newFakeGH(t) - f.on("POST", "/repos/o/r/issues/1/comments", func(w http.ResponseWriter, r *http.Request) { - var got struct { - Body string `json:"body"` - } - if err := json.NewDecoder(r.Body).Decode(&got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Body != "hello world" { - t.Errorf("body = %q, want %q", got.Body, "hello world") - } - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id":1}`)) - }) - tr := newTrackerForTest(t, f) - if err := tr.Comment(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, "hello world"); err != nil { - t.Fatalf("Comment: %v", err) - } -} - -// TestComment_PreservesMarkdownBody locks in that we POST the body verbatim -// — no trimming, no escape-and-unescape round trip — so multi-line markdown -// notifications from the SM survive. -func TestComment_PreservesMarkdownBody(t *testing.T) { - f := newFakeGH(t) - body := "## status\n\n- step 1: done\n- step 2: **in progress**\n\n```go\nfmt.Println(\"hi\")\n```\n" - f.on("POST", "/repos/o/r/issues/1/comments", func(w http.ResponseWriter, r *http.Request) { - var got struct { - Body string `json:"body"` - } - if err := json.NewDecoder(r.Body).Decode(&got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Body != body { - t.Errorf("body = %q, want %q", got.Body, body) - } - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id":1}`)) - }) - tr := newTrackerForTest(t, f) - if err := tr.Comment(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, body); err != nil { - t.Fatalf("Comment: %v", err) - } -} - -func TestComment_RejectsEmptyBody(t *testing.T) { - f := newFakeGH(t) - tr := newTrackerForTest(t, f) - for _, body := range []string{"", " ", "\n\t"} { - err := tr.Comment(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, body) - if !errors.Is(err, ErrEmptyBody) { - t.Fatalf("body %q: err = %v, want ErrEmptyBody", body, err) - } - } - if calls := f.calls(); len(calls) != 0 { - t.Fatalf("unexpected calls on empty body: %#v", calls) - } -} - -// transitionCall is the normalized record of one GH API call made by -// Transition. The tests compare a sorted slice of these against the expected -// call set so we don't depend on call ordering. -type transitionCall struct { - Method string - Path string - // for PATCH /issues/N — JSON keys we care about - State string - StateReason string - // for POST .../labels — labels added - AddLabels []string -} - -func TestTransition_MapsToCorrectGitHubCalls(t *testing.T) { - cases := []struct { - name string - state domain.NormalizedIssueState - want []transitionCall - }{ - { - name: "open clears status labels and reopens", - state: domain.IssueOpen, - want: []transitionCall{ - {Method: "PATCH", Path: "/repos/o/r/issues/1", State: "open"}, - {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"}, - {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"}, - }, - }, - { - name: "in_progress adds in-progress label, removes in-review", - state: domain.IssueInProgress, - want: []transitionCall{ - {Method: "PATCH", Path: "/repos/o/r/issues/1", State: "open"}, - {Method: "POST", Path: "/repos/o/r/issues/1/labels", AddLabels: []string{"in-progress"}}, - {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"}, - }, - }, - { - name: "review adds in-review label, removes in-progress", - state: domain.IssueInReview, - want: []transitionCall{ - {Method: "PATCH", Path: "/repos/o/r/issues/1", State: "open"}, - {Method: "POST", Path: "/repos/o/r/issues/1/labels", AddLabels: []string{"in-review"}}, - {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"}, - }, - }, - { - name: "done closes as completed and cleans status labels", - state: domain.IssueDone, - want: []transitionCall{ - {Method: "PATCH", Path: "/repos/o/r/issues/1", State: "closed", StateReason: "completed"}, - {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"}, - {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"}, - }, - }, - { - name: "cancelled closes as not_planned and cleans status labels", - state: domain.IssueCancelled, - want: []transitionCall{ - {Method: "PATCH", Path: "/repos/o/r/issues/1", State: "closed", StateReason: "not_planned"}, - {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"}, - {Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"}, - }, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - f := newFakeGH(t) - // PATCH endpoint returns an updated issue body - f.on("PATCH", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"number":1,"state":"open"}`)) - }) - // label-add endpoint - f.on("POST", "/repos/o/r/issues/1/labels", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`[]`)) - }) - // label-remove endpoints — return 404 sometimes to confirm we ignore it - f.on("DELETE", "/repos/o/r/issues/1/labels/in-progress", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, `{"message":"Label does not exist"}`, http.StatusNotFound) - }) - f.on("DELETE", "/repos/o/r/issues/1/labels/in-review", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`[]`)) - }) - tr := newTrackerForTest(t, f) - if err := tr.Transition(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, tc.state); err != nil { - t.Fatalf("Transition: %v", err) - } - got := normalizeCalls(t, f.calls()) - want := append([]transitionCall(nil), tc.want...) - sortCalls(got) - sortCalls(want) - if !reflect.DeepEqual(got, want) { - t.Fatalf("calls:\n got %#v\n want %#v", got, want) - } - }) - } -} - -func TestTransition_RejectsUnknownState(t *testing.T) { - f := newFakeGH(t) - tr := newTrackerForTest(t, f) - err := tr.Transition(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, domain.NormalizedIssueState("frobnicated")) - if !errors.Is(err, ErrUnknownState) { - t.Fatalf("err = %v, want ErrUnknownState", err) - } - if calls := f.calls(); len(calls) != 0 { - t.Fatalf("unexpected calls: %#v", calls) - } -} - -// normalizeCalls converts the recordedReq slice into transitionCall records -// the test cases assert against, decoding the PATCH/label-add bodies. -func normalizeCalls(t *testing.T, reqs []recordedReq) []transitionCall { - t.Helper() - out := make([]transitionCall, 0, len(reqs)) - for _, r := range reqs { - tc := transitionCall{Method: r.Method, Path: r.Path} - switch { - case r.Method == "PATCH": - var body struct { - State string `json:"state"` - StateReason string `json:"state_reason"` - } - if r.Body != "" { - if err := json.Unmarshal([]byte(r.Body), &body); err != nil { - t.Fatalf("patch body: %v", err) - } - } - tc.State = body.State - tc.StateReason = body.StateReason - case r.Method == "POST" && strings.HasSuffix(r.Path, "/labels"): - var body struct { - Labels []string `json:"labels"` - } - if r.Body != "" { - if err := json.Unmarshal([]byte(r.Body), &body); err != nil { - t.Fatalf("labels body: %v", err) - } - } - tc.AddLabels = body.Labels - } - out = append(out, tc) - } - return out -} - -func sortCalls(s []transitionCall) { - sort.Slice(s, func(i, j int) bool { - if s[i].Method != s[j].Method { - return s[i].Method < s[j].Method - } - return s[i].Path < s[j].Path - }) -} diff --git a/backend/internal/ports/tracker.go b/backend/internal/ports/tracker.go index 642b1912a4..c4b0240ea3 100644 --- a/backend/internal/ports/tracker.go +++ b/backend/internal/ports/tracker.go @@ -7,19 +7,16 @@ import ( ) // Tracker is the outbound port for issue trackers (GitHub Issues, GitLab -// Issues, Linear). v1 is write-mostly: spawn-bootstrap reads with Get, the -// Session Manager posts status updates with Comment, and lifecycle -// transitions (start, hand-off-to-review, close) propagate with Transition. -// There is no observer loop yet; polling and ApplyTrackerFacts arrive with -// issue #35. +// Issues, Linear). v1 is read-only: Get returns a normalized snapshot used +// by spawn-bootstrap to hydrate the agent prompt. Mirroring agent lifecycle +// back onto the tracker (Comment, Transition) is deferred to issue #40, and +// the observer/polling loop is deferred to issue #35. // -// All three v1 providers share this interface. Provider differences (label -// vs state machine vs close reason) are absorbed inside each adapter via +// All v1 providers share this interface. Provider differences (label vs +// state machine vs close reason) are absorbed inside each adapter via // domain.NormalizedIssueState. Fields on domain.Issue exist only when every // provider can populate them; richer per-provider metadata belongs behind a // separate port. type Tracker interface { Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) - Comment(ctx context.Context, id domain.TrackerID, body string) error - Transition(ctx context.Context, id domain.TrackerID, state domain.NormalizedIssueState) error } From d6cd24583336c1d34418818ae2605df156a94a8b Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sat, 30 May 2026 22:04:58 +0530 Subject: [PATCH 048/250] feat(tracker): add List + Preflight to the port and GitHub adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the read-side surface up to parity with the legacy TS impl's useful read methods. Closes a gap flagged during scope review. Port additions: - List(ctx, repo, filter) ([]Issue, error) - Preflight(ctx) error Domain additions: TrackerRepo, ListStateFilter (open/closed/""=all), ListFilter (State, Labels, Assignee, Limit). GitHub adapter: - List hits GET /repos/{o}/{r}/issues with query-encoded filter. Defaults: state=all, per_page=30; per_page is capped at 100. PRs are filtered out client-side (GitHub conflates them with issues on that endpoint). - Preflight hits GET /user. Success is cached for the Tracker's lifetime via sync.Mutex + bool; failures are intentionally NOT cached so a transient startup glitch is recoverable. - New ErrAuthFailed sentinel. classifyError now maps 401, and 403 without rate-limit signals, to ErrAuthFailed instead of an opaque error — so Preflight callers can distinguish bad-token from other failures. Pagination beyond the first page is intentionally out of scope for v1 (see doc.go); the observer/polling work in #35 will own that. Tests: 43 pass (was 25). Adds Preflight cache + recovery, List query encoding, PR filtering, repo parser rejection, and ErrAuthFailed classification. Co-Authored-By: Claude Opus 4.7 --- .../internal/adapters/tracker/github/doc.go | 17 +- .../adapters/tracker/github/tracker.go | 185 +++++++++++++-- .../adapters/tracker/github/tracker_test.go | 217 ++++++++++++++++++ backend/internal/domain/tracker.go | 34 +++ backend/internal/ports/tracker.go | 17 +- 5 files changed, 446 insertions(+), 24 deletions(-) diff --git a/backend/internal/adapters/tracker/github/doc.go b/backend/internal/adapters/tracker/github/doc.go index f2114334ae..f37c4c90f5 100644 --- a/backend/internal/adapters/tracker/github/doc.go +++ b/backend/internal/adapters/tracker/github/doc.go @@ -1,8 +1,17 @@ // Package github implements the ports.Tracker outbound port for GitHub -// Issues. v1 is read-only: Get returns a normalized Issue snapshot that the -// Session Manager uses to hydrate the agent prompt during spawn-bootstrap. +// Issues. v1 is read-only: +// +// - Get returns a normalized snapshot of one issue (spawn-bootstrap +// reads it to hydrate the agent prompt). +// - List returns a filtered slice of issues in a repo (one page, no +// auto-pagination in v1; PRs are filtered out client-side because +// GitHub's /issues endpoint conflates them). +// - Preflight performs a single GET /user against GitHub to verify the +// token is accepted; success is cached for the lifetime of the +// Tracker, failures are not. +// // Writing back to the tracker (Comment, Transition) is deferred to issue -// #40; the observer/polling loop is deferred to issue #35. +// #40. The observer/polling loop is deferred to issue #35. // // # Reverse state mapping // @@ -24,6 +33,8 @@ // # Out of scope // // - No Comment, no Transition (issue #40). +// - No List pagination beyond a single page (callers requesting more than +// 100 results need to wait for the observer/polling work in issue #35). // - No webhook receiver, no polling goroutine, no fact projection into // LCM (issue #35). // - No richer per-provider metadata on Issue (milestones, project boards, diff --git a/backend/internal/adapters/tracker/github/tracker.go b/backend/internal/adapters/tracker/github/tracker.go index 64e4438923..8176ebfdce 100644 --- a/backend/internal/adapters/tracker/github/tracker.go +++ b/backend/internal/adapters/tracker/github/tracker.go @@ -8,8 +8,10 @@ import ( "fmt" "io" "net/http" + "net/url" "strconv" "strings" + "sync" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -29,6 +31,11 @@ const ( stateClosedGH = "closed" reasonNotPlan = "not_planned" + + // List pagination — GitHub's per_page maxes at 100. We default to 30 + // (matching the legacy gh CLI default) when the caller passes 0. + defaultListLimit = 30 + maxListLimit = 100 ) // Sentinel errors. Adapter-level callers should match on these via @@ -37,6 +44,7 @@ const ( var ( ErrNotFound = errors.New("github tracker: issue not found") ErrRateLimited = errors.New("github tracker: rate limited") + ErrAuthFailed = errors.New("github tracker: authentication failed") ErrWrongProvider = errors.New("github tracker: id is not a github tracker id") ErrBadID = errors.New("github tracker: malformed native id") ) @@ -75,14 +83,19 @@ type Options struct { // Tracker implements ports.Tracker against the GitHub REST API. // -// Construction performs a fail-fast token presence check (no network call — -// validating the token's authorization scope against GitHub requires a real -// request, and that is the first operation any caller will make anyway). +// Construction performs a fail-fast token presence check (no network call). +// The first Preflight call validates the token against GitHub itself; a +// successful preflight is cached for the lifetime of the Tracker so repeat +// calls are free, while failures are intentionally NOT cached so a +// transient startup glitch doesn't permanently brick the adapter. type Tracker struct { http *http.Client tokens TokenSource baseURL string userAgent string + + preflightMu sync.Mutex + preflightOK bool } // New returns a Tracker. It fails fast when no token can be obtained so @@ -122,15 +135,19 @@ var _ ports.Tracker = (*Tracker)(nil) // --------------------------------------------------------------------------- // ghIssue is the subset of fields we read off the REST issue payload. +// PullRequest is present (non-nil) iff GitHub considers this row a PR — +// the /repos/{o}/{r}/issues endpoint conflates the two. List uses it to +// filter PRs out client-side so the SM never sees a PR number as an issue. type ghIssue struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - State string `json:"state"` - StateReason string `json:"state_reason"` - HTMLURL string `json:"html_url"` - Labels []ghLabel `json:"labels"` - Assignees []ghUser `json:"assignees"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + StateReason string `json:"state_reason"` + HTMLURL string `json:"html_url"` + Labels []ghLabel `json:"labels"` + Assignees []ghUser `json:"assignees"` + PullRequest *json.RawMessage `json:"pull_request,omitempty"` } type ghLabel struct { @@ -214,6 +231,113 @@ func mapStateFromGitHub(state, reason string, labels []string) domain.Normalized } } +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +// List returns issues for a repo, filtered by state/labels/assignee. PRs +// that GitHub's /issues endpoint conflates into the response are filtered +// out client-side. Pagination is intentionally NOT implemented in v1 — +// callers get one page bounded by ListFilter.Limit (default 30, max 100). +func (t *Tracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { + if repo.Provider != domain.TrackerProviderGitHub { + return nil, fmt.Errorf("%w: provider=%q", ErrWrongProvider, repo.Provider) + } + owner, repoName, err := parseGitHubRepo(repo.Native) + if err != nil { + return nil, err + } + + q := url.Values{} + switch filter.State { + case domain.ListOpen: + q.Set("state", "open") + case domain.ListClosed: + q.Set("state", "closed") + default: + q.Set("state", "all") + } + if len(filter.Labels) > 0 { + q.Set("labels", strings.Join(filter.Labels, ",")) + } + if filter.Assignee != "" { + q.Set("assignee", filter.Assignee) + } + limit := filter.Limit + if limit <= 0 { + limit = defaultListLimit + } + if limit > maxListLimit { + limit = maxListLimit + } + q.Set("per_page", strconv.Itoa(limit)) + + path := fmt.Sprintf("/repos/%s/%s/issues?%s", owner, repoName, q.Encode()) + resp, err := t.do(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + var raw []ghIssue + if err := json.Unmarshal(resp, &raw); err != nil { + return nil, fmt.Errorf("github tracker: decode list: %w", err) + } + out := make([]domain.Issue, 0, len(raw)) + for _, r := range raw { + if r.PullRequest != nil { + continue + } + labels := make([]string, 0, len(r.Labels)) + for _, l := range r.Labels { + labels = append(labels, l.Name) + } + assignees := make([]string, 0, len(r.Assignees)) + for _, a := range r.Assignees { + assignees = append(assignees, a.Login) + } + issue := domain.Issue{ + ID: domain.TrackerID{ + Provider: domain.TrackerProviderGitHub, + Native: fmt.Sprintf("%s/%s#%d", owner, repoName, r.Number), + }, + Title: r.Title, + Body: r.Body, + State: mapStateFromGitHub(r.State, r.StateReason, labels), + URL: r.HTMLURL, + Labels: labels, + Assignees: assignees, + } + if len(issue.Labels) == 0 { + issue.Labels = nil + } + if len(issue.Assignees) == 0 { + issue.Assignees = nil + } + out = append(out, issue) + } + return out, nil +} + +// --------------------------------------------------------------------------- +// Preflight +// --------------------------------------------------------------------------- + +// Preflight verifies the configured token is accepted by GitHub by making a +// single GET /user request. A successful check is cached for the lifetime +// of the Tracker; failures are never cached so a transient network glitch +// at startup is recoverable on a subsequent call. +func (t *Tracker) Preflight(ctx context.Context) error { + t.preflightMu.Lock() + defer t.preflightMu.Unlock() + if t.preflightOK { + return nil + } + if _, err := t.do(ctx, http.MethodGet, "/user", nil); err != nil { + return err + } + t.preflightOK = true + return nil +} + // --------------------------------------------------------------------------- // HTTP plumbing // --------------------------------------------------------------------------- @@ -262,16 +386,22 @@ func classifyError(resp *http.Response, body []byte) error { return fmt.Errorf("%w: %s", ErrNotFound, msg) case http.StatusTooManyRequests: return rateLimited(resp, msg) - case http.StatusForbidden, http.StatusUnauthorized: + case http.StatusUnauthorized: + // 401 is unambiguously an auth failure. GitHub never uses 401 for + // rate limiting; that's always 403 or 429. + return fmt.Errorf("%w: %s", ErrAuthFailed, msg) + case http.StatusForbidden: // GitHub returns 403 for primary rate-limit exhaustion, for - // secondary/abuse limits, and for genuine auth failures. Three - // signals disambiguate the rate-limit cases from auth: the primary - // limit sets X-RateLimit-Remaining=0; the secondary/abuse limit - // sets Retry-After (and often omits X-RateLimit-Remaining); and - // either case mentions "rate limit" / "abuse" in the body. + // secondary/abuse limits, and for genuine auth/permission failures. + // Disambiguate by signal: primary limit sets X-RateLimit-Remaining=0; + // secondary/abuse sets Retry-After (often without the Remaining + // header); either case mentions "rate limit" / "abuse" in the body. + // Everything else is an auth/permission failure (token missing the + // right scope, repo not visible to this token, etc). if isRateLimited(resp, msg) { return rateLimited(resp, msg) } + return fmt.Errorf("%w: %s", ErrAuthFailed, msg) } return fmt.Errorf("github tracker: %d %s", resp.StatusCode, msg) } @@ -354,3 +484,24 @@ func parseGitHubID(native string) (owner, repo string, number int, err error) { } return owner, repo, n, nil } + +// parseGitHubRepo accepts "owner/repo" and rejects anything containing +// additional slashes or a "#" segment. Used by List. +func parseGitHubRepo(native string) (owner, repo string, err error) { + if native == "" { + return "", "", fmt.Errorf("%w: empty repo", ErrBadID) + } + slash := strings.IndexByte(native, '/') + if slash < 0 { + return "", "", fmt.Errorf("%w: missing owner/repo separator", ErrBadID) + } + owner = native[:slash] + repo = native[slash+1:] + if owner == "" || repo == "" { + return "", "", fmt.Errorf("%w: empty owner or repo segment", ErrBadID) + } + if strings.ContainsAny(repo, "/#") { + return "", "", fmt.Errorf("%w: invalid repo segment %q", ErrBadID, repo) + } + return owner, repo, nil +} diff --git a/backend/internal/adapters/tracker/github/tracker_test.go b/backend/internal/adapters/tracker/github/tracker_test.go index 23424f023f..1c3d7d5455 100644 --- a/backend/internal/adapters/tracker/github/tracker_test.go +++ b/backend/internal/adapters/tracker/github/tracker_test.go @@ -321,3 +321,220 @@ func TestGet_CanonicalizesProviderOnOutput(t *testing.T) { t.Fatalf("issue.ID.Native = %q, want o/r#1", issue.ID.Native) } } + +// TestGet_AuthFailed locks in that a 401 (and 403 without rate-limit +// signals) maps to the typed ErrAuthFailed, so callers — especially +// Preflight — can distinguish bad-token from other failures. +func TestGet_AuthFailed(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"Bad credentials"}`, http.StatusUnauthorized) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +// --------------------------------------------------------------------------- +// Preflight +// --------------------------------------------------------------------------- + +func TestPreflight_HappyPath(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/user", func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer tkn-test" { + t.Errorf("Authorization = %q", got) + } + _, _ = w.Write([]byte(`{"login":"octocat","id":1}`)) + }) + tr := newTrackerForTest(t, f) + if err := tr.Preflight(ctx()); err != nil { + t.Fatalf("Preflight: %v", err) + } +} + +func TestPreflight_InvalidToken(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/user", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"Bad credentials"}`, http.StatusUnauthorized) + }) + tr := newTrackerForTest(t, f) + err := tr.Preflight(ctx()) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +// TestPreflight_CachesSuccess pins that a successful check is cached so the +// daemon doesn't burn a GET /user on every component start that wants to +// confirm tracker auth. +func TestPreflight_CachesSuccess(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/user", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"login":"octocat","id":1}`)) + }) + tr := newTrackerForTest(t, f) + for i := 0; i < 5; i++ { + if err := tr.Preflight(ctx()); err != nil { + t.Fatalf("Preflight #%d: %v", i, err) + } + } + if got := len(f.calls()); got != 1 { + t.Fatalf("HTTP calls = %d, want 1 (success should be cached)", got) + } +} + +// TestPreflight_RetriesAfterFailure pins the recovery property: failures +// must NOT be cached, otherwise a transient network glitch at startup would +// permanently brick the tracker for the lifetime of the daemon. +func TestPreflight_RetriesAfterFailure(t *testing.T) { + f := newFakeGH(t) + var calls int + f.on("GET", "/user", func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + http.Error(w, `{"message":"server exploded"}`, http.StatusInternalServerError) + return + } + _, _ = w.Write([]byte(`{"login":"octocat","id":1}`)) + }) + tr := newTrackerForTest(t, f) + if err := tr.Preflight(ctx()); err == nil { + t.Fatalf("first Preflight expected to fail") + } + if err := tr.Preflight(ctx()); err != nil { + t.Fatalf("second Preflight: %v", err) + } + if got := len(f.calls()); got != 2 { + t.Fatalf("HTTP calls = %d, want 2 (first fail not cached)", got) + } +} + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +func TestList_HappyPathAndDefaults(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if got := q.Get("state"); got != "all" { + t.Errorf("state = %q, want all (default)", got) + } + if got := q.Get("per_page"); got != "30" { + t.Errorf("per_page = %q, want 30 (default)", got) + } + _, _ = w.Write([]byte(`[ + {"number":1,"title":"first","body":"b1","state":"open","html_url":"https://github.com/o/r/issues/1","labels":[{"name":"bug"}],"assignees":[]}, + {"number":2,"title":"second","body":"b2","state":"closed","state_reason":"completed","html_url":"https://github.com/o/r/issues/2","labels":[],"assignees":[{"login":"alice"}]} + ]`)) + }) + tr := newTrackerForTest(t, f) + issues, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"}, domain.ListFilter{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(issues) != 2 { + t.Fatalf("len = %d, want 2", len(issues)) + } + if issues[0].ID.Native != "o/r#1" || issues[0].State != domain.IssueOpen || issues[0].Title != "first" { + t.Fatalf("issues[0] = %#v", issues[0]) + } + if issues[1].ID.Native != "o/r#2" || issues[1].State != domain.IssueDone || len(issues[1].Assignees) != 1 || issues[1].Assignees[0] != "alice" { + t.Fatalf("issues[1] = %#v", issues[1]) + } +} + +func TestList_FiltersOutPullRequests(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) { + // GitHub's issues endpoint returns PRs too. We must filter them out + // so the LCM never tries to spawn an agent against a PR number. + _, _ = w.Write([]byte(`[ + {"number":10,"title":"real issue","state":"open","html_url":"https://github.com/o/r/issues/10"}, + {"number":11,"title":"a PR","state":"open","html_url":"https://github.com/o/r/pull/11","pull_request":{"url":"https://api.github.com/repos/o/r/pulls/11"}}, + {"number":12,"title":"another issue","state":"open","html_url":"https://github.com/o/r/issues/12"} + ]`)) + }) + tr := newTrackerForTest(t, f) + issues, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"}, domain.ListFilter{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(issues) != 2 { + t.Fatalf("len = %d, want 2 (PR must be filtered out)", len(issues)) + } + if issues[0].ID.Native != "o/r#10" || issues[1].ID.Native != "o/r#12" { + t.Fatalf("kept wrong items: %#v", issues) + } +} + +func TestList_QueryEncoding(t *testing.T) { + cases := []struct { + name string + filter domain.ListFilter + wantQ map[string]string + }{ + { + name: "open + labels + assignee + limit", + filter: domain.ListFilter{State: domain.ListOpen, Labels: []string{"bug", "help wanted"}, Assignee: "alice", Limit: 50}, + wantQ: map[string]string{"state": "open", "labels": "bug,help wanted", "assignee": "alice", "per_page": "50"}, + }, + { + name: "closed only", + filter: domain.ListFilter{State: domain.ListClosed}, + wantQ: map[string]string{"state": "closed", "per_page": "30"}, + }, + { + name: "limit capped at 100", + filter: domain.ListFilter{Limit: 9999}, + wantQ: map[string]string{"state": "all", "per_page": "100"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) { + got := r.URL.Query() + for k, want := range tc.wantQ { + if g := got.Get(k); g != want { + t.Errorf("query[%q] = %q, want %q", k, g, want) + } + } + _, _ = w.Write([]byte(`[]`)) + }) + tr := newTrackerForTest(t, f) + if _, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"}, tc.filter); err != nil { + t.Fatalf("List: %v", err) + } + }) + } +} + +func TestList_RejectsWrongProvider(t *testing.T) { + f := newFakeGH(t) + tr := newTrackerForTest(t, f) + _, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderGitLab, Native: "g/p"}, domain.ListFilter{}) + if !errors.Is(err, ErrWrongProvider) { + t.Fatalf("err = %v, want ErrWrongProvider", err) + } + if calls := f.calls(); len(calls) != 0 { + t.Fatalf("unexpected HTTP calls: %#v", calls) + } +} + +func TestList_RejectsBadRepo(t *testing.T) { + cases := []string{"", "noseparator", "/repo", "owner/", "a/b/c"} + for _, native := range cases { + t.Run(native, func(t *testing.T) { + f := newFakeGH(t) + tr := newTrackerForTest(t, f) + _, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: native}, domain.ListFilter{}) + if !errors.Is(err, ErrBadID) { + t.Fatalf("native=%q: err = %v, want ErrBadID", native, err) + } + }) + } +} diff --git a/backend/internal/domain/tracker.go b/backend/internal/domain/tracker.go index 202c6bb179..27137752fd 100644 --- a/backend/internal/domain/tracker.go +++ b/backend/internal/domain/tracker.go @@ -47,3 +47,37 @@ type Issue struct { Labels []string `json:"labels,omitempty"` Assignees []string `json:"assignees,omitempty"` } + +// TrackerRepo identifies a repository (or its provider-equivalent) for +// cross-issue queries like Tracker.List. Native is the provider's canonical +// owner/project form: "owner/repo" for GitHub, "group/project" for GitLab. +// Linear has no native repo concept; adapters may use a team or workspace +// identifier in Native when this port reaches Linear. +type TrackerRepo struct { + Provider TrackerProvider `json:"provider"` + Native string `json:"native"` +} + +// ListStateFilter narrows Tracker.List results by the provider's coarse +// state (open vs closed). It is intentionally NOT the 5-value normalized +// enum — finer filtering (e.g. "only in-review issues") goes through the +// Labels field of ListFilter. +type ListStateFilter string + +const ( + // ListAll is the zero value and returns issues in any state. + ListAll ListStateFilter = "" + ListOpen ListStateFilter = "open" + ListClosed ListStateFilter = "closed" +) + +// ListFilter is the query the Session Manager passes to Tracker.List. +// Empty / zero values mean "no filter on this dimension". Limit is the +// requested page size; the adapter applies its own default when zero and +// caps at the provider's per-page maximum. +type ListFilter struct { + State ListStateFilter `json:"state,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignee string `json:"assignee,omitempty"` + Limit int `json:"limit,omitempty"` +} diff --git a/backend/internal/ports/tracker.go b/backend/internal/ports/tracker.go index c4b0240ea3..d9fac9104d 100644 --- a/backend/internal/ports/tracker.go +++ b/backend/internal/ports/tracker.go @@ -7,10 +7,17 @@ import ( ) // Tracker is the outbound port for issue trackers (GitHub Issues, GitLab -// Issues, Linear). v1 is read-only: Get returns a normalized snapshot used -// by spawn-bootstrap to hydrate the agent prompt. Mirroring agent lifecycle -// back onto the tracker (Comment, Transition) is deferred to issue #40, and -// the observer/polling loop is deferred to issue #35. +// Issues, Linear). v1 is read-only: +// +// - Get returns a normalized snapshot of one issue, used by spawn-bootstrap +// to hydrate the agent prompt. +// - List returns a filtered slice of issues in a repo, used when the SM +// needs to enumerate work (e.g. backlog view, status sweeps). +// - Preflight verifies the configured credential is actually valid against +// the provider so daemons fail fast at startup, not at first request. +// +// Mirroring agent lifecycle back onto the tracker (Comment, Transition) is +// deferred to issue #40. The observer / polling loop is deferred to #35. // // All v1 providers share this interface. Provider differences (label vs // state machine vs close reason) are absorbed inside each adapter via @@ -19,4 +26,6 @@ import ( // separate port. type Tracker interface { Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) + List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) + Preflight(ctx context.Context) error } From a0020e06be556c1173e1ce26ad93b170696ec2d2 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sat, 30 May 2026 22:32:45 +0530 Subject: [PATCH 049/250] refactor(tracker): address code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six fixes from the second code review pass — none load-bearing but all improve the contract honesty or prevent future churn. Tests pass with -race (49 in package, 299 across the backend). 1. Preflight: atomic.Bool fast-path before the mutex so cached-success calls don't contend on the lock. Double-checked locking on the mutex side so concurrent first-callers still serialize the single GET /user request. 2. Preflight godoc: tighten to say it verifies token validity, not repo authorization — Get/List against a specific repo may still return ErrAuthFailed after a green Preflight if the token lacks the scope or the repo isn't visible. 3. domain.ListFilter.Limit godoc: explicitly note that exceeding the provider per-page max is SILENTLY capped (no error, no truncation signal) and that auto-pagination is deferred to #35. 4. Extract issueFromGH helper. Get and List were duplicating the identical ghIssue -> domain.Issue projection; consolidating now prevents a 3-way merge when #40 (Comment/Transition) lands. 5. parseGitHubRepo: reject whitespace and # in both owner and repo segments. Leading dots stay legal (the "owner/.github" repo convention). New test cases cover the rejections plus a positive guard for the leading-dot case. 6. fakeGH test helper: lock the handlers map on both read and write, so future tests registering handlers from goroutines won't trip -race. Co-Authored-By: Claude Opus 4.7 --- .../adapters/tracker/github/tracker.go | 89 ++++++++++--------- .../adapters/tracker/github/tracker_test.go | 24 ++++- backend/internal/domain/tracker.go | 11 ++- 3 files changed, 77 insertions(+), 47 deletions(-) diff --git a/backend/internal/adapters/tracker/github/tracker.go b/backend/internal/adapters/tracker/github/tracker.go index 8176ebfdce..bf6ffcbfd2 100644 --- a/backend/internal/adapters/tracker/github/tracker.go +++ b/backend/internal/adapters/tracker/github/tracker.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -94,8 +95,12 @@ type Tracker struct { baseURL string userAgent string + // preflightOK is the fast-path: once a Preflight succeeds, every + // subsequent call short-circuits via atomic.Load without touching the + // mutex. preflightMu serializes the one-time network call so concurrent + // first-callers don't all fire GET /user against GitHub. + preflightOK atomic.Bool preflightMu sync.Mutex - preflightOK bool } // New returns a Tracker. It fails fast when no token can be obtained so @@ -173,6 +178,15 @@ func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, e if err := json.Unmarshal(resp, &raw); err != nil { return domain.Issue{}, fmt.Errorf("github tracker: decode issue: %w", err) } + return issueFromGH(owner, repo, raw), nil +} + +// issueFromGH projects a raw GitHub issue payload into the normalized +// domain.Issue. owner and repo are passed in because the TrackerID.Native +// shape is "owner/repo#N" and we want the returned ID to round-trip +// through the same adapter even if the original caller used a zero +// Provider. +func issueFromGH(owner, repo string, raw ghIssue) domain.Issue { labels := make([]string, 0, len(raw.Labels)) for _, l := range raw.Labels { labels = append(labels, l.Name) @@ -182,9 +196,10 @@ func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, e assignees = append(assignees, a.Login) } out := domain.Issue{ - // Canonicalize Provider so the returned Issue always re-routes back - // to this adapter, even if the caller built id with a zero Provider. - ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: id.Native}, + ID: domain.TrackerID{ + Provider: domain.TrackerProviderGitHub, + Native: fmt.Sprintf("%s/%s#%d", owner, repo, raw.Number), + }, Title: raw.Title, Body: raw.Body, State: mapStateFromGitHub(raw.State, raw.StateReason, labels), @@ -198,7 +213,7 @@ func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, e if len(out.Assignees) == 0 { out.Assignees = nil } - return out, nil + return out } // mapStateFromGitHub projects GitHub's open/closed + state_reason + labels @@ -286,33 +301,7 @@ func (t *Tracker) List(ctx context.Context, repo domain.TrackerRepo, filter doma if r.PullRequest != nil { continue } - labels := make([]string, 0, len(r.Labels)) - for _, l := range r.Labels { - labels = append(labels, l.Name) - } - assignees := make([]string, 0, len(r.Assignees)) - for _, a := range r.Assignees { - assignees = append(assignees, a.Login) - } - issue := domain.Issue{ - ID: domain.TrackerID{ - Provider: domain.TrackerProviderGitHub, - Native: fmt.Sprintf("%s/%s#%d", owner, repoName, r.Number), - }, - Title: r.Title, - Body: r.Body, - State: mapStateFromGitHub(r.State, r.StateReason, labels), - URL: r.HTMLURL, - Labels: labels, - Assignees: assignees, - } - if len(issue.Labels) == 0 { - issue.Labels = nil - } - if len(issue.Assignees) == 0 { - issue.Assignees = nil - } - out = append(out, issue) + out = append(out, issueFromGH(owner, repoName, r)) } return out, nil } @@ -321,20 +310,34 @@ func (t *Tracker) List(ctx context.Context, repo domain.TrackerRepo, filter doma // Preflight // --------------------------------------------------------------------------- -// Preflight verifies the configured token is accepted by GitHub by making a -// single GET /user request. A successful check is cached for the lifetime -// of the Tracker; failures are never cached so a transient network glitch -// at startup is recoverable on a subsequent call. +// Preflight verifies the configured token is currently accepted by GitHub +// (one GET /user). It does NOT prove the token has the repo scope or +// visibility needed for any specific Get/List call — those may still fail +// with ErrAuthFailed even after a successful Preflight. The guarantee is +// "token exists and is valid against GitHub's identity endpoint", not +// "token can do everything the SM will ask of it." Per-repo authorization +// is detected lazily at the first Get/List against that repo. +// +// Successful checks are cached for the lifetime of the Tracker via a +// double-checked atomic+mutex pattern: the hot path is one atomic.Load +// with no contention; concurrent first-callers serialize on the mutex so +// only one GET /user is in flight. Failures are intentionally NOT cached +// so a transient startup glitch is recoverable on a subsequent call. func (t *Tracker) Preflight(ctx context.Context) error { + if t.preflightOK.Load() { + return nil + } t.preflightMu.Lock() defer t.preflightMu.Unlock() - if t.preflightOK { + // Re-check after acquiring the lock — another goroutine may have raced + // us through the network call and stored success while we were waiting. + if t.preflightOK.Load() { return nil } if _, err := t.do(ctx, http.MethodGet, "/user", nil); err != nil { return err } - t.preflightOK = true + t.preflightOK.Store(true) return nil } @@ -485,8 +488,9 @@ func parseGitHubID(native string) (owner, repo string, number int, err error) { return owner, repo, n, nil } -// parseGitHubRepo accepts "owner/repo" and rejects anything containing -// additional slashes or a "#" segment. Used by List. +// parseGitHubRepo accepts "owner/repo" and rejects empty segments, +// embedded slashes, "#", and whitespace. Leading dots are kept legal — +// "owner/.github" is a real GitHub convention for repo-level config repos. func parseGitHubRepo(native string) (owner, repo string, err error) { if native == "" { return "", "", fmt.Errorf("%w: empty repo", ErrBadID) @@ -500,7 +504,10 @@ func parseGitHubRepo(native string) (owner, repo string, err error) { if owner == "" || repo == "" { return "", "", fmt.Errorf("%w: empty owner or repo segment", ErrBadID) } - if strings.ContainsAny(repo, "/#") { + if strings.ContainsAny(owner, "/# \t\n\r") { + return "", "", fmt.Errorf("%w: invalid owner segment %q", ErrBadID, owner) + } + if strings.ContainsAny(repo, "/# \t\n\r") { return "", "", fmt.Errorf("%w: invalid repo segment %q", ErrBadID, repo) } return owner, repo, nil diff --git a/backend/internal/adapters/tracker/github/tracker_test.go b/backend/internal/adapters/tracker/github/tracker_test.go index 1c3d7d5455..a61a68999d 100644 --- a/backend/internal/adapters/tracker/github/tracker_test.go +++ b/backend/internal/adapters/tracker/github/tracker_test.go @@ -45,16 +45,18 @@ func newFakeGH(t *testing.T) *fakeGH { } func (f *fakeGH) on(method, path string, h http.HandlerFunc) { + f.mu.Lock() + defer f.mu.Unlock() f.handlers[method+" "+path] = h } func (f *fakeGH) serve(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) + key := r.Method + " " + r.URL.Path f.mu.Lock() f.requests = append(f.requests, recordedReq{Method: r.Method, Path: r.URL.Path, Body: string(body)}) - f.mu.Unlock() - key := r.Method + " " + r.URL.Path h, ok := f.handlers[key] + f.mu.Unlock() if !ok { f.t.Errorf("unexpected request: %s", key) http.Error(w, "no handler", http.StatusNotImplemented) @@ -526,7 +528,23 @@ func TestList_RejectsWrongProvider(t *testing.T) { } func TestList_RejectsBadRepo(t *testing.T) { - cases := []string{"", "noseparator", "/repo", "owner/", "a/b/c"} + cases := []string{ + "", // empty + "noseparator", // missing / + "/repo", // empty owner + "owner/", // empty repo + "a/b/c", // extra slash + " owner/repo", // leading whitespace in owner + "owner/repo ", // trailing whitespace in repo + "own er/repo", // embedded space in owner + "owner/re#po", // embedded # in repo + "\towner/repo", // tab in owner + "owner/repo\n", // newline in repo + } + // Sanity: a benign leading-dot repo (".github" convention) must pass. + if _, _, err := parseGitHubRepo("octocat/.github"); err != nil { + t.Fatalf("leading-dot repo rejected unexpectedly: %v", err) + } for _, native := range cases { t.Run(native, func(t *testing.T) { f := newFakeGH(t) diff --git a/backend/internal/domain/tracker.go b/backend/internal/domain/tracker.go index 27137752fd..8fe0ed3b7b 100644 --- a/backend/internal/domain/tracker.go +++ b/backend/internal/domain/tracker.go @@ -72,9 +72,14 @@ const ( ) // ListFilter is the query the Session Manager passes to Tracker.List. -// Empty / zero values mean "no filter on this dimension". Limit is the -// requested page size; the adapter applies its own default when zero and -// caps at the provider's per-page maximum. +// Empty / zero values mean "no filter on this dimension". +// +// Limit is the requested page size. The adapter applies its own default +// when zero and SILENTLY CAPS at the provider's per-page maximum — a +// caller asking for more than the cap gets exactly cap items back with no +// error and no indication of truncation. v1 has no auto-pagination; +// callers needing more results need to wait for the observer/polling work +// in issue #35. type ListFilter struct { State ListStateFilter `json:"state,omitempty"` Labels []string `json:"labels,omitempty"` From 4ce90448e28e0792a33f90d0379ce7a11d877f71 Mon Sep 17 00:00:00 2001 From: prateek Date: Sun, 31 May 2026 00:33:13 +0530 Subject: [PATCH 050/250] refactor(storage): make session metadata + PR facts typed and structured The first storage cut modelled two side tables as free-form blobs. This replaces both with opinionated, statically-typed schema so what a session can carry is fixed by the schema, not by convention. session_metadata: was a (session_id, key, value) KV bag with six convention-only keys. Now a 1:1 table of named, typed columns. The domain currency is a typed domain.SessionMetadata struct (was map[string]string), threaded through ports.LifecycleStore, the LCM, the Session Manager and the reaper, so an unknown key is a compile error rather than a silently-dropped write. PatchMetadata keeps its non-destructive merge ("empty = leave unchanged"). The off-canonical invariant is now enforced at the type level via json:"-" on SessionRecord.Metadata, removing the manual `Metadata = nil` scrub the change_log/snapshot paths had to remember; the Meta* string-key constants are deleted. pr_enrichment -> pr (+ pr_check, pr_comment): the scalar facts are now typed columns with CHECK-constrained enums (review_decision, mergeability, ci_state) and integer CI counts instead of opaque TEXT. The two list facts the old `pending_comments`/ci_summary strings smuggled are normalized into child tables (pr_check, pr_comment) that cascade from pr. The store exposes UpsertPR/GetPR plus atomic ReplacePRChecks/ReplacePRComments + List. Both tables remain off the canonical CDC path. sqlc regenerated; migrations 0001/0002 revised in place (nothing released). gofmt/vet clean; go test -race green; daemon smoke-boots and creates the new schema. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/cdc_wiring.go | 1 - backend/internal/domain/session.go | 27 +- backend/internal/lifecycle/fakes_test.go | 43 ++-- backend/internal/lifecycle/manager.go | 48 ++-- backend/internal/lifecycle/manager_test.go | 2 +- backend/internal/observe/reaper/reaper.go | 7 +- .../internal/observe/reaper/reaper_test.go | 15 +- backend/internal/ports/outbound.go | 4 +- backend/internal/session/fakes_test.go | 43 +++- backend/internal/session/manager.go | 21 +- backend/internal/session/manager_test.go | 42 ++-- .../storage/sqlite/gen/metadata.sql.go | 95 ++++--- backend/internal/storage/sqlite/gen/models.go | 47 +++- backend/internal/storage/sqlite/gen/pr.sql.go | 235 ++++++++++++++++++ .../storage/sqlite/gen/pr_enrichment.sql.go | 76 ------ .../internal/storage/sqlite/gen/querier.go | 19 +- .../storage/sqlite/migrations/0001_init.sql | 21 +- .../sqlite/migrations/0002_pr_projects.sql | 57 ++++- .../storage/sqlite/pr_projects_test.go | 108 +++++++- backend/internal/storage/sqlite/pr_store.go | 210 +++++++++++++--- .../storage/sqlite/queries/metadata.sql | 25 +- .../internal/storage/sqlite/queries/pr.sql | 43 ++++ .../storage/sqlite/queries/pr_enrichment.sql | 18 -- backend/internal/storage/sqlite/store.go | 64 ++--- backend/internal/storage/sqlite/store_test.go | 13 +- backend/internal/storage/sqlite/upsert.go | 6 +- backend/main_test.go | 2 +- 27 files changed, 909 insertions(+), 383 deletions(-) create mode 100644 backend/internal/storage/sqlite/gen/pr.sql.go delete mode 100644 backend/internal/storage/sqlite/gen/pr_enrichment.sql.go create mode 100644 backend/internal/storage/sqlite/queries/pr.sql delete mode 100644 backend/internal/storage/sqlite/queries/pr_enrichment.sql diff --git a/backend/cdc_wiring.go b/backend/cdc_wiring.go index 89997e7dcb..cfae4fdb9b 100644 --- a/backend/cdc_wiring.go +++ b/backend/cdc_wiring.go @@ -125,7 +125,6 @@ func (s snapshotSource) Snapshot(ctx context.Context) ([]cdc.Event, int64, error events := make([]cdc.Event, 0, len(recs)) for _, r := range recs { r.Lifecycle.Version = domain.LifecycleVersion - r.Metadata = nil blob, err := json.Marshal(r) if err != nil { return nil, 0, fmt.Errorf("marshal snapshot %s: %w", r.ID, err) diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index 578cca4066..c9cd8d9632 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -17,16 +17,41 @@ const ( KindOrchestrator SessionKind = "orchestrator" ) +// SessionMetadata is the typed, off-canonical metadata for a session: the +// operational handles and seed inputs the Session Manager and reaper need but +// that are NOT part of the canonical lifecycle. The set of fields is fixed here +// (no free-form keys), so what a session can carry is a compile-time fact, and +// it is persisted 1:1 in the session_metadata table off the CDC path. +// +// Empty fields mean "unset": PatchMetadata never overwrites a stored value with +// an empty one, so a partial write (spawn setting only the runtime handle) does +// not clobber a value set earlier (the branch set at creation). +type SessionMetadata struct { + Branch string `json:"branch,omitempty"` + WorkspacePath string `json:"workspacePath,omitempty"` + RuntimeHandleID string `json:"runtimeHandleId,omitempty"` + RuntimeName string `json:"runtimeName,omitempty"` + AgentSessionID string `json:"agentSessionId,omitempty"` + Prompt string `json:"prompt,omitempty"` +} + +// IsZero reports whether no metadata field is set. +func (m SessionMetadata) IsZero() bool { return m == SessionMetadata{} } + // SessionRecord is the PERSISTENCE shape: identity, canonical lifecycle, and // metadata — everything the store holds, and nothing derived. The store reads // and writes records; it never produces the derived display status. +// +// Metadata is json:"-" on purpose: it lives off the canonical path, so it must +// never ride along in the change_log / snapshot payloads. Enforcing that at the +// type level means no caller has to remember to scrub it before marshalling. type SessionRecord struct { ID SessionID `json:"id"` ProjectID ProjectID `json:"projectId"` IssueID IssueID `json:"issueId,omitempty"` Kind SessionKind `json:"kind"` Lifecycle CanonicalSessionLifecycle `json:"lifecycle"` - Metadata map[string]string `json:"metadata,omitempty"` + Metadata SessionMetadata `json:"-"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } diff --git a/backend/internal/lifecycle/fakes_test.go b/backend/internal/lifecycle/fakes_test.go index 5bacb49a6f..45aec91b98 100644 --- a/backend/internal/lifecycle/fakes_test.go +++ b/backend/internal/lifecycle/fakes_test.go @@ -14,7 +14,7 @@ import ( type fakeStore struct { mu sync.Mutex records map[domain.SessionID]*domain.SessionRecord - metadata map[domain.SessionID]map[string]string + metadata map[domain.SessionID]domain.SessionMetadata } var _ ports.LifecycleStore = (*fakeStore)(nil) @@ -22,7 +22,7 @@ var _ ports.LifecycleStore = (*fakeStore)(nil) func newFakeStore() *fakeStore { return &fakeStore{ records: map[domain.SessionID]*domain.SessionRecord{}, - metadata: map[domain.SessionID]map[string]string{}, + metadata: map[domain.SessionID]domain.SessionMetadata{}, } } @@ -90,26 +90,41 @@ func (s *fakeStore) List(_ context.Context, project domain.ProjectID) ([]domain. return out, nil } -func (s *fakeStore) GetMetadata(_ context.Context, id domain.SessionID) (map[string]string, error) { +func (s *fakeStore) GetMetadata(_ context.Context, id domain.SessionID) (domain.SessionMetadata, error) { s.mu.Lock() defer s.mu.Unlock() - out := map[string]string{} - for k, v := range s.metadata[id] { - out[k] = v - } - return out, nil + return s.metadata[id], nil } -func (s *fakeStore) PatchMetadata(_ context.Context, id domain.SessionID, kv map[string]string) error { +func (s *fakeStore) PatchMetadata(_ context.Context, id domain.SessionID, meta domain.SessionMetadata) error { s.mu.Lock() defer s.mu.Unlock() - if s.metadata[id] == nil { - s.metadata[id] = map[string]string{} + s.metadata[id] = mergeSessionMetadata(s.metadata[id], meta) + return nil +} + +// mergeSessionMetadata applies meta onto dst with the store's "empty = leave +// unchanged" semantics, so partial patches do not clobber earlier values. +func mergeSessionMetadata(dst, meta domain.SessionMetadata) domain.SessionMetadata { + if meta.Branch != "" { + dst.Branch = meta.Branch } - for k, v := range kv { - s.metadata[id][k] = v + if meta.WorkspacePath != "" { + dst.WorkspacePath = meta.WorkspacePath } - return nil + if meta.RuntimeHandleID != "" { + dst.RuntimeHandleID = meta.RuntimeHandleID + } + if meta.RuntimeName != "" { + dst.RuntimeName = meta.RuntimeName + } + if meta.AgentSessionID != "" { + dst.AgentSessionID = meta.AgentSessionID + } + if meta.Prompt != "" { + dst.Prompt = meta.Prompt + } + return dst } // recordingNotifier captures emitted events for assertions. diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 54e6887f0a..63d7164a1b 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -21,19 +21,11 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// Metadata keys OnSpawnCompleted records for the spawned session's handles. -// -// MetaPrompt is the assembled launch prompt, persisted so a Restore that finds -// no captured agent session id can still fall back to a fresh launch with the -// same prompt rather than failing. -const ( - MetaBranch = "branch" - MetaWorkspacePath = "workspacePath" - MetaRuntimeHandleID = "runtimeHandleId" - MetaRuntimeName = "runtimeName" - MetaAgentSessionID = "agentSessionId" - MetaPrompt = "prompt" -) +// Session metadata is now the typed domain.SessionMetadata struct (was a +// free-form string map keyed by Meta* constants). OnSpawnCompleted records the +// spawned session's handles via spawnMetadata; Prompt is the assembled launch +// prompt, persisted so a Restore that finds no captured agent session id can +// still fall back to a fresh launch with the same prompt rather than failing. // Manager is the LCM. The Apply* pipeline persists a transition and then fires // the mapped reaction via Notifier/AgentMessenger (see reactions.go). @@ -381,7 +373,7 @@ func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o p return err } } - if meta := spawnMetadata(o); len(meta) > 0 { + if meta := spawnMetadata(o); !meta.IsZero() { if err := m.store.PatchMetadata(ctx, id, meta); err != nil { return err } @@ -545,25 +537,13 @@ func sameActivity(a, b domain.ActivitySubstate) bool { return a.State == b.State && a.Source == b.Source && a.LastActivityAt.Equal(b.LastActivityAt) } -func spawnMetadata(o ports.SpawnOutcome) map[string]string { - meta := map[string]string{} - if o.Branch != "" { - meta[MetaBranch] = o.Branch - } - if o.WorkspacePath != "" { - meta[MetaWorkspacePath] = o.WorkspacePath - } - if o.RuntimeHandle.ID != "" { - meta[MetaRuntimeHandleID] = o.RuntimeHandle.ID - } - if o.RuntimeHandle.RuntimeName != "" { - meta[MetaRuntimeName] = o.RuntimeHandle.RuntimeName - } - if o.AgentSessionID != "" { - meta[MetaAgentSessionID] = o.AgentSessionID - } - if o.Prompt != "" { - meta[MetaPrompt] = o.Prompt +func spawnMetadata(o ports.SpawnOutcome) domain.SessionMetadata { + return domain.SessionMetadata{ + Branch: o.Branch, + WorkspacePath: o.WorkspacePath, + RuntimeHandleID: o.RuntimeHandle.ID, + RuntimeName: o.RuntimeHandle.RuntimeName, + AgentSessionID: o.AgentSessionID, + Prompt: o.Prompt, } - return meta } diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index 6a2cc1d1a1..96557e8f01 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -388,7 +388,7 @@ func TestOnSpawnCompleted(t *testing.T) { t.Errorf("display = %v, want spawning", got) } meta, _ := store.GetMetadata(context.Background(), sid) - if meta[MetaBranch] != "feat/x" || meta[MetaAgentSessionID] != "agent-1" || meta[MetaRuntimeName] != "tmux" { + if meta.Branch != "feat/x" || meta.AgentSessionID != "agent-1" || meta.RuntimeName != "tmux" { t.Errorf("metadata not recorded: %+v", meta) } } diff --git a/backend/internal/observe/reaper/reaper.go b/backend/internal/observe/reaper/reaper.go index 66456ea6e4..579f1d6347 100644 --- a/backend/internal/observe/reaper/reaper.go +++ b/backend/internal/observe/reaper/reaper.go @@ -16,7 +16,6 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -203,11 +202,11 @@ func (r *Reaper) probeOne(ctx context.Context, sess domain.SessionRecord, now ti } // handleFromRecord reconstructs the RuntimeHandle stored on the session by -// OnSpawnCompleted. Both keys are required; either being empty is the +// OnSpawnCompleted. Both fields are required; either being empty is the // "session lacks a probable handle" signal that probeOne uses to skip. func handleFromRecord(rec domain.SessionRecord) (ports.RuntimeHandle, bool) { - id := rec.Metadata[lifecycle.MetaRuntimeHandleID] - name := rec.Metadata[lifecycle.MetaRuntimeName] + id := rec.Metadata.RuntimeHandleID + name := rec.Metadata.RuntimeName if id == "" || name == "" { return ports.RuntimeHandle{}, false } diff --git a/backend/internal/observe/reaper/reaper_test.go b/backend/internal/observe/reaper/reaper_test.go index d6b88efdfe..0d3b4d4792 100644 --- a/backend/internal/observe/reaper/reaper_test.go +++ b/backend/internal/observe/reaper/reaper_test.go @@ -9,7 +9,6 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -124,9 +123,9 @@ func aliveSessionWith(id domain.SessionID, runtimeName, handleID string) domain. Session: domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress}, Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, }, - Metadata: map[string]string{ - lifecycle.MetaRuntimeHandleID: handleID, - lifecycle.MetaRuntimeName: runtimeName, + Metadata: domain.SessionMetadata{ + RuntimeHandleID: handleID, + RuntimeName: runtimeName, }, } } @@ -141,9 +140,9 @@ func detectingSessionWith(id domain.SessionID, runtimeName, handleID string) dom Session: domain.SessionSubstate{State: domain.SessionDetecting, Reason: domain.ReasonProbeFailure}, Runtime: domain.RuntimeSubstate{State: domain.RuntimeProbeFailed, Reason: domain.RuntimeReasonProbeError}, }, - Metadata: map[string]string{ - lifecycle.MetaRuntimeHandleID: handleID, - lifecycle.MetaRuntimeName: runtimeName, + Metadata: domain.SessionMetadata{ + RuntimeHandleID: handleID, + RuntimeName: runtimeName, }, } } @@ -367,7 +366,7 @@ func TestReaper_SkipsMissingHandle(t *testing.T) { now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) clock := func() time.Time { return now } sess := aliveSessionWith("s1", "tmux", "h1") - delete(sess.Metadata, lifecycle.MetaRuntimeHandleID) + sess.Metadata.RuntimeHandleID = "" lcm := &fakeLCM{sessions: []domain.SessionRecord{sess}} rt := &fakeRuntime{results: map[string]aliveResult{}} rp := reaper.New(lcm, reaper.MapRegistry{"tmux": rt}, reaper.Config{Clock: clock, Tick: time.Hour}) diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index ba08d9b9ab..c64a1e6d44 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -23,8 +23,8 @@ type LifecycleStore interface { Upsert(ctx context.Context, rec domain.SessionRecord, eventType EventType) error Load(ctx context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) List(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) - GetMetadata(ctx context.Context, id domain.SessionID) (map[string]string, error) - PatchMetadata(ctx context.Context, id domain.SessionID, kv map[string]string) error + GetMetadata(ctx context.Context, id domain.SessionID) (domain.SessionMetadata, error) + PatchMetadata(ctx context.Context, id domain.SessionID, meta domain.SessionMetadata) error // Get returns a single full record (with identity) by id. Load is // lifecycle-only, so readers use this to build the read-model and reconstruct diff --git a/backend/internal/session/fakes_test.go b/backend/internal/session/fakes_test.go index 71eaa4afd9..033f6de757 100644 --- a/backend/internal/session/fakes_test.go +++ b/backend/internal/session/fakes_test.go @@ -47,7 +47,7 @@ func (c *callLog) indexOf(name string) int { type fakeStore struct { mu sync.Mutex records map[domain.SessionID]*domain.SessionRecord - metadata map[domain.SessionID]map[string]string + metadata map[domain.SessionID]domain.SessionMetadata } var _ ports.LifecycleStore = (*fakeStore)(nil) @@ -55,7 +55,7 @@ var _ ports.LifecycleStore = (*fakeStore)(nil) func newFakeStore() *fakeStore { return &fakeStore{ records: map[domain.SessionID]*domain.SessionRecord{}, - metadata: map[domain.SessionID]map[string]string{}, + metadata: map[domain.SessionID]domain.SessionMetadata{}, } } @@ -113,30 +113,47 @@ func (s *fakeStore) List(_ context.Context, project domain.ProjectID) ([]domain. return out, nil } -func (s *fakeStore) GetMetadata(_ context.Context, id domain.SessionID) (map[string]string, error) { +func (s *fakeStore) GetMetadata(_ context.Context, id domain.SessionID) (domain.SessionMetadata, error) { s.mu.Lock() defer s.mu.Unlock() - return cloneMap(s.metadata[id]), nil + return s.metadata[id], nil } -func (s *fakeStore) PatchMetadata(_ context.Context, id domain.SessionID, kv map[string]string) error { +func (s *fakeStore) PatchMetadata(_ context.Context, id domain.SessionID, meta domain.SessionMetadata) error { s.mu.Lock() defer s.mu.Unlock() - if s.metadata[id] == nil { - s.metadata[id] = map[string]string{} + s.metadata[id] = mergeSessionMetadata(s.metadata[id], meta) + return nil +} + +// mergeSessionMetadata applies meta onto dst with the store's "empty = leave +// unchanged" semantics, so partial patches do not clobber earlier values. +func mergeSessionMetadata(dst, meta domain.SessionMetadata) domain.SessionMetadata { + if meta.Branch != "" { + dst.Branch = meta.Branch } - for k, v := range kv { - s.metadata[id][k] = v + if meta.WorkspacePath != "" { + dst.WorkspacePath = meta.WorkspacePath } - return nil + if meta.RuntimeHandleID != "" { + dst.RuntimeHandleID = meta.RuntimeHandleID + } + if meta.RuntimeName != "" { + dst.RuntimeName = meta.RuntimeName + } + if meta.AgentSessionID != "" { + dst.AgentSessionID = meta.AgentSessionID + } + if meta.Prompt != "" { + dst.Prompt = meta.Prompt + } + return dst } // withMetadata attaches the separately-stored metadata to a record copy (a real // store would return them together). Caller holds s.mu. func (s *fakeStore) withMetadata(rec domain.SessionRecord) domain.SessionRecord { - if md := s.metadata[rec.ID]; len(md) > 0 { - rec.Metadata = cloneMap(md) - } + rec.Metadata = s.metadata[rec.ID] return rec } diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index e764f6a31d..dce6330558 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -19,7 +19,6 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -278,8 +277,8 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess // (the agent's id-capture path is a separate hook that may never have run, so // "no id" is the common case rather than an error). If neither is available // there is nothing to relaunch from — fail early, before any I/O. - agentSessionID := meta[lifecycle.MetaAgentSessionID] - seededPrompt := meta[lifecycle.MetaPrompt] + agentSessionID := meta.AgentSessionID + seededPrompt := meta.Prompt if agentSessionID == "" && seededPrompt == "" { return domain.Session{}, fmt.Errorf("restore %s: no agent session id or seeded prompt (cannot resume or relaunch)", id) } @@ -287,7 +286,7 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ ProjectID: rec.ProjectID, SessionID: id, - Branch: meta[lifecycle.MetaBranch], + Branch: meta.Branch, }) if err != nil { return domain.Session{}, fmt.Errorf("restore %s: workspace restore: %w", id, err) @@ -335,7 +334,7 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess if revertErr := m.lcm.OnSpawnInitiated(ctx, rec); revertErr != nil { return domain.Session{}, fmt.Errorf("restore %s: revert after spawn completed failure: %w (original error: %v)", id, revertErr, err) } - if len(rec.Metadata) > 0 { + if !rec.Metadata.IsZero() { if revertErr := m.store.PatchMetadata(ctx, id, rec.Metadata); revertErr != nil { return domain.Session{}, fmt.Errorf("restore %s: revert metadata after spawn completed failure: %w (original error: %v)", id, revertErr, err) } @@ -440,17 +439,17 @@ func seedRecord(id domain.SessionID, cfg ports.SpawnConfig, now time.Time) domai // runtimeHandle / workspaceInfo reconstruct teardown handles from the metadata // the LCM persisted in OnSpawnCompleted (the metadata-key contract is shared // with the lifecycle package). -func runtimeHandle(meta map[string]string) ports.RuntimeHandle { +func runtimeHandle(meta domain.SessionMetadata) ports.RuntimeHandle { return ports.RuntimeHandle{ - ID: meta[lifecycle.MetaRuntimeHandleID], - RuntimeName: meta[lifecycle.MetaRuntimeName], + ID: meta.RuntimeHandleID, + RuntimeName: meta.RuntimeName, } } -func workspaceInfo(rec domain.SessionRecord, meta map[string]string) ports.WorkspaceInfo { +func workspaceInfo(rec domain.SessionRecord, meta domain.SessionMetadata) ports.WorkspaceInfo { return ports.WorkspaceInfo{ - Path: meta[lifecycle.MetaWorkspacePath], - Branch: meta[lifecycle.MetaBranch], + Path: meta.WorkspacePath, + Branch: meta.Branch, SessionID: rec.ID, ProjectID: rec.ProjectID, } diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index 5bb20d07b9..c0c98cf7c7 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -86,16 +85,15 @@ func TestSpawn_HappyPath(t *testing.T) { // persisted too so a later Restore that finds no captured agent session id // can still fall back to a fresh launch using the same prompt. meta, _ := h.store.GetMetadata(ctx, "sess-1") - for k, want := range map[string]string{ - lifecycle.MetaBranch: "feat/42", - lifecycle.MetaWorkspacePath: "/tmp/ws/sess-1", - lifecycle.MetaRuntimeHandleID: "rt-sess-1", - lifecycle.MetaRuntimeName: "tmux", - lifecycle.MetaPrompt: "do the thing\n\nbe careful", - } { - if meta[k] != want { - t.Errorf("meta[%q] = %q, want %q", k, meta[k], want) - } + want := domain.SessionMetadata{ + Branch: "feat/42", + WorkspacePath: "/tmp/ws/sess-1", + RuntimeHandleID: "rt-sess-1", + RuntimeName: "tmux", + Prompt: "do the thing\n\nbe careful", + } + if meta != want { + t.Errorf("metadata = %+v, want %+v", meta, want) } } @@ -300,7 +298,7 @@ func TestRestore_LiveSession_Rejected(t *testing.T) { } // The session is live (never torn down). Capture an agent id so the only thing // blocking restore is the non-terminal lifecycle, not missing metadata. - if err := h.store.PatchMetadata(ctx, "sess-1", map[string]string{lifecycle.MetaAgentSessionID: "agent-xyz"}); err != nil { + if err := h.store.PatchMetadata(ctx, "sess-1", domain.SessionMetadata{AgentSessionID: "agent-xyz"}); err != nil { t.Fatalf("patch metadata: %v", err) } createdBefore := len(h.runtime.created) @@ -398,7 +396,7 @@ func TestRestore_RelaunchesWithResumeCommand(t *testing.T) { t.Fatalf("kill: %v", err) } // The agent's resume id is captured in metadata (here set explicitly). - if err := h.store.PatchMetadata(ctx, "sess-1", map[string]string{lifecycle.MetaAgentSessionID: "agent-xyz"}); err != nil { + if err := h.store.PatchMetadata(ctx, "sess-1", domain.SessionMetadata{AgentSessionID: "agent-xyz"}); err != nil { t.Fatalf("patch metadata: %v", err) } @@ -505,7 +503,7 @@ func TestRestore_OnSpawnCompletedFailure_RollsBackRuntime(t *testing.T) { if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); err != nil { t.Fatalf("kill: %v", err) } - if err := h.store.PatchMetadata(ctx, "sess-1", map[string]string{lifecycle.MetaAgentSessionID: "agent-xyz"}); err != nil { + if err := h.store.PatchMetadata(ctx, "sess-1", domain.SessionMetadata{AgentSessionID: "agent-xyz"}); err != nil { t.Fatalf("patch metadata: %v", err) } beforeMeta, _ := h.store.GetMetadata(ctx, "sess-1") @@ -528,7 +526,7 @@ func TestRestore_OnSpawnCompletedFailure_RollsBackRuntime(t *testing.T) { t.Fatalf("restore failure should advance revision twice, got %d want %d", rec.Lifecycle.Revision, before.Lifecycle.Revision+2) } afterMeta, _ := h.store.GetMetadata(ctx, "sess-1") - if !equalStringMap(afterMeta, beforeMeta) { + if afterMeta != beforeMeta { t.Fatalf("restore failure should restore metadata, got %+v want %+v", afterMeta, beforeMeta) } @@ -595,7 +593,7 @@ func seedTerminal(t *testing.T, h *harness, id domain.SessionID, wsPath string) }, ports.EventSessionCreated); err != nil { t.Fatalf("upsert %s: %v", id, err) } - if err := h.store.PatchMetadata(ctx, id, map[string]string{lifecycle.MetaWorkspacePath: wsPath}); err != nil { + if err := h.store.PatchMetadata(ctx, id, domain.SessionMetadata{WorkspacePath: wsPath}); err != nil { t.Fatalf("patch metadata %s: %v", id, err) } } @@ -612,18 +610,6 @@ func equalStrings(a, b []string) bool { return true } -func equalStringMap(a, b map[string]string) bool { - if len(a) != len(b) { - return false - } - for k, v := range a { - if b[k] != v { - return false - } - } - return true -} - func contains(ids []domain.SessionID, id domain.SessionID) bool { for _, x := range ids { if x == id { diff --git a/backend/internal/storage/sqlite/gen/metadata.sql.go b/backend/internal/storage/sqlite/gen/metadata.sql.go index 96510eb80e..2c0396f734 100644 --- a/backend/internal/storage/sqlite/gen/metadata.sql.go +++ b/backend/internal/storage/sqlite/gen/metadata.sql.go @@ -7,53 +7,76 @@ package gen import ( "context" + "time" ) -const getMetadata = `-- name: GetMetadata :many -SELECT key, value FROM session_metadata WHERE session_id = ? +const getSessionMetadata = `-- name: GetSessionMetadata :one +SELECT branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt +FROM session_metadata +WHERE session_id = ? ` -type GetMetadataRow struct { - Key string - Value string +type GetSessionMetadataRow struct { + Branch string + WorkspacePath string + RuntimeHandleID string + RuntimeName string + AgentSessionID string + Prompt string } -func (q *Queries) GetMetadata(ctx context.Context, sessionID string) ([]GetMetadataRow, error) { - rows, err := q.db.QueryContext(ctx, getMetadata, sessionID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMetadataRow{} - for rows.Next() { - var i GetMetadataRow - if err := rows.Scan(&i.Key, &i.Value); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil +func (q *Queries) GetSessionMetadata(ctx context.Context, sessionID string) (GetSessionMetadataRow, error) { + row := q.db.QueryRowContext(ctx, getSessionMetadata, sessionID) + var i GetSessionMetadataRow + err := row.Scan( + &i.Branch, + &i.WorkspacePath, + &i.RuntimeHandleID, + &i.RuntimeName, + &i.AgentSessionID, + &i.Prompt, + ) + return i, err } -const upsertMetadata = `-- name: UpsertMetadata :exec -INSERT INTO session_metadata (session_id, key, value) -VALUES (?, ?, ?) -ON CONFLICT (session_id, key) DO UPDATE SET value = excluded.value +const upsertSessionMetadata = `-- name: UpsertSessionMetadata :exec +INSERT INTO session_metadata ( + session_id, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id) DO UPDATE SET + branch = CASE WHEN excluded.branch <> '' THEN excluded.branch ELSE session_metadata.branch END, + workspace_path = CASE WHEN excluded.workspace_path <> '' THEN excluded.workspace_path ELSE session_metadata.workspace_path END, + runtime_handle_id = CASE WHEN excluded.runtime_handle_id <> '' THEN excluded.runtime_handle_id ELSE session_metadata.runtime_handle_id END, + runtime_name = CASE WHEN excluded.runtime_name <> '' THEN excluded.runtime_name ELSE session_metadata.runtime_name END, + agent_session_id = CASE WHEN excluded.agent_session_id <> '' THEN excluded.agent_session_id ELSE session_metadata.agent_session_id END, + prompt = CASE WHEN excluded.prompt <> '' THEN excluded.prompt ELSE session_metadata.prompt END, + updated_at = excluded.updated_at ` -type UpsertMetadataParams struct { - SessionID string - Key string - Value string +type UpsertSessionMetadataParams struct { + SessionID string + Branch string + WorkspacePath string + RuntimeHandleID string + RuntimeName string + AgentSessionID string + Prompt string + UpdatedAt time.Time } -func (q *Queries) UpsertMetadata(ctx context.Context, arg UpsertMetadataParams) error { - _, err := q.db.ExecContext(ctx, upsertMetadata, arg.SessionID, arg.Key, arg.Value) +// Merge semantics: an empty incoming column is "leave unchanged", so a partial +// patch (e.g. spawn writing only the runtime handle) never clobbers a value set +// earlier (e.g. the branch set at creation). Mirrors the old per-key map merge. +func (q *Queries) UpsertSessionMetadata(ctx context.Context, arg UpsertSessionMetadataParams) error { + _, err := q.db.ExecContext(ctx, upsertSessionMetadata, + arg.SessionID, + arg.Branch, + arg.WorkspacePath, + arg.RuntimeHandleID, + arg.RuntimeName, + arg.AgentSessionID, + arg.Prompt, + arg.UpdatedAt, + ) return err } diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index dccf25c463..339062bf06 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -34,14 +34,34 @@ type Outbox struct { CreatedAt time.Time } -type PrEnrichment struct { - SessionID string - CiSummary string - ReviewDecision string - Mergeability string - PendingComments string - CiLogTail string - LastFetchedAt time.Time +type Pr struct { + SessionID string + ReviewDecision string + Mergeability string + CiState string + CiPassed int64 + CiFailed int64 + CiPending int64 + CiLogTail string + LastFetchedAt time.Time +} + +type PrCheck struct { + SessionID string + Name string + Status string + Url string +} + +type PrComment struct { + SessionID string + CommentID string + Author string + File string + Line int64 + Body string + Resolved int64 + CreatedAt time.Time } type Project struct { @@ -93,7 +113,12 @@ type Session struct { } type SessionMetadatum struct { - SessionID string - Key string - Value string + SessionID string + Branch string + WorkspacePath string + RuntimeHandleID string + RuntimeName string + AgentSessionID string + Prompt string + UpdatedAt time.Time } diff --git a/backend/internal/storage/sqlite/gen/pr.sql.go b/backend/internal/storage/sqlite/gen/pr.sql.go new file mode 100644 index 0000000000..95cbd20ae5 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/pr.sql.go @@ -0,0 +1,235 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: pr.sql + +package gen + +import ( + "context" + "time" +) + +const deletePR = `-- name: DeletePR :exec +DELETE FROM pr WHERE session_id = ? +` + +func (q *Queries) DeletePR(ctx context.Context, sessionID string) error { + _, err := q.db.ExecContext(ctx, deletePR, sessionID) + return err +} + +const deletePRChecks = `-- name: DeletePRChecks :exec +DELETE FROM pr_check WHERE session_id = ? +` + +func (q *Queries) DeletePRChecks(ctx context.Context, sessionID string) error { + _, err := q.db.ExecContext(ctx, deletePRChecks, sessionID) + return err +} + +const deletePRComments = `-- name: DeletePRComments :exec +DELETE FROM pr_comment WHERE session_id = ? +` + +func (q *Queries) DeletePRComments(ctx context.Context, sessionID string) error { + _, err := q.db.ExecContext(ctx, deletePRComments, sessionID) + return err +} + +const getPR = `-- name: GetPR :one +SELECT session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at +FROM pr +WHERE session_id = ? +` + +func (q *Queries) GetPR(ctx context.Context, sessionID string) (Pr, error) { + row := q.db.QueryRowContext(ctx, getPR, sessionID) + var i Pr + err := row.Scan( + &i.SessionID, + &i.ReviewDecision, + &i.Mergeability, + &i.CiState, + &i.CiPassed, + &i.CiFailed, + &i.CiPending, + &i.CiLogTail, + &i.LastFetchedAt, + ) + return i, err +} + +const insertPRCheck = `-- name: InsertPRCheck :exec +INSERT INTO pr_check (session_id, name, status, url) VALUES (?, ?, ?, ?) +` + +type InsertPRCheckParams struct { + SessionID string + Name string + Status string + Url string +} + +func (q *Queries) InsertPRCheck(ctx context.Context, arg InsertPRCheckParams) error { + _, err := q.db.ExecContext(ctx, insertPRCheck, + arg.SessionID, + arg.Name, + arg.Status, + arg.Url, + ) + return err +} + +const insertPRComment = `-- name: InsertPRComment :exec +INSERT INTO pr_comment (session_id, comment_id, author, file, line, body, resolved, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +` + +type InsertPRCommentParams struct { + SessionID string + CommentID string + Author string + File string + Line int64 + Body string + Resolved int64 + CreatedAt time.Time +} + +func (q *Queries) InsertPRComment(ctx context.Context, arg InsertPRCommentParams) error { + _, err := q.db.ExecContext(ctx, insertPRComment, + arg.SessionID, + arg.CommentID, + arg.Author, + arg.File, + arg.Line, + arg.Body, + arg.Resolved, + arg.CreatedAt, + ) + return err +} + +const listPRChecks = `-- name: ListPRChecks :many +SELECT name, status, url FROM pr_check WHERE session_id = ? ORDER BY name +` + +type ListPRChecksRow struct { + Name string + Status string + Url string +} + +func (q *Queries) ListPRChecks(ctx context.Context, sessionID string) ([]ListPRChecksRow, error) { + rows, err := q.db.QueryContext(ctx, listPRChecks, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListPRChecksRow{} + for rows.Next() { + var i ListPRChecksRow + if err := rows.Scan(&i.Name, &i.Status, &i.Url); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPRComments = `-- name: ListPRComments :many +SELECT comment_id, author, file, line, body, resolved, created_at +FROM pr_comment +WHERE session_id = ? +ORDER BY created_at, comment_id +` + +type ListPRCommentsRow struct { + CommentID string + Author string + File string + Line int64 + Body string + Resolved int64 + CreatedAt time.Time +} + +func (q *Queries) ListPRComments(ctx context.Context, sessionID string) ([]ListPRCommentsRow, error) { + rows, err := q.db.QueryContext(ctx, listPRComments, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListPRCommentsRow{} + for rows.Next() { + var i ListPRCommentsRow + if err := rows.Scan( + &i.CommentID, + &i.Author, + &i.File, + &i.Line, + &i.Body, + &i.Resolved, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertPR = `-- name: UpsertPR :exec +INSERT INTO pr ( + session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id) DO UPDATE SET + review_decision = excluded.review_decision, + mergeability = excluded.mergeability, + ci_state = excluded.ci_state, + ci_passed = excluded.ci_passed, + ci_failed = excluded.ci_failed, + ci_pending = excluded.ci_pending, + ci_log_tail = excluded.ci_log_tail, + last_fetched_at = excluded.last_fetched_at +` + +type UpsertPRParams struct { + SessionID string + ReviewDecision string + Mergeability string + CiState string + CiPassed int64 + CiFailed int64 + CiPending int64 + CiLogTail string + LastFetchedAt time.Time +} + +func (q *Queries) UpsertPR(ctx context.Context, arg UpsertPRParams) error { + _, err := q.db.ExecContext(ctx, upsertPR, + arg.SessionID, + arg.ReviewDecision, + arg.Mergeability, + arg.CiState, + arg.CiPassed, + arg.CiFailed, + arg.CiPending, + arg.CiLogTail, + arg.LastFetchedAt, + ) + return err +} diff --git a/backend/internal/storage/sqlite/gen/pr_enrichment.sql.go b/backend/internal/storage/sqlite/gen/pr_enrichment.sql.go deleted file mode 100644 index c0643104ba..0000000000 --- a/backend/internal/storage/sqlite/gen/pr_enrichment.sql.go +++ /dev/null @@ -1,76 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: pr_enrichment.sql - -package gen - -import ( - "context" - "time" -) - -const deletePREnrichment = `-- name: DeletePREnrichment :exec -DELETE FROM pr_enrichment WHERE session_id = ? -` - -func (q *Queries) DeletePREnrichment(ctx context.Context, sessionID string) error { - _, err := q.db.ExecContext(ctx, deletePREnrichment, sessionID) - return err -} - -const getPREnrichment = `-- name: GetPREnrichment :one -SELECT session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at -FROM pr_enrichment -WHERE session_id = ? -` - -func (q *Queries) GetPREnrichment(ctx context.Context, sessionID string) (PrEnrichment, error) { - row := q.db.QueryRowContext(ctx, getPREnrichment, sessionID) - var i PrEnrichment - err := row.Scan( - &i.SessionID, - &i.CiSummary, - &i.ReviewDecision, - &i.Mergeability, - &i.PendingComments, - &i.CiLogTail, - &i.LastFetchedAt, - ) - return i, err -} - -const upsertPREnrichment = `-- name: UpsertPREnrichment :exec -INSERT INTO pr_enrichment (session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at) -VALUES (?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id) DO UPDATE SET - ci_summary = excluded.ci_summary, - review_decision = excluded.review_decision, - mergeability = excluded.mergeability, - pending_comments = excluded.pending_comments, - ci_log_tail = excluded.ci_log_tail, - last_fetched_at = excluded.last_fetched_at -` - -type UpsertPREnrichmentParams struct { - SessionID string - CiSummary string - ReviewDecision string - Mergeability string - PendingComments string - CiLogTail string - LastFetchedAt time.Time -} - -func (q *Queries) UpsertPREnrichment(ctx context.Context, arg UpsertPREnrichmentParams) error { - _, err := q.db.ExecContext(ctx, upsertPREnrichment, - arg.SessionID, - arg.CiSummary, - arg.ReviewDecision, - arg.Mergeability, - arg.PendingComments, - arg.CiLogTail, - arg.LastFetchedAt, - ) - return err -} diff --git a/backend/internal/storage/sqlite/gen/querier.go b/backend/internal/storage/sqlite/gen/querier.go index 76dd1aab58..83aa0c7edb 100644 --- a/backend/internal/storage/sqlite/gen/querier.go +++ b/backend/internal/storage/sqlite/gen/querier.go @@ -10,25 +10,31 @@ import ( type Querier interface { ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error - DeletePREnrichment(ctx context.Context, sessionID string) error + DeletePR(ctx context.Context, sessionID string) error + DeletePRChecks(ctx context.Context, sessionID string) error + DeletePRComments(ctx context.Context, sessionID string) error DeleteProject(ctx context.Context, id string) error DeleteReactionTracker(ctx context.Context, arg DeleteReactionTrackerParams) error DeleteSentOutboxBelow(ctx context.Context, changeLogSeq int64) (int64, error) DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error GetConsumerOffset(ctx context.Context, consumer string) (int64, error) - GetMetadata(ctx context.Context, sessionID string) ([]GetMetadataRow, error) - GetPREnrichment(ctx context.Context, sessionID string) (PrEnrichment, error) + GetPR(ctx context.Context, sessionID string) (Pr, error) GetProject(ctx context.Context, id string) (Project, error) GetSession(ctx context.Context, id string) (Session, error) + GetSessionMetadata(ctx context.Context, sessionID string) (GetSessionMetadataRow, error) GetSessionRevision(ctx context.Context, id string) (int64, error) // Appends a canonical-write record and returns its monotonic seq so the same // transaction can thread it into the outbox row. InsertChangeLog(ctx context.Context, arg InsertChangeLogParams) (int64, error) InsertOutbox(ctx context.Context, arg InsertOutboxParams) error + InsertPRCheck(ctx context.Context, arg InsertPRCheckParams) error + InsertPRComment(ctx context.Context, arg InsertPRCommentParams) error // CAS insert: only succeeds for a brand-new id. Incoming revision must be 0; // the row is persisted at revision 1. InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) ListAllSessions(ctx context.Context) ([]Session, error) + ListPRChecks(ctx context.Context, sessionID string) ([]ListPRChecksRow, error) + ListPRComments(ctx context.Context, sessionID string) ([]ListPRCommentsRow, error) ListProjects(ctx context.Context) ([]Project, error) ListReactionTrackers(ctx context.Context) ([]ReactionTracker, error) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) @@ -41,10 +47,13 @@ type Querier interface { // revision (@expected_revision). 0 rows affected => revision mismatch. UpdateSessionCAS(ctx context.Context, arg UpdateSessionCASParams) (int64, error) UpsertConsumerOffset(ctx context.Context, arg UpsertConsumerOffsetParams) error - UpsertMetadata(ctx context.Context, arg UpsertMetadataParams) error - UpsertPREnrichment(ctx context.Context, arg UpsertPREnrichmentParams) error + UpsertPR(ctx context.Context, arg UpsertPRParams) error UpsertProject(ctx context.Context, arg UpsertProjectParams) error UpsertReactionTracker(ctx context.Context, arg UpsertReactionTrackerParams) error + // Merge semantics: an empty incoming column is "leave unchanged", so a partial + // patch (e.g. spawn writing only the runtime handle) never clobbers a value set + // earlier (e.g. the branch set at creation). Mirrors the old per-key map merge. + UpsertSessionMetadata(ctx context.Context, arg UpsertSessionMetadataParams) error } var _ Querier = (*Queries)(nil) diff --git a/backend/internal/storage/sqlite/migrations/0001_init.sql b/backend/internal/storage/sqlite/migrations/0001_init.sql index f343e16d13..3822412569 100644 --- a/backend/internal/storage/sqlite/migrations/0001_init.sql +++ b/backend/internal/storage/sqlite/migrations/0001_init.sql @@ -39,14 +39,21 @@ CREATE TABLE sessions ( CREATE INDEX idx_sessions_project ON sessions (project_id); --- session_metadata is the opaque key/value side-channel (branch, workspacePath, --- runtimeHandleId, runtimeName, agentSessionId, prompt). Written by --- PatchMetadata; never bumps revision and never emits a CDC event. +-- session_metadata is the 1:1 typed side-channel for a session's operational +-- handles and seed inputs — the fields the Session Manager and reaper need but +-- that are NOT part of the canonical lifecycle. One row per session, named +-- columns (not a free-form key/value bag), so the set of metadata a session can +-- carry is fixed by the schema. Written by PatchMetadata; never bumps revision +-- and never emits a CDC event. CREATE TABLE session_metadata ( - session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE CASCADE, - key TEXT NOT NULL, - value TEXT NOT NULL, - PRIMARY KEY (session_id, key) + session_id TEXT PRIMARY KEY REFERENCES sessions (id) ON DELETE CASCADE, + branch TEXT NOT NULL DEFAULT '', + workspace_path TEXT NOT NULL DEFAULT '', + runtime_handle_id TEXT NOT NULL DEFAULT '', + runtime_name TEXT NOT NULL DEFAULT '', + agent_session_id TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMP NOT NULL ); -- change_log is the durable, ordered record of every canonical write. seq is the diff --git a/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql b/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql index 4421f0ddc1..da987ed5c6 100644 --- a/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql +++ b/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql @@ -26,25 +26,60 @@ CREATE TABLE projects ( archived_at TIMESTAMP ); --- pr_enrichment is the SCM observer's per-session cache of the rich PR facts that --- do NOT live in the canonical lifecycle (which keeps only pr_state/reason/number/ --- url). It is 1:1 with a session (a PR is always tied to a session by its branch), --- written by the SCM observer OFF the canonical CDC path (no revision bump, no --- change_log/outbox event), and cascades away with its session. -CREATE TABLE pr_enrichment ( +-- pr is the SCM observer's per-session cache of the rich PR facts that do NOT +-- live in the canonical lifecycle (which keeps only pr_state/reason/number/url). +-- 1:1 with a session (a PR is tied to a session by its branch), written by the +-- SCM observer OFF the canonical CDC path (no revision bump, no change_log/outbox +-- event), and cascades away with its session. Scalar facts are typed columns — +-- review_decision/mergeability/ci_state are CHECK-constrained enums and the CI +-- counts are integers, not opaque strings; the list facts (individual checks and +-- review comments) are normalized into pr_check / pr_comment. +CREATE TABLE pr ( session_id TEXT PRIMARY KEY REFERENCES sessions (id) ON DELETE CASCADE, - ci_summary TEXT NOT NULL DEFAULT '', - review_decision TEXT NOT NULL DEFAULT '', - mergeability TEXT NOT NULL DEFAULT '', - pending_comments TEXT NOT NULL DEFAULT '', + review_decision TEXT NOT NULL DEFAULT 'none' + CHECK (review_decision IN ('none', 'approved', 'changes_requested', 'review_required')), + mergeability TEXT NOT NULL DEFAULT 'unknown' + CHECK (mergeability IN ('unknown', 'mergeable', 'conflicting', 'blocked', 'unstable')), + ci_state TEXT NOT NULL DEFAULT 'unknown' + CHECK (ci_state IN ('unknown', 'pending', 'passing', 'failing')), + ci_passed INTEGER NOT NULL DEFAULT 0, + ci_failed INTEGER NOT NULL DEFAULT 0, + ci_pending INTEGER NOT NULL DEFAULT 0, ci_log_tail TEXT NOT NULL DEFAULT '', last_fetched_at TIMESTAMP NOT NULL ); +-- pr_check is one CI check belonging to a pr (the normalized form of the old +-- ci_summary string). It cascades from pr, so it cannot outlive its PR facts. +CREATE TABLE pr_check ( + session_id TEXT NOT NULL REFERENCES pr (session_id) ON DELETE CASCADE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown' + CHECK (status IN ('unknown', 'queued', 'in_progress', 'passed', 'failed', 'skipped', 'cancelled')), + url TEXT NOT NULL DEFAULT '', + PRIMARY KEY (session_id, name) +); + +-- pr_comment is one unresolved review comment belonging to a pr (the normalized +-- form of the old pending_comments JSON-in-a-string). Cascades from pr. +CREATE TABLE pr_comment ( + session_id TEXT NOT NULL REFERENCES pr (session_id) ON DELETE CASCADE, + comment_id TEXT NOT NULL, + author TEXT NOT NULL DEFAULT '', + file TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0, + body TEXT NOT NULL DEFAULT '', + resolved INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (session_id, comment_id) +); + -- +goose StatementEnd -- +goose Down -- +goose StatementBegin -DROP TABLE pr_enrichment; +DROP TABLE pr_comment; +DROP TABLE pr_check; +DROP TABLE pr; DROP TABLE projects; -- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/pr_projects_test.go b/backend/internal/storage/sqlite/pr_projects_test.go index 6cdd20bc11..58227b1f3c 100644 --- a/backend/internal/storage/sqlite/pr_projects_test.go +++ b/backend/internal/storage/sqlite/pr_projects_test.go @@ -2,6 +2,7 @@ package sqlite import ( "context" + "reflect" "testing" "time" @@ -88,41 +89,122 @@ func TestArchiveProjectHidesFromListButGetResolves(t *testing.T) { } } -func TestPREnrichmentUpsertGetDelete(t *testing.T) { +func TestPRUpsertGetDelete(t *testing.T) { s := newTestStore(t) ctx := context.Background() now := time.Now().UTC().Truncate(time.Second) - // pr_enrichment FKs sessions(id); seed the session first. + // pr FKs sessions(id); seed the session first. if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { t.Fatalf("seed session: %v", err) } - if _, ok, err := s.GetPREnrichment(ctx, "s1"); err != nil || ok { + if _, ok, err := s.GetPR(ctx, "s1"); err != nil || ok { t.Fatalf("get missing: ok=%v err=%v", ok, err) } - e := PREnrichmentRow{ - SessionID: "s1", CISummary: "3 passing, 1 failing", ReviewDecision: "changes_requested", - Mergeability: "blocked", PendingComments: `[{"path":"a.go"}]`, CILogTail: "FAIL TestX", + pr := PRRow{ + SessionID: "s1", ReviewDecision: "changes_requested", Mergeability: "blocked", + CIState: "failing", CIPassed: 3, CIFailed: 1, CIPending: 0, CILogTail: "FAIL TestX", LastFetchedAt: now, } - if err := s.UpsertPREnrichment(ctx, e); err != nil { + if err := s.UpsertPR(ctx, pr); err != nil { t.Fatalf("upsert: %v", err) } - got, ok, err := s.GetPREnrichment(ctx, "s1") + got, ok, err := s.GetPR(ctx, "s1") if err != nil || !ok { t.Fatalf("get: ok=%v err=%v", ok, err) } - if got != e { - t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, e) + if got != pr { + t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, pr) } - if err := s.DeletePREnrichment(ctx, "s1"); err != nil { + if err := s.DeletePR(ctx, "s1"); err != nil { t.Fatalf("delete: %v", err) } - if _, ok, _ := s.GetPREnrichment(ctx, "s1"); ok { - t.Fatal("enrichment should be gone after delete") + if _, ok, _ := s.GetPR(ctx, "s1"); ok { + t.Fatal("pr should be gone after delete") + } +} + +func TestPRRejectsBadEnum(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { + t.Fatalf("seed session: %v", err) + } + // review_decision is a CHECK-constrained enum; an off-list value must fail. + err := s.UpsertPR(ctx, PRRow{ + SessionID: "s1", ReviewDecision: "definitely_not_a_decision", + Mergeability: "unknown", CIState: "unknown", LastFetchedAt: time.Now().UTC(), + }) + if err == nil { + t.Fatal("expected CHECK constraint to reject an invalid review_decision") + } +} + +func TestPRChecksAndCommentsReplaceAndList(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { + t.Fatalf("seed session: %v", err) + } + // pr_check / pr_comment FK pr(session_id); the pr row must exist first. + if err := s.UpsertPR(ctx, PRRow{ + SessionID: "s1", ReviewDecision: "review_required", Mergeability: "unknown", + CIState: "pending", LastFetchedAt: now, + }); err != nil { + t.Fatalf("upsert pr: %v", err) + } + + checks := []PRCheck{ + {Name: "build", Status: "passed", URL: "https://ci/build"}, + {Name: "test", Status: "failed", URL: "https://ci/test"}, + } + if err := s.ReplacePRChecks(ctx, "s1", checks); err != nil { + t.Fatalf("replace checks: %v", err) + } + gotChecks, err := s.ListPRChecks(ctx, "s1") + if err != nil { + t.Fatalf("list checks: %v", err) + } + if !reflect.DeepEqual(gotChecks, checks) { + t.Fatalf("checks = %+v, want %+v", gotChecks, checks) + } + // Replace is a set-replace, not a merge: a shorter set removes the rest. + if err := s.ReplacePRChecks(ctx, "s1", []PRCheck{{Name: "build", Status: "passed"}}); err != nil { + t.Fatalf("replace checks 2: %v", err) + } + if gotChecks, _ = s.ListPRChecks(ctx, "s1"); len(gotChecks) != 1 { + t.Fatalf("after replace, checks = %+v, want 1", gotChecks) + } + + comments := []PRComment{ + {CommentID: "c1", Author: "alice", File: "a.go", Line: 10, Body: "nit", Resolved: false, CreatedAt: now}, + {CommentID: "c2", Author: "bob", File: "b.go", Line: 20, Body: "bug", Resolved: true, CreatedAt: now.Add(time.Second)}, + } + if err := s.ReplacePRComments(ctx, "s1", comments); err != nil { + t.Fatalf("replace comments: %v", err) + } + gotComments, err := s.ListPRComments(ctx, "s1") + if err != nil { + t.Fatalf("list comments: %v", err) + } + if !reflect.DeepEqual(gotComments, comments) { + t.Fatalf("comments = %+v, want %+v", gotComments, comments) + } + + // Deleting the pr cascades its checks and comments. + if err := s.DeletePR(ctx, "s1"); err != nil { + t.Fatalf("delete pr: %v", err) + } + if c, _ := s.ListPRChecks(ctx, "s1"); len(c) != 0 { + t.Fatalf("checks not cascaded: %+v", c) + } + if c, _ := s.ListPRComments(ctx, "s1"); len(c) != 0 { + t.Fatalf("comments not cascaded: %+v", c) } } diff --git a/backend/internal/storage/sqlite/pr_store.go b/backend/internal/storage/sqlite/pr_store.go index 70efb7ce73..c7d436bda0 100644 --- a/backend/internal/storage/sqlite/pr_store.go +++ b/backend/internal/storage/sqlite/pr_store.go @@ -10,57 +10,185 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -// PREnrichmentRow is the SCM observer's cache of the rich PR facts that do not -// live in the canonical lifecycle (which keeps only pr_state/reason/number/url). -// It is 1:1 with a session and written OFF the canonical CDC path: upserting it -// never bumps revision and never emits a change_log/outbox event. pending_comments -// and ci_log_tail are opaque blobs the SCM observer serializes. -type PREnrichmentRow struct { - SessionID string - CISummary string - ReviewDecision string - Mergeability string - PendingComments string - CILogTail string - LastFetchedAt time.Time +// PRRow is the SCM observer's cache of the scalar PR facts that do not live in +// the canonical lifecycle (which keeps only pr_state/reason/number/url). It is +// 1:1 with a session and written OFF the canonical CDC path: upserting it never +// bumps revision and never emits a change_log/outbox event. The list facts +// (checks, comments) are separate rows — see PRCheck / PRComment. +type PRRow struct { + SessionID string + ReviewDecision string // none | approved | changes_requested | review_required + Mergeability string // unknown | mergeable | conflicting | blocked | unstable + CIState string // unknown | pending | passing | failing + CIPassed int64 + CIFailed int64 + CIPending int64 + CILogTail string + LastFetchedAt time.Time } -// UpsertPREnrichment inserts or replaces the cached PR facts for one session. -func (s *Store) UpsertPREnrichment(ctx context.Context, r PREnrichmentRow) error { - return s.q.UpsertPREnrichment(ctx, gen.UpsertPREnrichmentParams{ - SessionID: r.SessionID, - CiSummary: r.CISummary, - ReviewDecision: r.ReviewDecision, - Mergeability: r.Mergeability, - PendingComments: r.PendingComments, - CiLogTail: r.CILogTail, - LastFetchedAt: r.LastFetchedAt, +// PRCheck is one CI check belonging to a session's PR. +type PRCheck struct { + Name string + Status string // unknown | queued | in_progress | passed | failed | skipped | cancelled + URL string +} + +// PRComment is one review comment belonging to a session's PR. +type PRComment struct { + CommentID string + Author string + File string + Line int64 + Body string + Resolved bool + CreatedAt time.Time +} + +// UpsertPR inserts or replaces the scalar PR facts for one session. +func (s *Store) UpsertPR(ctx context.Context, r PRRow) error { + return s.q.UpsertPR(ctx, gen.UpsertPRParams{ + SessionID: r.SessionID, + ReviewDecision: r.ReviewDecision, + Mergeability: r.Mergeability, + CiState: r.CIState, + CiPassed: r.CIPassed, + CiFailed: r.CIFailed, + CiPending: r.CIPending, + CiLogTail: r.CILogTail, + LastFetchedAt: r.LastFetchedAt, }) } -// GetPREnrichment returns the cached PR facts for one session. ok is false when -// no row exists (the SCM observer has not yet fetched, or the session has no PR). -func (s *Store) GetPREnrichment(ctx context.Context, sessionID string) (PREnrichmentRow, bool, error) { - e, err := s.q.GetPREnrichment(ctx, sessionID) +// GetPR returns the scalar PR facts for one session. ok is false when no row +// exists (the SCM observer has not fetched yet, or the session has no PR). +func (s *Store) GetPR(ctx context.Context, sessionID string) (PRRow, bool, error) { + p, err := s.q.GetPR(ctx, sessionID) if errors.Is(err, sql.ErrNoRows) { - return PREnrichmentRow{}, false, nil + return PRRow{}, false, nil } if err != nil { - return PREnrichmentRow{}, false, fmt.Errorf("get pr enrichment: %w", err) + return PRRow{}, false, fmt.Errorf("get pr: %w", err) } - return PREnrichmentRow{ - SessionID: e.SessionID, - CISummary: e.CiSummary, - ReviewDecision: e.ReviewDecision, - Mergeability: e.Mergeability, - PendingComments: e.PendingComments, - CILogTail: e.CiLogTail, - LastFetchedAt: e.LastFetchedAt, + return PRRow{ + SessionID: p.SessionID, + ReviewDecision: p.ReviewDecision, + Mergeability: p.Mergeability, + CIState: p.CiState, + CIPassed: p.CiPassed, + CIFailed: p.CiFailed, + CIPending: p.CiPending, + CILogTail: p.CiLogTail, + LastFetchedAt: p.LastFetchedAt, }, true, nil } -// DeletePREnrichment drops the cached PR facts for one session. Normally -// unnecessary (the FK cascades on session delete), exposed for explicit eviction. -func (s *Store) DeletePREnrichment(ctx context.Context, sessionID string) error { - return s.q.DeletePREnrichment(ctx, sessionID) +// DeletePR drops the scalar PR facts for one session, cascading its checks and +// comments. Normally unnecessary (the chain cascades on session delete); exposed +// for explicit eviction. +func (s *Store) DeletePR(ctx context.Context, sessionID string) error { + return s.q.DeletePR(ctx, sessionID) +} + +// ReplacePRChecks atomically replaces the full set of CI checks for a session's +// PR — each SCM fetch reports the current set, so a replace (not a merge) keeps +// the table in sync (a check that disappeared upstream is removed). The PR row +// must already exist (pr_check FKs pr). +func (s *Store) ReplacePRChecks(ctx context.Context, sessionID string, checks []PRCheck) error { + return s.inTx(ctx, "replace pr checks", func(qtx *gen.Queries) error { + if err := qtx.DeletePRChecks(ctx, sessionID); err != nil { + return err + } + for _, c := range checks { + if err := qtx.InsertPRCheck(ctx, gen.InsertPRCheckParams{ + SessionID: sessionID, + Name: c.Name, + Status: c.Status, + Url: c.URL, + }); err != nil { + return fmt.Errorf("check %q: %w", c.Name, err) + } + } + return nil + }) +} + +// ListPRChecks returns a session's CI checks, ordered by name. +func (s *Store) ListPRChecks(ctx context.Context, sessionID string) ([]PRCheck, error) { + rows, err := s.q.ListPRChecks(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("list pr checks: %w", err) + } + out := make([]PRCheck, 0, len(rows)) + for _, r := range rows { + out = append(out, PRCheck{Name: r.Name, Status: r.Status, URL: r.Url}) + } + return out, nil +} + +// ReplacePRComments atomically replaces the full set of review comments for a +// session's PR (same replace-not-merge rationale as ReplacePRChecks). +func (s *Store) ReplacePRComments(ctx context.Context, sessionID string, comments []PRComment) error { + return s.inTx(ctx, "replace pr comments", func(qtx *gen.Queries) error { + if err := qtx.DeletePRComments(ctx, sessionID); err != nil { + return err + } + for _, c := range comments { + if err := qtx.InsertPRComment(ctx, gen.InsertPRCommentParams{ + SessionID: sessionID, + CommentID: c.CommentID, + Author: c.Author, + File: c.File, + Line: c.Line, + Body: c.Body, + Resolved: boolToInt(c.Resolved), + CreatedAt: c.CreatedAt, + }); err != nil { + return fmt.Errorf("comment %q: %w", c.CommentID, err) + } + } + return nil + }) +} + +// ListPRComments returns a session's review comments, ordered by creation time. +func (s *Store) ListPRComments(ctx context.Context, sessionID string) ([]PRComment, error) { + rows, err := s.q.ListPRComments(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("list pr comments: %w", err) + } + out := make([]PRComment, 0, len(rows)) + for _, r := range rows { + out = append(out, PRComment{ + CommentID: r.CommentID, + Author: r.Author, + File: r.File, + Line: r.Line, + Body: r.Body, + Resolved: r.Resolved != 0, + CreatedAt: r.CreatedAt, + }) + } + return out, nil +} + +// inTx runs fn inside a single transaction over the store's queries, rolling +// back on error. +func (s *Store) inTx(ctx context.Context, what string, fn func(*gen.Queries) error) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin %s: %w", what, err) + } + defer tx.Rollback() + if err := fn(s.q.WithTx(tx)); err != nil { + return fmt.Errorf("%s: %w", what, err) + } + return tx.Commit() +} + +func boolToInt(b bool) int64 { + if b { + return 1 + } + return 0 } diff --git a/backend/internal/storage/sqlite/queries/metadata.sql b/backend/internal/storage/sqlite/queries/metadata.sql index 45079bb252..158552daa3 100644 --- a/backend/internal/storage/sqlite/queries/metadata.sql +++ b/backend/internal/storage/sqlite/queries/metadata.sql @@ -1,7 +1,20 @@ --- name: GetMetadata :many -SELECT key, value FROM session_metadata WHERE session_id = ?; +-- name: GetSessionMetadata :one +SELECT branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt +FROM session_metadata +WHERE session_id = ?; --- name: UpsertMetadata :exec -INSERT INTO session_metadata (session_id, key, value) -VALUES (?, ?, ?) -ON CONFLICT (session_id, key) DO UPDATE SET value = excluded.value; +-- name: UpsertSessionMetadata :exec +-- Merge semantics: an empty incoming column is "leave unchanged", so a partial +-- patch (e.g. spawn writing only the runtime handle) never clobbers a value set +-- earlier (e.g. the branch set at creation). Mirrors the old per-key map merge. +INSERT INTO session_metadata ( + session_id, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id) DO UPDATE SET + branch = CASE WHEN excluded.branch <> '' THEN excluded.branch ELSE session_metadata.branch END, + workspace_path = CASE WHEN excluded.workspace_path <> '' THEN excluded.workspace_path ELSE session_metadata.workspace_path END, + runtime_handle_id = CASE WHEN excluded.runtime_handle_id <> '' THEN excluded.runtime_handle_id ELSE session_metadata.runtime_handle_id END, + runtime_name = CASE WHEN excluded.runtime_name <> '' THEN excluded.runtime_name ELSE session_metadata.runtime_name END, + agent_session_id = CASE WHEN excluded.agent_session_id <> '' THEN excluded.agent_session_id ELSE session_metadata.agent_session_id END, + prompt = CASE WHEN excluded.prompt <> '' THEN excluded.prompt ELSE session_metadata.prompt END, + updated_at = excluded.updated_at; diff --git a/backend/internal/storage/sqlite/queries/pr.sql b/backend/internal/storage/sqlite/queries/pr.sql new file mode 100644 index 0000000000..13c14a78da --- /dev/null +++ b/backend/internal/storage/sqlite/queries/pr.sql @@ -0,0 +1,43 @@ +-- name: UpsertPR :exec +INSERT INTO pr ( + session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id) DO UPDATE SET + review_decision = excluded.review_decision, + mergeability = excluded.mergeability, + ci_state = excluded.ci_state, + ci_passed = excluded.ci_passed, + ci_failed = excluded.ci_failed, + ci_pending = excluded.ci_pending, + ci_log_tail = excluded.ci_log_tail, + last_fetched_at = excluded.last_fetched_at; + +-- name: GetPR :one +SELECT session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at +FROM pr +WHERE session_id = ?; + +-- name: DeletePR :exec +DELETE FROM pr WHERE session_id = ?; + +-- name: DeletePRChecks :exec +DELETE FROM pr_check WHERE session_id = ?; + +-- name: InsertPRCheck :exec +INSERT INTO pr_check (session_id, name, status, url) VALUES (?, ?, ?, ?); + +-- name: ListPRChecks :many +SELECT name, status, url FROM pr_check WHERE session_id = ? ORDER BY name; + +-- name: DeletePRComments :exec +DELETE FROM pr_comment WHERE session_id = ?; + +-- name: InsertPRComment :exec +INSERT INTO pr_comment (session_id, comment_id, author, file, line, body, resolved, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?); + +-- name: ListPRComments :many +SELECT comment_id, author, file, line, body, resolved, created_at +FROM pr_comment +WHERE session_id = ? +ORDER BY created_at, comment_id; diff --git a/backend/internal/storage/sqlite/queries/pr_enrichment.sql b/backend/internal/storage/sqlite/queries/pr_enrichment.sql deleted file mode 100644 index 7c2ac0a030..0000000000 --- a/backend/internal/storage/sqlite/queries/pr_enrichment.sql +++ /dev/null @@ -1,18 +0,0 @@ --- name: UpsertPREnrichment :exec -INSERT INTO pr_enrichment (session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at) -VALUES (?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id) DO UPDATE SET - ci_summary = excluded.ci_summary, - review_decision = excluded.review_decision, - mergeability = excluded.mergeability, - pending_comments = excluded.pending_comments, - ci_log_tail = excluded.ci_log_tail, - last_fetched_at = excluded.last_fetched_at; - --- name: GetPREnrichment :one -SELECT session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at -FROM pr_enrichment -WHERE session_id = ?; - --- name: DeletePREnrichment :exec -DELETE FROM pr_enrichment WHERE session_id = ?; diff --git a/backend/internal/storage/sqlite/store.go b/backend/internal/storage/sqlite/store.go index bd61e73b38..75b5474a2c 100644 --- a/backend/internal/storage/sqlite/store.go +++ b/backend/internal/storage/sqlite/store.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -77,42 +78,41 @@ func (s *Store) ListAll(ctx context.Context) ([]domain.SessionRecord, error) { return out, nil } -// GetMetadata returns the opaque key/value metadata for a session. -func (s *Store) GetMetadata(ctx context.Context, id domain.SessionID) (map[string]string, error) { - rows, err := s.q.GetMetadata(ctx, string(id)) - if err != nil { - return nil, fmt.Errorf("get metadata %s: %w", id, err) - } - if len(rows) == 0 { - return nil, nil +// GetMetadata returns the typed metadata for a session, or the zero value if the +// session has no metadata row yet. +func (s *Store) GetMetadata(ctx context.Context, id domain.SessionID) (domain.SessionMetadata, error) { + row, err := s.q.GetSessionMetadata(ctx, string(id)) + if errors.Is(err, sql.ErrNoRows) { + return domain.SessionMetadata{}, nil } - m := make(map[string]string, len(rows)) - for _, r := range rows { - m[r.Key] = r.Value + if err != nil { + return domain.SessionMetadata{}, fmt.Errorf("get metadata %s: %w", id, err) } - return m, nil + return domain.SessionMetadata{ + Branch: row.Branch, + WorkspacePath: row.WorkspacePath, + RuntimeHandleID: row.RuntimeHandleID, + RuntimeName: row.RuntimeName, + AgentSessionID: row.AgentSessionID, + Prompt: row.Prompt, + }, nil } -// PatchMetadata merges kv into the session's metadata. It is outside the -// canonical write path: no revision bump, no CDC event. -func (s *Store) PatchMetadata(ctx context.Context, id domain.SessionID, kv map[string]string) error { - if len(kv) == 0 { +// PatchMetadata merges meta into the session's metadata. It is outside the +// canonical write path: no revision bump, no CDC event. Empty fields are left +// unchanged (see UpsertSessionMetadata), so a partial patch is non-destructive. +func (s *Store) PatchMetadata(ctx context.Context, id domain.SessionID, meta domain.SessionMetadata) error { + if meta.IsZero() { return nil } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin patch metadata: %w", err) - } - defer tx.Rollback() - qtx := s.q.WithTx(tx) - for k, v := range kv { - if err := qtx.UpsertMetadata(ctx, gen.UpsertMetadataParams{ - SessionID: string(id), - Key: k, - Value: v, - }); err != nil { - return fmt.Errorf("patch metadata %s[%s]: %w", id, k, err) - } - } - return tx.Commit() + return s.q.UpsertSessionMetadata(ctx, gen.UpsertSessionMetadataParams{ + SessionID: string(id), + Branch: meta.Branch, + WorkspacePath: meta.WorkspacePath, + RuntimeHandleID: meta.RuntimeHandleID, + RuntimeName: meta.RuntimeName, + AgentSessionID: meta.AgentSessionID, + Prompt: meta.Prompt, + UpdatedAt: time.Now().UTC(), + }) } diff --git a/backend/internal/storage/sqlite/store_test.go b/backend/internal/storage/sqlite/store_test.go index 5457855da0..711f8cf1e5 100644 --- a/backend/internal/storage/sqlite/store_test.go +++ b/backend/internal/storage/sqlite/store_test.go @@ -156,7 +156,7 @@ func TestGetListRoundTrip(t *testing.T) { if got.ID != "a" || got.Lifecycle.Revision != 1 || got.IssueID != "issue-1" { t.Fatalf("unexpected record: %+v", got) } - if got.Metadata != nil { + if !got.Metadata.IsZero() { t.Fatalf("Get must not reconstruct metadata, got %v", got.Metadata) } @@ -176,10 +176,11 @@ func TestMetadataSideChannel(t *testing.T) { t.Fatal(err) } - if err := s.PatchMetadata(ctx, "s1", map[string]string{"branch": "feat/x", "prompt": "do it"}); err != nil { + if err := s.PatchMetadata(ctx, "s1", domain.SessionMetadata{Branch: "feat/x", Prompt: "do it"}); err != nil { t.Fatalf("patch: %v", err) } - if err := s.PatchMetadata(ctx, "s1", map[string]string{"branch": "feat/y"}); err != nil { + // A partial patch (only Branch) must not clobber the earlier Prompt. + if err := s.PatchMetadata(ctx, "s1", domain.SessionMetadata{Branch: "feat/y"}); err != nil { t.Fatalf("patch overwrite: %v", err) } @@ -187,8 +188,8 @@ func TestMetadataSideChannel(t *testing.T) { if err != nil { t.Fatal(err) } - if m["branch"] != "feat/y" || m["prompt"] != "do it" { - t.Fatalf("metadata = %v", m) + if m.Branch != "feat/y" || m.Prompt != "do it" { + t.Fatalf("metadata = %+v", m) } // Metadata writes must not bump revision (off the canonical path). lc, _, _ := s.Load(ctx, "s1") @@ -239,7 +240,7 @@ func TestLoadGetMissing(t *testing.T) { if _, ok, err := s.Get(ctx, "nope"); ok || err != nil { t.Fatalf("Get missing: ok=%v err=%v", ok, err) } - if m, err := s.GetMetadata(ctx, "nope"); err != nil || m != nil { + if m, err := s.GetMetadata(ctx, "nope"); err != nil || !m.IsZero() { t.Fatalf("GetMetadata missing: m=%v err=%v", m, err) } } diff --git a/backend/internal/storage/sqlite/upsert.go b/backend/internal/storage/sqlite/upsert.go index 40944005a1..6467451685 100644 --- a/backend/internal/storage/sqlite/upsert.go +++ b/backend/internal/storage/sqlite/upsert.go @@ -82,14 +82,14 @@ func casPersist(ctx context.Context, q *gen.Queries, rec domain.SessionRecord) ( } // appendOutbox writes the change_log entry and threads its seq into a fresh -// outbox row. The change_log payload is the persisted record at its new -// revision (metadata excluded — it is not on the canonical path). +// outbox row. The change_log payload is the persisted record at its new revision +// (metadata is excluded by SessionRecord's json:"-" tag — it is not on the +// canonical path). func appendOutbox(ctx context.Context, q *gen.Queries, rec domain.SessionRecord, newRevision int, eventType ports.EventType) error { now := time.Now().UTC() payload := rec payload.Lifecycle.Revision = newRevision payload.Lifecycle.Version = domain.LifecycleVersion - payload.Metadata = nil blob, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal change_log payload %s: %w", rec.ID, err) diff --git a/backend/main_test.go b/backend/main_test.go index 1a8d60c3fe..c8f3254189 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -127,7 +127,7 @@ func TestSnapshotSourceRebuildsState(t *testing.T) { if rec.Lifecycle.Revision != 1 { t.Errorf("payload revision = %d, want 1", rec.Lifecycle.Revision) } - if rec.Metadata != nil { + if !rec.Metadata.IsZero() { t.Errorf("snapshot payload must exclude metadata, got %v", rec.Metadata) } } From b0e4fffa62f92957bc40c330c1c0a105c7c597c4 Mon Sep 17 00:00:00 2001 From: prateek Date: Sun, 31 May 2026 00:39:19 +0530 Subject: [PATCH 051/250] test(cdc): add full-stack E2E tests through the real store + snapshot source The cdc integration test covers the synchronous Drain/Poll happy path but (1) resyncs from a fake snapshot and (2) never runs the publisher/consumer as the concurrent goroutines the daemon actually uses. Add two E2E tests in the composition-root package that wire the real sqlite.Store, outboxAdapter, Publisher, JSONL log, Consumer, Broadcaster and the REAL snapshotSource (store.ListAll): - RealSnapshotResyncThroughRotation: forces a rotation and asserts the consumer rebuilds from the sessions table, delivering the persisted record payload, with the offset landing at the change_log head. - ConcurrentPublisherConsumer: runs both as goroutines on their tickers and asserts every write is delivered exactly once, in order, offset at head (also exercises the broadcaster hand-off under -race). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/cdc_e2e_test.go | 194 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 backend/cdc_e2e_test.go diff --git a/backend/cdc_e2e_test.go b/backend/cdc_e2e_test.go new file mode 100644 index 0000000000..29b04534c2 --- /dev/null +++ b/backend/cdc_e2e_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "context" + "encoding/json" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// These are full-stack end-to-end tests of the write+delivery path wired exactly +// as main.go wires it: real sqlite.Store -> real outboxAdapter -> real +// cdc.Publisher -> real JSONL log -> real cdc.Consumer -> real cdc.Broadcaster, +// using the REAL snapshotSource (store.ListAll) rather than a fake. The cdc +// package's own integration test covers the synchronous Drain/Poll happy path +// with a fake snapshot; these cover the two gaps it leaves: a rotation that +// resyncs from the actual sessions table, and the concurrent goroutine model +// the daemon actually runs. + +// TestE2E_RealSnapshotResyncThroughRotation forces a log rotation and asserts the +// consumer rebuilds state from the REAL sessions-table snapshot (not the +// rotated-away bytes), delivering the persisted record's payload. +func TestE2E_RealSnapshotResyncThroughRotation(t *testing.T) { + ctx := context.Background() + store := newWiringStore(t) + dir := t.TempDir() + log, err := cdc.OpenLog(dir, 80) // tiny cap: the second write forces a rotation + if err != nil { + t.Fatal(err) + } + defer log.Close() + + var mu sync.Mutex + var got []cdc.Event + bc := cdc.NewBroadcaster() + bc.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) + + con := cdc.NewConsumer("fe", filepath.Join(dir, cdc.LogFileName), store, bc, + cdc.ConsumerConfig{Snapshot: snapshotSource{store: store}}) + if _, err := con.Start(ctx); err != nil { + t.Fatal(err) + } + pub := cdc.NewPublisher(outboxAdapter{store: store}, log, cdc.PublisherConfig{}) + + // First canonical write: drained and consumed live from the original file. + if err := store.Upsert(ctx, wiringRec("s1"), ports.EventSessionCreated); err != nil { + t.Fatal(err) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + if err := con.Poll(ctx); err != nil { + t.Fatal(err) + } + mu.Lock() + before := len(got) + mu.Unlock() + + // Second write pushes the log past its cap -> rotation. The consumer sees a + // fresh file and must resync from the sessions table. + r := wiringRec("s1") + r.Lifecycle.Revision = 1 + if err := store.Upsert(ctx, r, ports.EventSessionStateChanged); err != nil { + t.Fatal(err) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + if err := con.Poll(ctx); err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + if len(got) <= before { + t.Fatalf("resync delivered nothing after rotation (got %d, before %d)", len(got), before) + } + // A real session_snapshot for s1 must have been delivered, carrying the full + // record persisted in the sessions table. + var snap *cdc.Event + for i := range got { + if got[i].EventType == "session_snapshot" && got[i].SessionID == "s1" { + snap = &got[i] + } + } + if snap == nil { + t.Fatalf("no real session_snapshot delivered after rotation; got %+v", got) + } + var rec domain.SessionRecord + if err := json.Unmarshal([]byte(snap.Payload), &rec); err != nil { + t.Fatalf("snapshot payload not a SessionRecord: %v", err) + } + if rec.ID != "s1" || rec.Lifecycle.Session.State != domain.SessionWorking { + t.Fatalf("snapshot payload mismatch: %+v", rec) + } + // The consumer's durable offset advanced to the change_log head. + off, err := store.GetOffset(ctx, "fe") + if err != nil { + t.Fatal(err) + } + maxSeq, err := store.MaxChangeLogSeq(ctx) + if err != nil { + t.Fatal(err) + } + if off != maxSeq { + t.Fatalf("offset = %d, want change_log head %d", off, maxSeq) + } +} + +// TestE2E_ConcurrentPublisherConsumer runs the publisher and consumer as the +// daemon runs them — independent goroutines on their own tickers — and asserts +// every canonical write is delivered exactly once, in order, with the offset +// landing at the head. Run under -race this also guards the broadcaster/consumer +// hand-off. +func TestE2E_ConcurrentPublisherConsumer(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := newWiringStore(t) + dir := t.TempDir() + log, err := cdc.OpenLog(dir, 0) + if err != nil { + t.Fatal(err) + } + defer log.Close() + + var mu sync.Mutex + var got []cdc.Event + bc := cdc.NewBroadcaster() + bc.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) + + pub := cdc.NewPublisher(outboxAdapter{store: store}, log, cdc.PublisherConfig{}) + con := cdc.NewConsumer("fe", filepath.Join(dir, cdc.LogFileName), store, bc, cdc.ConsumerConfig{}) + + pubDone := pub.Start(ctx) + conDone, err := con.Start(ctx) + if err != nil { + t.Fatal(err) + } + + const n = 5 + for i := 0; i < n; i++ { + r := wiringRec("s1") + r.Lifecycle.Revision = i + evt := ports.EventSessionStateChanged + if i == 0 { + evt = ports.EventSessionCreated + } + if err := store.Upsert(ctx, r, evt); err != nil { + t.Fatalf("upsert %d: %v", i, err) + } + } + + // Bounded wait for the goroutine pipeline to deliver everything. + deadline := time.Now().Add(5 * time.Second) + for { + mu.Lock() + count := len(got) + mu.Unlock() + if count >= n { + break + } + if time.Now().After(deadline) { + t.Fatalf("timed out: delivered %d/%d events", count, n) + } + time.Sleep(20 * time.Millisecond) + } + + cancel() + <-pubDone + <-conDone + + mu.Lock() + defer mu.Unlock() + if len(got) != n { + t.Fatalf("delivered %d events, want %d", len(got), n) + } + for i, e := range got { + if e.Seq != int64(i+1) { + t.Fatalf("event %d has seq %d, want %d (out-of-order or duplicate)", i, e.Seq, i+1) + } + } + off, err := store.GetOffset(context.Background(), "fe") + if err != nil { + t.Fatal(err) + } + if off != n { + t.Fatalf("offset = %d, want %d", off, n) + } +} From ba472128021848d3cb223d153b079ca4eed67441 Mon Sep 17 00:00:00 2001 From: prateek Date: Sun, 31 May 2026 00:51:18 +0530 Subject: [PATCH 052/250] perf(storage): allow concurrent reads; serialize writes via a mutex SetMaxOpenConns(1) forced every read (List/Get/GetPR/...) to queue behind the single connection, so the dashboard's reads contended with the LCM's writes. WAL already supports many concurrent readers, so raise the pool to 8 and instead serialize *writes* with a Store.writeMu. That keeps WAL's single-writer rule and the revision-CAS read-then-write atomic regardless of pool size, while reads now run in parallel across the pool. Every write method takes writeMu (Upsert, PatchMetadata, UpsertPR/DeletePR, the pr_check/pr_comment Replace* via inTx, the CDC outbox/offset writes, project writes, reaction-tracker writes); reads take nothing. Added TestConcurrentReadsAndWrites (16 writers + 16 readers) which passes under -race. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/storage/sqlite/cdc_store.go | 8 +++ backend/internal/storage/sqlite/db.go | 22 +++++--- backend/internal/storage/sqlite/pr_store.go | 11 +++- .../internal/storage/sqlite/project_store.go | 6 +++ .../internal/storage/sqlite/reaction_store.go | 6 +++ backend/internal/storage/sqlite/store.go | 17 +++++-- backend/internal/storage/sqlite/store_test.go | 51 +++++++++++++++++++ backend/internal/storage/sqlite/upsert.go | 2 + 8 files changed, 109 insertions(+), 14 deletions(-) diff --git a/backend/internal/storage/sqlite/cdc_store.go b/backend/internal/storage/sqlite/cdc_store.go index 3386f98807..8f92eda735 100644 --- a/backend/internal/storage/sqlite/cdc_store.go +++ b/backend/internal/storage/sqlite/cdc_store.go @@ -45,6 +45,8 @@ func (s *Store) ListUnsent(ctx context.Context, limit int) ([]OutboxEvent, error // MarkSent flags an outbox row delivered. func (s *Store) MarkSent(ctx context.Context, outboxID int64, at time.Time) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.MarkOutboxSent(ctx, gen.MarkOutboxSentParams{ SentAt: sql.NullTime{Time: at, Valid: true}, ID: outboxID, @@ -53,6 +55,8 @@ func (s *Store) MarkSent(ctx context.Context, outboxID int64, at time.Time) erro // MarkFailed bumps the attempt count and records the last error for an outbox row. func (s *Store) MarkFailed(ctx context.Context, outboxID int64, errMsg string) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.MarkOutboxFailed(ctx, gen.MarkOutboxFailedParams{LastError: errMsg, ID: outboxID}) } @@ -70,6 +74,8 @@ func (s *Store) GetOffset(ctx context.Context, consumer string) (int64, error) { // SetOffset durably records a consumer's acknowledged seq. func (s *Store) SetOffset(ctx context.Context, consumer string, seq int64, at time.Time) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.UpsertConsumerOffset(ctx, gen.UpsertConsumerOffsetParams{ Consumer: consumer, LastSeq: seq, @@ -100,5 +106,7 @@ func (s *Store) MinConsumerOffset(ctx context.Context) (int64, error) { // DeleteSentOutboxBelow removes delivered outbox rows whose seq is below the // watermark, returning the number removed. func (s *Store) DeleteSentOutboxBelow(ctx context.Context, seq int64) (int64, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.DeleteSentOutboxBelow(ctx, seq) } diff --git a/backend/internal/storage/sqlite/db.go b/backend/internal/storage/sqlite/db.go index 78eb3ae9df..0a2555e4e6 100644 --- a/backend/internal/storage/sqlite/db.go +++ b/backend/internal/storage/sqlite/db.go @@ -18,17 +18,25 @@ import ( //go:embed migrations/*.sql var migrationsFS embed.FS -// pragmas are applied on every connection open. WAL + NORMAL gives concurrent -// reads alongside the single writer; busy_timeout absorbs brief writer -// contention; foreign_keys enforces the session_metadata cascade. +// pragmas are applied on every connection open. WAL + NORMAL lets readers run +// concurrently with the writer; busy_timeout absorbs brief writer contention; +// foreign_keys enforces the cascades. const pragmas = "?_pragma=journal_mode(WAL)" + "&_pragma=busy_timeout(5000)" + "&_pragma=foreign_keys(ON)" + "&_pragma=synchronous(NORMAL)" +// maxConnections caps the pool. WAL allows many concurrent readers, so reads +// (List/Get/GetPR/...) scale across the pool instead of queuing behind a single +// connection. Writes do NOT rely on the pool for serialization — the Store funnels +// every write through its writeMu (see store.go), which keeps WAL's single-writer +// rule and the revision-CAS read-then-write atomic regardless of pool size. +const maxConnections = 8 + // Open opens (creating if absent) the SQLite database under dataDir, applies the // connection pragmas, and runs all goose migrations up. The returned *sql.DB is -// safe for the single-writer / many-reader workload the LCM and readers impose. +// sized for the many-reader / serialized-single-writer workload the LCM and +// readers impose. func Open(dataDir string) (*sql.DB, error) { if err := os.MkdirAll(dataDir, 0o755); err != nil { return nil, fmt.Errorf("create data dir: %w", err) @@ -38,10 +46,8 @@ func Open(dataDir string) (*sql.DB, error) { if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } - // Single writer: serialize all access through one connection so WAL's - // single-writer rule is never violated by the pool handing out a second - // writable conn mid-transaction. - db.SetMaxOpenConns(1) + db.SetMaxOpenConns(maxConnections) + db.SetMaxIdleConns(maxConnections) // keep reader conns warm; avoid open/close churn if err := migrate(db); err != nil { db.Close() diff --git a/backend/internal/storage/sqlite/pr_store.go b/backend/internal/storage/sqlite/pr_store.go index c7d436bda0..1eca08f8f2 100644 --- a/backend/internal/storage/sqlite/pr_store.go +++ b/backend/internal/storage/sqlite/pr_store.go @@ -47,6 +47,8 @@ type PRComment struct { // UpsertPR inserts or replaces the scalar PR facts for one session. func (s *Store) UpsertPR(ctx context.Context, r PRRow) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.UpsertPR(ctx, gen.UpsertPRParams{ SessionID: r.SessionID, ReviewDecision: r.ReviewDecision, @@ -87,6 +89,8 @@ func (s *Store) GetPR(ctx context.Context, sessionID string) (PRRow, bool, error // comments. Normally unnecessary (the chain cascades on session delete); exposed // for explicit eviction. func (s *Store) DeletePR(ctx context.Context, sessionID string) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.DeletePR(ctx, sessionID) } @@ -172,9 +176,12 @@ func (s *Store) ListPRComments(ctx context.Context, sessionID string) ([]PRComme return out, nil } -// inTx runs fn inside a single transaction over the store's queries, rolling -// back on error. +// inTx runs fn inside a single write transaction over the store's queries, +// rolling back on error. It holds writeMu for the duration, so callers must not +// already hold it. func (s *Store) inTx(ctx context.Context, what string, fn func(*gen.Queries) error) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin %s: %w", what, err) diff --git a/backend/internal/storage/sqlite/project_store.go b/backend/internal/storage/sqlite/project_store.go index fb75e18aee..4837cafccf 100644 --- a/backend/internal/storage/sqlite/project_store.go +++ b/backend/internal/storage/sqlite/project_store.go @@ -34,6 +34,8 @@ type ProjectRow struct { // UpsertProject inserts or updates one registered project. func (s *Store) UpsertProject(ctx context.Context, r ProjectRow) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.UpsertProject(ctx, gen.UpsertProjectParams{ ID: r.ID, Path: r.Path, @@ -53,6 +55,8 @@ func (s *Store) UpsertProject(ctx context.Context, r ProjectRow) error { // ArchiveProject soft-deletes one project, keeping the row so a session's // project_id still resolves. Active-only reads (ListProjects) then hide it. func (s *Store) ArchiveProject(ctx context.Context, id string, t time.Time) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.ArchiveProject(ctx, gen.ArchiveProjectParams{ ArchivedAt: nullTime(t), ID: id, @@ -86,6 +90,8 @@ func (s *Store) ListProjects(ctx context.Context) ([]ProjectRow, error) { // DeleteProject removes one project by id. func (s *Store) DeleteProject(ctx context.Context, id string) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.DeleteProject(ctx, id) } diff --git a/backend/internal/storage/sqlite/reaction_store.go b/backend/internal/storage/sqlite/reaction_store.go index 819d9716b2..c703a21b2b 100644 --- a/backend/internal/storage/sqlite/reaction_store.go +++ b/backend/internal/storage/sqlite/reaction_store.go @@ -48,6 +48,8 @@ func (s *Store) ListReactionTrackers(ctx context.Context) ([]ReactionTrackerRow, // SaveReactionTracker durably persists one escalation budget (insert or update). func (s *Store) SaveReactionTracker(ctx context.Context, r ReactionTrackerRow) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() escalated := int64(0) if r.Escalated { escalated = 1 @@ -68,6 +70,8 @@ func (s *Store) SaveReactionTracker(ctx context.Context, r ReactionTrackerRow) e // DeleteReactionTracker drops one escalation budget. func (s *Store) DeleteReactionTracker(ctx context.Context, sessionID, reactionKey string) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.DeleteReactionTracker(ctx, gen.DeleteReactionTrackerParams{ SessionID: sessionID, ReactionKey: reactionKey, @@ -76,5 +80,7 @@ func (s *Store) DeleteReactionTracker(ctx context.Context, sessionID, reactionKe // DeleteSessionReactionTrackers drops every escalation budget for a session. func (s *Store) DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.DeleteSessionReactionTrackers(ctx, sessionID) } diff --git a/backend/internal/storage/sqlite/store.go b/backend/internal/storage/sqlite/store.go index 75b5474a2c..2effeaee86 100644 --- a/backend/internal/storage/sqlite/store.go +++ b/backend/internal/storage/sqlite/store.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "sync" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -12,11 +13,17 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -// Store is the SQLite-backed ports.LifecycleStore. The LCM is its sole logical -// writer (via Upsert); readers (Session Manager, reaper) use Load/Get/List. +// Store is the SQLite-backed ports.LifecycleStore. Reads (Load/Get/List/...) run +// concurrently across the connection pool; every write is funnelled through +// writeMu so there is exactly one writer at a time. That single-writer guarantee +// is load-bearing: it keeps WAL's single-writer rule and makes the revision-CAS +// (read-then-write in Upsert) atomic without depending on the pool size. Hold +// writeMu only around writes — never around a read — and never call one +// write method from inside another (the mutex is not reentrant). type Store struct { - db *sql.DB - q *gen.Queries + db *sql.DB + q *gen.Queries + writeMu sync.Mutex } var _ ports.LifecycleStore = (*Store)(nil) @@ -105,6 +112,8 @@ func (s *Store) PatchMetadata(ctx context.Context, id domain.SessionID, meta dom if meta.IsZero() { return nil } + s.writeMu.Lock() + defer s.writeMu.Unlock() return s.q.UpsertSessionMetadata(ctx, gen.UpsertSessionMetadataParams{ SessionID: string(id), Branch: meta.Branch, diff --git a/backend/internal/storage/sqlite/store_test.go b/backend/internal/storage/sqlite/store_test.go index 711f8cf1e5..a197f3af14 100644 --- a/backend/internal/storage/sqlite/store_test.go +++ b/backend/internal/storage/sqlite/store_test.go @@ -2,7 +2,9 @@ package sqlite import ( "context" + "fmt" "strings" + "sync" "testing" "time" @@ -255,3 +257,52 @@ func assertOutboxCount(t *testing.T, s *Store, ctx context.Context, want int) { t.Fatalf("outbox count = %d, want %d", len(rows), want) } } + +// TestConcurrentReadsAndWrites exercises the read-pool + write-mutex model: +// many writers (each its own session) run alongside many readers hammering +// ListAll. Reads must not be serialized behind writes, writes must not corrupt +// or error under the revision-CAS, and the final state must be exact. Run under +// -race this also guards the writeMu discipline. +func TestConcurrentReadsAndWrites(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + const n = 16 + + var wg sync.WaitGroup + errc := make(chan error, n*2) + + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if err := s.Upsert(ctx, sampleRecord(fmt.Sprintf("s%02d", i)), ports.EventSessionCreated); err != nil { + errc <- err + } + }(i) + } + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 25; j++ { + if _, err := s.ListAll(ctx); err != nil { + errc <- err + return + } + } + }() + } + wg.Wait() + close(errc) + for err := range errc { + t.Fatalf("concurrent op error: %v", err) + } + + got, err := s.ListAll(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != n { + t.Fatalf("after %d concurrent inserts, ListAll returned %d", n, len(got)) + } +} diff --git a/backend/internal/storage/sqlite/upsert.go b/backend/internal/storage/sqlite/upsert.go index 6467451685..f8ae409322 100644 --- a/backend/internal/storage/sqlite/upsert.go +++ b/backend/internal/storage/sqlite/upsert.go @@ -24,6 +24,8 @@ import ( // stored+1. // - insert: rec.Lifecycle.Revision must be 0, persisted as 1. func (s *Store) Upsert(ctx context.Context, rec domain.SessionRecord, eventType ports.EventType) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin upsert: %w", err) From e5c4fd6ffde69cdede0a0f7395d8e94128de82d3 Mon Sep 17 00:00:00 2001 From: prateek Date: Sun, 31 May 2026 05:04:31 +0530 Subject: [PATCH 053/250] feat(storage,cdc): minimal 6-table schema + trigger-driven CDC (storage layer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the storage + CDC layer to the simplified design agreed in review: Schema (one clean migration, 0001): projects, sessions, pr, pr_checks, pr_comment, change_log. sessions.id is a single string key "{project}-{num}" (mer-1); operational metadata folded into sessions; is_alive replaces the runtime axis; no revision (the per-session write mutex serializes, change_log.seq orders). pr keyed by URL (1 session : many PRs). pr_checks is CI run history (one row per check per commit) — the CI-fix-loop brake is a LIMIT 3 query, no counter stored. change_log carries a required project_id FK + nullable session_id. CDC is DB-native: AFTER INSERT/UPDATE triggers on sessions/pr/pr_checks append to change_log atomically with the change (json_object payloads). The old durable outbox/JSONL/janitor pipeline is gone; the cdc package is now a Poller that reads change_log and fans events out through the in-memory Broadcaster (hardened with recover()). Clients catch up via the log from their own offset (SSE Last-Event-ID). Storage uses a single writer connection + a reader pool (read-your-writes for the triggers' subqueries; concurrent reads). sqlc-generated typed queries. Tests (-race): CRUD, per-project id assignment, the loop-brake query, concurrent creates, triggers populating change_log; CDC end-to-end through the real store, concurrent goroutine delivery, broadcaster panic-isolation. NOTE: scoped to storage + CDC. The lifecycle-engine consumers (decide, lifecycle, session, reaper, main wiring) still reference the old domain axes and need a follow-up integration pass to compile against the new model. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/cdc/broadcast.go | 40 +- backend/internal/cdc/cdc_integration_test.go | 256 -------- backend/internal/cdc/cdc_test.go | 192 ++++++ backend/internal/cdc/consumer.go | 221 ------- backend/internal/cdc/event.go | 60 +- backend/internal/cdc/janitor.go | 84 --- backend/internal/cdc/jsonl.go | 109 ---- backend/internal/cdc/poller.go | 123 ++++ backend/internal/cdc/publisher.go | 115 ---- backend/internal/domain/decide/decide.go | 226 ++----- backend/internal/domain/decide/decide_test.go | 602 +++--------------- backend/internal/domain/decide/types.go | 68 +- backend/internal/domain/lifecycle.go | 174 +++-- backend/internal/domain/status.go | 76 ++- backend/internal/domain/status_test.go | 145 ++--- backend/internal/storage/sqlite/cdc_store.go | 112 ---- .../storage/sqlite/changelog_store.go | 89 +++ backend/internal/storage/sqlite/db.go | 59 +- .../internal/storage/sqlite/gen/cdc.sql.go | 199 ------ .../storage/sqlite/gen/changelog.sql.go | 102 +++ .../storage/sqlite/gen/metadata.sql.go | 82 --- backend/internal/storage/sqlite/gen/models.go | 92 +-- backend/internal/storage/sqlite/gen/pr.sql.go | 209 ++---- .../storage/sqlite/gen/pr_checks.sql.go | 119 ++++ .../storage/sqlite/gen/pr_comment.sql.go | 89 +++ .../storage/sqlite/gen/projects.sql.go | 55 +- .../internal/storage/sqlite/gen/querier.go | 53 +- .../storage/sqlite/gen/reactions.sql.go | 100 --- .../storage/sqlite/gen/sessions.sql.go | 262 ++++---- backend/internal/storage/sqlite/mapping.go | 158 +++-- .../storage/sqlite/migrations/0001_init.sql | 273 +++++--- .../sqlite/migrations/0002_pr_projects.sql | 85 --- .../storage/sqlite/pr_projects_test.go | 210 ------ backend/internal/storage/sqlite/pr_store.go | 271 ++++---- .../internal/storage/sqlite/project_store.go | 72 +-- .../internal/storage/sqlite/queries/cdc.sql | 42 -- .../storage/sqlite/queries/changelog.sql | 10 + .../storage/sqlite/queries/metadata.sql | 20 - .../internal/storage/sqlite/queries/pr.sql | 51 +- .../storage/sqlite/queries/pr_checks.sql | 15 + .../storage/sqlite/queries/pr_comment.sql | 12 + .../storage/sqlite/queries/projects.sql | 25 +- .../storage/sqlite/queries/reactions.sql | 18 - .../storage/sqlite/queries/sessions.sql | 70 +- .../internal/storage/sqlite/reaction_store.go | 86 --- backend/internal/storage/sqlite/spike_test.go | 92 --- backend/internal/storage/sqlite/store.go | 163 ++--- backend/internal/storage/sqlite/store_test.go | 406 ++++++------ backend/internal/storage/sqlite/upsert.go | 115 ---- 49 files changed, 2169 insertions(+), 4138 deletions(-) delete mode 100644 backend/internal/cdc/cdc_integration_test.go create mode 100644 backend/internal/cdc/cdc_test.go delete mode 100644 backend/internal/cdc/consumer.go delete mode 100644 backend/internal/cdc/janitor.go delete mode 100644 backend/internal/cdc/jsonl.go create mode 100644 backend/internal/cdc/poller.go delete mode 100644 backend/internal/cdc/publisher.go delete mode 100644 backend/internal/storage/sqlite/cdc_store.go create mode 100644 backend/internal/storage/sqlite/changelog_store.go delete mode 100644 backend/internal/storage/sqlite/gen/cdc.sql.go create mode 100644 backend/internal/storage/sqlite/gen/changelog.sql.go delete mode 100644 backend/internal/storage/sqlite/gen/metadata.sql.go create mode 100644 backend/internal/storage/sqlite/gen/pr_checks.sql.go create mode 100644 backend/internal/storage/sqlite/gen/pr_comment.sql.go delete mode 100644 backend/internal/storage/sqlite/gen/reactions.sql.go delete mode 100644 backend/internal/storage/sqlite/migrations/0002_pr_projects.sql delete mode 100644 backend/internal/storage/sqlite/pr_projects_test.go delete mode 100644 backend/internal/storage/sqlite/queries/cdc.sql create mode 100644 backend/internal/storage/sqlite/queries/changelog.sql delete mode 100644 backend/internal/storage/sqlite/queries/metadata.sql create mode 100644 backend/internal/storage/sqlite/queries/pr_checks.sql create mode 100644 backend/internal/storage/sqlite/queries/pr_comment.sql delete mode 100644 backend/internal/storage/sqlite/queries/reactions.sql delete mode 100644 backend/internal/storage/sqlite/reaction_store.go delete mode 100644 backend/internal/storage/sqlite/spike_test.go delete mode 100644 backend/internal/storage/sqlite/upsert.go diff --git a/backend/internal/cdc/broadcast.go b/backend/internal/cdc/broadcast.go index a7458e38db..b914f766e2 100644 --- a/backend/internal/cdc/broadcast.go +++ b/backend/internal/cdc/broadcast.go @@ -1,25 +1,29 @@ package cdc -import "sync" +import ( + "log/slog" + "sync" +) -// Broadcaster is the in-process fan-out the consumer feeds. Subscribers (the +// Broadcaster is the in-process fan-out the poller feeds. Subscribers (the // WS/SSE transport, wired in the frontend task) register a callback; every -// consumed Event is delivered to all current subscribers. It is the single -// seam between the CDC pipeline and live delivery, so the transport can be -// built and swapped without touching the pipeline. +// polled Event is delivered to all current subscribers. It is the single seam +// between the CDC poller and live delivery, so the transport can be built and +// swapped without touching the poller. type Broadcaster struct { mu sync.RWMutex nextID int subs map[int]func(Event) + logger *slog.Logger } // NewBroadcaster returns an empty Broadcaster ready for subscriptions. func NewBroadcaster() *Broadcaster { - return &Broadcaster{subs: map[int]func(Event){}} + return &Broadcaster{subs: map[int]func(Event){}, logger: slog.Default()} } // Subscribe registers fn and returns an unsubscribe function. fn is called -// synchronously from the consumer loop, so it must not block; a transport that +// synchronously from the poller loop, so it must not block; a transport that // needs buffering should push onto its own channel inside fn. func (b *Broadcaster) Subscribe(fn func(Event)) (unsubscribe func()) { b.mu.Lock() @@ -34,11 +38,29 @@ func (b *Broadcaster) Subscribe(fn func(Event)) (unsubscribe func()) { } } -// Publish delivers e to every current subscriber. +// SubscriberCount reports the number of current subscribers. +func (b *Broadcaster) SubscriberCount() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.subs) +} + +// Publish delivers e to every current subscriber. A panicking subscriber is +// recovered and logged so one bad callback can't kill the poller goroutine or +// starve the other subscribers. func (b *Broadcaster) Publish(e Event) { b.mu.RLock() defer b.mu.RUnlock() for _, fn := range b.subs { - fn(e) + b.deliver(fn, e) } } + +func (b *Broadcaster) deliver(fn func(Event), e Event) { + defer func() { + if r := recover(); r != nil { + b.logger.Error("cdc broadcaster: subscriber panicked", "seq", e.Seq, "panic", r) + } + }() + fn(e) +} diff --git a/backend/internal/cdc/cdc_integration_test.go b/backend/internal/cdc/cdc_integration_test.go deleted file mode 100644 index 9390afe012..0000000000 --- a/backend/internal/cdc/cdc_integration_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package cdc_test - -import ( - "context" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/cdc" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" -) - -// outboxAdapter bridges sqlite.Store's outbox methods to cdc.OutboxStore. This -// is the same glue the composition root (main.go) installs. -type outboxAdapter struct{ s *sqlite.Store } - -func (a outboxAdapter) ListUnsent(ctx context.Context, limit int) ([]cdc.PendingEvent, error) { - evs, err := a.s.ListUnsent(ctx, limit) - if err != nil { - return nil, err - } - out := make([]cdc.PendingEvent, len(evs)) - for i, e := range evs { - out[i] = cdc.PendingEvent{ - OutboxID: e.OutboxID, - Event: cdc.Event{ - Seq: e.Seq, - SessionID: e.SessionID, - EventType: e.EventType, - Revision: e.Revision, - Payload: e.Payload, - CreatedAt: e.CreatedAt, - }, - } - } - return out, nil -} - -func (a outboxAdapter) MarkSent(ctx context.Context, id int64, at time.Time) error { - return a.s.MarkSent(ctx, id, at) -} -func (a outboxAdapter) MarkFailed(ctx context.Context, id int64, msg string) error { - return a.s.MarkFailed(ctx, id, msg) -} - -func newStore(t *testing.T) *sqlite.Store { - t.Helper() - db, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatalf("open: %v", err) - } - t.Cleanup(func() { db.Close() }) - return sqlite.NewStore(db) -} - -func rec(id string) domain.SessionRecord { - now := time.Now().UTC() - return domain.SessionRecord{ - ID: domain.SessionID(id), ProjectID: "p", Kind: domain.KindWorker, CreatedAt: now, UpdatedAt: now, - Lifecycle: domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress}, - PR: domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonNotCreated}, - Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, - Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, - }, - } -} - -func TestEndToEndPublishConsume(t *testing.T) { - ctx := context.Background() - store := newStore(t) - dir := t.TempDir() - log, err := cdc.OpenLog(dir, 0) - if err != nil { - t.Fatal(err) - } - defer log.Close() - - // Three canonical writes => three outbox rows, seq 1..3. - r := rec("s1") - if err := store.Upsert(ctx, r, ports.EventSessionCreated); err != nil { - t.Fatal(err) - } - r.Lifecycle.Revision = 1 - if err := store.Upsert(ctx, r, ports.EventSessionStateChanged); err != nil { - t.Fatal(err) - } - r.Lifecycle.Revision = 2 - if err := store.Upsert(ctx, r, ports.EventSessionStateChanged); err != nil { - t.Fatal(err) - } - - pub := cdc.NewPublisher(outboxAdapter{store}, log, cdc.PublisherConfig{}) - if err := pub.Drain(ctx); err != nil { - t.Fatalf("drain: %v", err) - } - - var got []cdc.Event - bc := cdc.NewBroadcaster() - bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) - - con := cdc.NewConsumer("fe", dir+"/"+cdc.LogFileName, store, bc, cdc.ConsumerConfig{}) - if _, err := con.Start(ctx); err != nil { - t.Fatal(err) - } - // Drive one poll synchronously instead of waiting on the goroutine. - if err := con.Poll(ctx); err != nil { - t.Fatalf("poll: %v", err) - } - - if len(got) != 3 { - t.Fatalf("delivered %d events, want 3", len(got)) - } - for i, e := range got { - if e.Seq != int64(i+1) { - t.Fatalf("event %d has seq %d, want %d", i, e.Seq, i+1) - } - } - if got[0].EventType != string(ports.EventSessionCreated) { - t.Fatalf("first event type = %q", got[0].EventType) - } - - // Idempotency: a second poll with no new bytes delivers nothing more. - if err := con.Poll(ctx); err != nil { - t.Fatal(err) - } - if len(got) != 3 { - t.Fatalf("re-poll delivered extra events: %d", len(got)) - } - - // Offset persisted at seq 3. - off, _ := store.GetOffset(ctx, "fe") - if off != 3 { - t.Fatalf("offset = %d, want 3", off) - } - - // Janitor: consumer ACKed 3, so sent rows with seq < 3 are reclaimed. - jan := cdc.NewJanitor(store, cdc.JanitorConfig{}) - deleted, err := jan.Sweep(ctx) - if err != nil { - t.Fatal(err) - } - if deleted != 2 { - t.Fatalf("janitor deleted %d, want 2 (seq 1,2 < watermark 3)", deleted) - } -} - -func TestConsumerRestartSkipsDelivered(t *testing.T) { - ctx := context.Background() - store := newStore(t) - dir := t.TempDir() - log, _ := cdc.OpenLog(dir, 0) - defer log.Close() - - if err := store.Upsert(ctx, rec("s1"), ports.EventSessionCreated); err != nil { - t.Fatal(err) - } - pub := cdc.NewPublisher(outboxAdapter{store}, log, cdc.PublisherConfig{}) - if err := pub.Drain(ctx); err != nil { - t.Fatal(err) - } - - // Pre-seed the durable offset as if a prior consumer already delivered seq 1. - if err := store.SetOffset(ctx, "fe", 1, time.Now().UTC()); err != nil { - t.Fatal(err) - } - - var got []cdc.Event - bc := cdc.NewBroadcaster() - bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) - con := cdc.NewConsumer("fe", dir+"/"+cdc.LogFileName, store, bc, cdc.ConsumerConfig{}) - if _, err := con.Start(ctx); err != nil { - t.Fatal(err) - } - if err := con.Poll(ctx); err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("restart re-delivered already-acked events: %d", len(got)) - } -} - -// fakeSnapshot stands in for the sessions-table snapshot source on resync. -type fakeSnapshot struct { - events []cdc.Event - maxSeq int64 -} - -func (f fakeSnapshot) Snapshot(context.Context) ([]cdc.Event, int64, error) { - return f.events, f.maxSeq, nil -} - -func TestRotationTriggersResync(t *testing.T) { - ctx := context.Background() - store := newStore(t) - dir := t.TempDir() - // Tiny cap so a couple of writes force a rotation. - log, err := cdc.OpenLog(dir, 80) - if err != nil { - t.Fatal(err) - } - defer log.Close() - - var got []cdc.Event - bc := cdc.NewBroadcaster() - bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) - - snap := fakeSnapshot{events: []cdc.Event{{Seq: 5, SessionID: "s1", EventType: "session_updated"}}, maxSeq: 5} - con := cdc.NewConsumer("fe", dir+"/"+cdc.LogFileName, store, bc, cdc.ConsumerConfig{Snapshot: snap}) - if _, err := con.Start(ctx); err != nil { - t.Fatal(err) - } - - pub := cdc.NewPublisher(outboxAdapter{store}, log, cdc.PublisherConfig{}) - - // First write + drain + poll: consumer reads it and advances its cursor. - if err := store.Upsert(ctx, rec("s1"), ports.EventSessionCreated); err != nil { - t.Fatal(err) - } - if err := pub.Drain(ctx); err != nil { - t.Fatal(err) - } - if err := con.Poll(ctx); err != nil { - t.Fatal(err) - } - cursorBefore := len(got) - - // Force rotation by writing past the cap, then poll: the file shrank, so the - // consumer must resync from the snapshot source. - r := rec("s1") - r.Lifecycle.Revision = 1 - if err := store.Upsert(ctx, r, ports.EventSessionStateChanged); err != nil { - t.Fatal(err) - } - if err := pub.Drain(ctx); err != nil { - t.Fatal(err) - } - if err := con.Poll(ctx); err != nil { - t.Fatal(err) - } - - if len(got) <= cursorBefore { - t.Fatal("expected resync to deliver the snapshot event") - } - // The snapshot event (seq 5) must be among the delivered events. - var sawSnapshot bool - for _, e := range got { - if e.Seq == 5 { - sawSnapshot = true - } - } - if !sawSnapshot { - t.Fatalf("resync did not deliver snapshot event; got %+v", got) - } -} diff --git a/backend/internal/cdc/cdc_test.go b/backend/internal/cdc/cdc_test.go new file mode 100644 index 0000000000..d72370f4aa --- /dev/null +++ b/backend/internal/cdc/cdc_test.go @@ -0,0 +1,192 @@ +package cdc_test + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// storeSource adapts sqlite.Store to cdc.Source — the same glue the daemon wires. +type storeSource struct{ s *sqlite.Store } + +func (a storeSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) { + rows, err := a.s.ReadChangeLogAfter(ctx, after, limit) + if err != nil { + return nil, err + } + out := make([]cdc.Event, len(rows)) + for i, r := range rows { + out[i] = cdc.Event{ + Seq: r.Seq, + ProjectID: r.ProjectID, + SessionID: r.SessionID, + Type: cdc.EventType(r.EventType), + Payload: json.RawMessage(r.Payload), + CreatedAt: r.CreatedAt, + } + } + return out, nil +} + +func (a storeSource) LatestSeq(ctx context.Context) (int64, error) { return a.s.MaxChangeLogSeq(ctx) } + +func newStore(t *testing.T) *sqlite.Store { + t.Helper() + s, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func seedSession(t *testing.T, s *sqlite.Store) domain.SessionRecord { + t.Helper() + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + if err := s.UpsertProject(ctx, sqlite.ProjectRow{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil { + t.Fatal(err) + } + r, err := s.CreateSession(ctx, domain.SessionRecord{ + ProjectID: "mer", Kind: domain.KindWorker, + Lifecycle: domain.CanonicalSessionLifecycle{ + Session: domain.SessionSubstate{State: domain.SessionWorking}, + Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, + }, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + return r +} + +// TestE2E_StoreWriteToBroadcast drives the whole path: a store write fires a DB +// trigger that appends to change_log; the poller reads it and broadcasts. +func TestE2E_StoreWriteToBroadcast(t *testing.T) { + ctx := context.Background() + s := newStore(t) + r := seedSession(t, s) // -> session_created (seq 1) + + r.Lifecycle.Session.State = domain.SessionIdle + if err := s.UpdateSession(ctx, r); err != nil { // -> session_updated (seq 2) + t.Fatal(err) + } + if err := s.UpsertPR(ctx, sqlite.PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: r.UpdatedAt}); err != nil { // -> pr_created (seq 3) + t.Fatal(err) + } + + var got []cdc.Event + bc := cdc.NewBroadcaster() + bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) + p := cdc.NewPoller(storeSource{s}, bc, cdc.PollerConfig{}) // StartSeq 0: read from the top + if err := p.Poll(ctx); err != nil { + t.Fatal(err) + } + + if len(got) != 3 { + t.Fatalf("delivered %d events, want 3", len(got)) + } + for i, e := range got { + if e.Seq != int64(i+1) { + t.Fatalf("event %d seq=%d, want %d", i, e.Seq, i+1) + } + if e.ProjectID != "mer" { + t.Fatalf("event %d project=%q, want mer", i, e.ProjectID) + } + } + if got[0].Type != cdc.EventSessionCreated || got[1].Type != cdc.EventSessionUpdated || got[2].Type != cdc.EventPRCreated { + t.Fatalf("types = %s, %s, %s", got[0].Type, got[1].Type, got[2].Type) + } + // the trigger-built JSON payload survives as a usable RawMessage. + var payload map[string]any + if err := json.Unmarshal(got[0].Payload, &payload); err != nil { + t.Fatalf("payload not JSON: %v", err) + } + if payload["id"] != string(r.ID) || payload["state"] != "working" { + t.Fatalf("payload = %v", payload) + } + + // idempotent: a second poll with no new rows delivers nothing more. + if err := p.Poll(ctx); err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Fatalf("re-poll delivered extra events: %d", len(got)) + } +} + +// TestE2E_ConcurrentPollerLiveDelivery runs the poller as a goroutine (the daemon +// model) and asserts every store change is delivered exactly once, in order. +func TestE2E_ConcurrentPollerLiveDelivery(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := newStore(t) + r := seedSession(t, s) // seq 1 + + var mu sync.Mutex + var got []cdc.Event + bc := cdc.NewBroadcaster() + bc.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) + + p := cdc.NewPoller(storeSource{s}, bc, cdc.PollerConfig{}) // from the top + done := p.Start(ctx) + + const n = 6 + for i := 0; i < n; i++ { + r.Lifecycle.IsAlive = i%2 == 0 // toggles is_alive -> sessions_cdc_update fires + if err := s.UpdateSession(ctx, r); err != nil { + t.Fatal(err) + } + } + want := 1 + n // session_created + n updates + + deadline := time.Now().Add(5 * time.Second) + for { + mu.Lock() + c := len(got) + mu.Unlock() + if c >= want { + break + } + if time.Now().After(deadline) { + t.Fatalf("timed out: delivered %d/%d", c, want) + } + time.Sleep(20 * time.Millisecond) + } + cancel() + <-done + + mu.Lock() + defer mu.Unlock() + if len(got) != want { + t.Fatalf("delivered %d events, want %d", len(got), want) + } + for i, e := range got { + if e.Seq != int64(i+1) { + t.Fatalf("event %d has seq %d, want %d (out-of-order/duplicate)", i, e.Seq, i+1) + } + } +} + +// TestBroadcasterRecoversPanickingSubscriber: one panicking subscriber must not +// kill delivery to the others (or crash the poller goroutine). +func TestBroadcasterRecoversPanickingSubscriber(t *testing.T) { + bc := cdc.NewBroadcaster() + good := 0 + bc.Subscribe(func(cdc.Event) { panic("boom") }) + bc.Subscribe(func(cdc.Event) { good++ }) + + bc.Publish(cdc.Event{Seq: 1}) // must not panic + bc.Publish(cdc.Event{Seq: 2}) + + if good != 2 { + t.Fatalf("good subscriber got %d, want 2 (panic was not isolated)", good) + } +} diff --git a/backend/internal/cdc/consumer.go b/backend/internal/cdc/consumer.go deleted file mode 100644 index 00edb0f103..0000000000 --- a/backend/internal/cdc/consumer.go +++ /dev/null @@ -1,221 +0,0 @@ -package cdc - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" - "time" -) - -// DefaultPollInterval is how often the consumer checks the log for new bytes. -// Polling (rather than fs-notify) keeps the consumer dependency-free; at this -// cadence live updates stay well under a human-perceptible delay. -const DefaultPollInterval = 100 * time.Millisecond - -// OffsetStore persists the consumer's durable seq cursor (at-least-once). -type OffsetStore interface { - GetOffset(ctx context.Context, consumer string) (int64, error) - SetOffset(ctx context.Context, consumer string, seq int64, at time.Time) error -} - -// SnapshotSource rebuilds current state from the source of truth (the sessions -// table) after a rotation gap, where log lines for unconsumed-but-already-sent -// events were truncated away. It returns one Event per live session plus the -// MAX(change_log seq) the snapshot corresponds to, so the consumer can resume. -type SnapshotSource interface { - Snapshot(ctx context.Context) (events []Event, maxSeq int64, err error) -} - -// Consumer tails the JSONL log, deduplicates by seq, and fans each new event -// out through the Broadcaster, persisting its durable offset as it goes. -type Consumer struct { - name string - path string - offsets OffsetStore - bcast *Broadcaster - snapshot SnapshotSource - interval time.Duration - clock func() time.Time - logger *slog.Logger - - cursor int64 // byte offset into the log - lastSeq int64 // highest seq delivered - prevInfo os.FileInfo // identity of the file last polled (rotation detection) -} - -// ConsumerConfig holds optional knobs and the snapshot source. -type ConsumerConfig struct { - Snapshot SnapshotSource - Interval time.Duration - Clock func() time.Time - Logger *slog.Logger -} - -// NewConsumer constructs a Consumer named name (the consumer_offsets key) over -// the log at path, fanning out through bcast and persisting offsets via offsets. -func NewConsumer(name, path string, offsets OffsetStore, bcast *Broadcaster, cfg ConsumerConfig) *Consumer { - c := &Consumer{ - name: name, - path: path, - offsets: offsets, - bcast: bcast, - snapshot: cfg.Snapshot, - interval: cfg.Interval, - clock: cfg.Clock, - logger: cfg.Logger, - } - if c.interval <= 0 { - c.interval = DefaultPollInterval - } - if c.clock == nil { - c.clock = time.Now - } - if c.logger == nil { - c.logger = slog.Default() - } - return c -} - -// Start loads the durable offset and runs the poll loop until ctx is cancelled; -// the returned channel closes when the loop has exited. -func (c *Consumer) Start(ctx context.Context) (<-chan struct{}, error) { - seq, err := c.offsets.GetOffset(ctx, c.name) - if err != nil { - return nil, fmt.Errorf("load consumer offset: %w", err) - } - c.lastSeq = seq - - done := make(chan struct{}) - go func() { - defer close(done) - t := time.NewTicker(c.interval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if err := c.Poll(ctx); err != nil { - c.logger.Error("cdc consumer: poll failed", "err", err) - } - } - } - }() - return done, nil -} - -// Poll reads any new bytes since the last cursor and delivers complete lines. It -// detects rotation (the file shrank below the cursor) and resyncs from the DB -// snapshot before resuming. -func (c *Consumer) Poll(ctx context.Context) error { - f, err := os.Open(c.path) - if err != nil { - if os.IsNotExist(err) { - return nil // publisher has not created the log yet - } - return fmt.Errorf("open cdc log: %w", err) - } - defer f.Close() - - info, err := f.Stat() - if err != nil { - return fmt.Errorf("stat cdc log: %w", err) - } - size := info.Size() - - rotated := (c.prevInfo != nil && !os.SameFile(c.prevInfo, info)) || size < c.cursor - c.prevInfo = info - if rotated { - // The previous file's bytes are void. Resync from the DB snapshot (if - // wired), then resume reading the fresh file from the top. - if err := c.resync(ctx); err != nil { - return err - } - c.cursor = 0 - } - if size == c.cursor { - return nil - } - - if _, err := f.Seek(c.cursor, io.SeekStart); err != nil { - return fmt.Errorf("seek cdc log: %w", err) - } - data, err := io.ReadAll(f) - if err != nil { - return fmt.Errorf("read cdc log: %w", err) - } - - consumed, maxSeq := c.processLines(data) - c.cursor += int64(consumed) - - if maxSeq > c.lastSeq { - c.lastSeq = maxSeq - if err := c.offsets.SetOffset(ctx, c.name, c.lastSeq, c.clock().UTC()); err != nil { - return fmt.Errorf("persist consumer offset: %w", err) - } - } - return nil -} - -// processLines delivers each complete (newline-terminated) line, skipping reset -// markers and any event whose seq was already delivered. It returns the number -// of bytes consumed (only complete lines) and the highest seq seen. -func (c *Consumer) processLines(data []byte) (consumed int, maxSeq int64) { - maxSeq = c.lastSeq - for { - nl := bytes.IndexByte(data[consumed:], '\n') - if nl < 0 { - return consumed, maxSeq // partial trailing line: leave for next poll - } - line := data[consumed : consumed+nl] - consumed += nl + 1 - - if isResetMarker(line) { - continue - } - var e Event - if err := json.Unmarshal(line, &e); err != nil { - c.logger.Error("cdc consumer: bad line skipped", "err", err) - continue - } - if e.Seq <= c.lastSeq { - continue // idempotent: already delivered - } - c.bcast.Publish(e) - if e.Seq > maxSeq { - maxSeq = e.Seq - } - } -} - -func (c *Consumer) resync(ctx context.Context) error { - if c.snapshot == nil { - return nil - } - events, maxSeq, err := c.snapshot.Snapshot(ctx) - if err != nil { - return fmt.Errorf("cdc consumer resync: %w", err) - } - for _, e := range events { - c.bcast.Publish(e) - } - if maxSeq > c.lastSeq { - c.lastSeq = maxSeq - if err := c.offsets.SetOffset(ctx, c.name, c.lastSeq, c.clock().UTC()); err != nil { - return fmt.Errorf("persist offset after resync: %w", err) - } - } - return nil -} - -func isResetMarker(line []byte) bool { - var m resetMarker - if err := json.Unmarshal(line, &m); err != nil { - return false - } - return m.Type == "reset" -} diff --git a/backend/internal/cdc/event.go b/backend/internal/cdc/event.go index b0eddf9829..04f52648f3 100644 --- a/backend/internal/cdc/event.go +++ b/backend/internal/cdc/event.go @@ -1,32 +1,40 @@ -// Package cdc is the change-data-capture pipeline that turns the storage layer's -// transactional outbox into a durable, ordered event stream for the frontend. +// Package cdc is the change-data-capture delivery layer. Change events are +// captured durably by SQLite triggers into the change_log table (see the storage +// migrations); this package POLLS that log and fans new events out, in order, to +// in-process subscribers (the WS/SSE transport, wired in the frontend task). // -// The flow: the publisher drains the SQLite outbox (sent=0, seq order) and -// appends each change as one JSON line to a rotating log file. The consumer -// tails that file from a durable byte cursor, deduplicates by seq, and fans each -// change out through the Broadcaster to in-process subscribers (the WS/SSE -// transport, wired later). The janitor reclaims outbox rows every consumer has -// acknowledged. Delivery is at-least-once; seq is the idempotency key. +// There is no durable outbox/JSONL/janitor machinery: the change_log table IS +// the durable, ordered source of truth, and clients catch up by reading it from +// their own offset (SSE Last-Event-ID). The poller + broadcaster here are only +// the LIVE push on top of that. package cdc -import "time" +import ( + "encoding/json" + "time" +) -// Event is one change-data-capture record. It is the JSONL line shape and the -// value handed to Broadcaster subscribers. Seq is the monotonic ordering and -// idempotency key (the change_log seq). -type Event struct { - Seq int64 `json:"seq"` - SessionID string `json:"sessionId"` - EventType string `json:"eventType"` - Revision int64 `json:"revision"` - Payload string `json:"payload"` - CreatedAt time.Time `json:"createdAt"` -} +// EventType mirrors the event_type values the DB triggers write. +type EventType string -// resetMarker is written as the first line of a freshly rotated log file. A -// consumer that reads it knows the byte offsets of the previous file are void -// and must snapshot-resync, then resume from the current MAX(seq). -type resetMarker struct { - Type string `json:"type"` // always "reset" - RotatedAt time.Time `json:"rotatedAt"` +const ( + EventSessionCreated EventType = "session_created" + EventSessionUpdated EventType = "session_updated" + EventPRCreated EventType = "pr_created" + EventPRUpdated EventType = "pr_updated" + EventPRCheckRecorded EventType = "pr_check_recorded" +) + +// Event is one CDC change read from change_log. Seq is the monotonic ordering + +// idempotency key (consumers dedup by it). SessionID is empty for project-level +// events. Payload is the trigger-built JSON, kept raw so a typed transport can +// narrow it by Type (the discriminated-union decode lives at the transport edge, +// not here). +type Event struct { + Seq int64 `json:"seq"` + ProjectID string `json:"projectId"` + SessionID string `json:"sessionId,omitempty"` + Type EventType `json:"type"` + Payload json.RawMessage `json:"payload"` + CreatedAt time.Time `json:"createdAt"` } diff --git a/backend/internal/cdc/janitor.go b/backend/internal/cdc/janitor.go deleted file mode 100644 index 3968b2cf41..0000000000 --- a/backend/internal/cdc/janitor.go +++ /dev/null @@ -1,84 +0,0 @@ -package cdc - -import ( - "context" - "log/slog" - "time" -) - -// DefaultJanitorInterval is the outbox-vacuum cadence. -const DefaultJanitorInterval = 60 * time.Second - -// Vacuum is the janitor's view of storage: the safe deletion watermark and the -// delete itself. -type Vacuum interface { - MinConsumerOffset(ctx context.Context) (int64, error) - DeleteSentOutboxBelow(ctx context.Context, seq int64) (int64, error) -} - -// Janitor reclaims delivered outbox rows every consumer has acknowledged. -// -// Watermark: MIN(consumer_offsets.last_seq). Rows with seq < watermark are sent -// AND past every consumer's cursor, so they are safe to drop. When the watermark -// is 0 (a consumer exists but has acknowledged nothing, or none is registered -// yet) the janitor deletes nothing — it never races ahead of a consumer that -// has not yet read an event. change_log is never touched: it is the durable -// history and the snapshot-resync floor. -type Janitor struct { - store Vacuum - interval time.Duration - logger *slog.Logger -} - -// JanitorConfig holds optional knobs; zero values fall back to defaults. -type JanitorConfig struct { - Interval time.Duration - Logger *slog.Logger -} - -// NewJanitor constructs a Janitor over store. -func NewJanitor(store Vacuum, cfg JanitorConfig) *Janitor { - j := &Janitor{store: store, interval: cfg.Interval, logger: cfg.Logger} - if j.interval <= 0 { - j.interval = DefaultJanitorInterval - } - if j.logger == nil { - j.logger = slog.Default() - } - return j -} - -// Start runs the vacuum loop until ctx is cancelled; the returned channel closes -// when the loop has exited. -func (j *Janitor) Start(ctx context.Context) <-chan struct{} { - done := make(chan struct{}) - go func() { - defer close(done) - t := time.NewTicker(j.interval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if _, err := j.Sweep(ctx); err != nil { - j.logger.Error("cdc janitor: sweep failed", "err", err) - } - } - } - }() - return done -} - -// Sweep deletes delivered outbox rows below the safe watermark and returns the -// number removed. -func (j *Janitor) Sweep(ctx context.Context) (int64, error) { - watermark, err := j.store.MinConsumerOffset(ctx) - if err != nil { - return 0, err - } - if watermark <= 0 { - return 0, nil - } - return j.store.DeleteSentOutboxBelow(ctx, watermark) -} diff --git a/backend/internal/cdc/jsonl.go b/backend/internal/cdc/jsonl.go deleted file mode 100644 index 74dc0695c8..0000000000 --- a/backend/internal/cdc/jsonl.go +++ /dev/null @@ -1,109 +0,0 @@ -package cdc - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" - "time" -) - -// LogFileName is the active CDC log under the data dir. -const LogFileName = "session-events.jsonl" - -// DefaultMaxBytes is the size at which the log rotates (1 MiB). -const DefaultMaxBytes int64 = 1 << 20 - -// Log is the append-only JSONL sink the publisher writes to. When it grows past -// maxBytes it rotates by truncating in place and writing a reset marker as the -// new first line — the consumer treats a shrunken file as "resync from the DB -// snapshot", so the log itself is not the durable source of truth (SQLite is). -type Log struct { - mu sync.Mutex - path string - maxBytes int64 - f *os.File - size int64 -} - -// OpenLog opens (creating if absent) the JSONL log in dir. maxBytes <= 0 uses -// DefaultMaxBytes. -func OpenLog(dir string, maxBytes int64) (*Log, error) { - if maxBytes <= 0 { - maxBytes = DefaultMaxBytes - } - path := filepath.Join(dir, LogFileName) - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return nil, fmt.Errorf("open cdc log: %w", err) - } - info, err := f.Stat() - if err != nil { - f.Close() - return nil, fmt.Errorf("stat cdc log: %w", err) - } - return &Log{path: path, maxBytes: maxBytes, f: f, size: info.Size()}, nil -} - -// Append writes one event as a JSON line, flushing to disk. It rotates first if -// the file is already at/over the size cap, so a single oversized burst still -// lands in a fresh segment. -func (l *Log) Append(e Event) error { - l.mu.Lock() - defer l.mu.Unlock() - - if l.size >= l.maxBytes { - if err := l.rotateLocked(); err != nil { - return err - } - } - return l.writeLocked(e) -} - -func (l *Log) writeLocked(v any) error { - line, err := json.Marshal(v) - if err != nil { - return fmt.Errorf("marshal cdc line: %w", err) - } - line = append(line, '\n') - n, err := l.f.Write(line) - l.size += int64(n) - if err != nil { - return fmt.Errorf("write cdc line: %w", err) - } - if err := l.f.Sync(); err != nil { - return fmt.Errorf("sync cdc log: %w", err) - } - return nil -} - -// rotateLocked renames the active file aside and starts a fresh one whose first -// line is a reset marker. Renaming (not truncating in place) gives the file a -// new identity, so a polling consumer reliably detects rotation via -// os.SameFile even if the fresh file grows past its old byte cursor between -// polls. The consumer then resyncs from the DB snapshot. -func (l *Log) rotateLocked() error { - if err := l.f.Close(); err != nil { - return fmt.Errorf("close cdc log for rotate: %w", err) - } - archive := l.path + ".1" - _ = os.Remove(archive) // best-effort: history lives in SQLite, not the log - if err := os.Rename(l.path, archive); err != nil { - return fmt.Errorf("rotate cdc log: %w", err) - } - f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return fmt.Errorf("reopen cdc log after rotate: %w", err) - } - l.f = f - l.size = 0 - return l.writeLocked(resetMarker{Type: "reset", RotatedAt: time.Now().UTC()}) -} - -// Close closes the underlying file. -func (l *Log) Close() error { - l.mu.Lock() - defer l.mu.Unlock() - return l.f.Close() -} diff --git a/backend/internal/cdc/poller.go b/backend/internal/cdc/poller.go new file mode 100644 index 0000000000..c824def371 --- /dev/null +++ b/backend/internal/cdc/poller.go @@ -0,0 +1,123 @@ +package cdc + +import ( + "context" + "fmt" + "log/slog" + "time" +) + +// DefaultPollInterval is how often the poller checks change_log for new rows. +// Polling (rather than fs-notify or a DB hook) keeps it dependency-free; at this +// cadence live updates stay well under a human-perceptible delay. +const DefaultPollInterval = 100 * time.Millisecond + +// DefaultBatch bounds how many events one poll drains. +const DefaultBatch = 512 + +// Source is the poller's view of the durable log: read events after a seq, and +// the current head seq. The storage layer implements it (the change_log table). +type Source interface { + EventsAfter(ctx context.Context, after int64, limit int) ([]Event, error) + LatestSeq(ctx context.Context) (int64, error) +} + +// Poller tails change_log and fans each new event out through the Broadcaster, +// in seq order. It holds only an in-memory cursor (lastSeq): it is the LIVE push +// path, while durable catch-up is the client's job (read change_log from its own +// offset). A restart re-seeks to head, so the poller never re-broadcasts history +// to a freshly-started broadcaster. +type Poller struct { + src Source + bcast *Broadcaster + interval time.Duration + batch int + logger *slog.Logger + lastSeq int64 +} + +// PollerConfig holds optional knobs; zero values fall back to defaults. StartSeq +// is the cursor to begin from; production wiring leaves it 0 and calls +// SeekToHead, tests set it to read from the beginning. +type PollerConfig struct { + Interval time.Duration + Batch int + Logger *slog.Logger + StartSeq int64 +} + +// NewPoller constructs a Poller over src, fanning out through bcast. +func NewPoller(src Source, bcast *Broadcaster, cfg PollerConfig) *Poller { + p := &Poller{ + src: src, + bcast: bcast, + interval: cfg.Interval, + batch: cfg.Batch, + logger: cfg.Logger, + lastSeq: cfg.StartSeq, + } + if p.interval <= 0 { + p.interval = DefaultPollInterval + } + if p.batch <= 0 { + p.batch = DefaultBatch + } + if p.logger == nil { + p.logger = slog.Default() + } + return p +} + +// SeekToHead moves the cursor to the current head, so the poller only broadcasts +// events created from now on (clients catch up on older events via the store). +func (p *Poller) SeekToHead(ctx context.Context) error { + seq, err := p.src.LatestSeq(ctx) + if err != nil { + return fmt.Errorf("cdc poller seek: %w", err) + } + p.lastSeq = seq + return nil +} + +// Start runs the poll loop until ctx is cancelled; the returned channel closes +// when the loop has exited. +func (p *Poller) Start(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + t := time.NewTicker(p.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := p.Poll(ctx); err != nil { + p.logger.Error("cdc poller: poll failed", "err", err) + } + } + } + }() + return done +} + +// Poll drains one batch of new events and broadcasts them in seq order, +// advancing the cursor. Exported so tests (and a daemon) can drive a cycle +// synchronously. +func (p *Poller) Poll(ctx context.Context) error { + evs, err := p.src.EventsAfter(ctx, p.lastSeq, p.batch) + if err != nil { + return fmt.Errorf("cdc poller: read after %d: %w", p.lastSeq, err) + } + for _, e := range evs { + if e.Seq <= p.lastSeq { + continue // idempotent guard + } + p.bcast.Publish(e) + p.lastSeq = e.Seq + } + return nil +} + +// LastSeq returns the poller's current cursor (the highest seq broadcast). +func (p *Poller) LastSeq() int64 { return p.lastSeq } diff --git a/backend/internal/cdc/publisher.go b/backend/internal/cdc/publisher.go deleted file mode 100644 index 3283a236e0..0000000000 --- a/backend/internal/cdc/publisher.go +++ /dev/null @@ -1,115 +0,0 @@ -package cdc - -import ( - "context" - "log/slog" - "time" -) - -// DefaultPublishInterval is the outbox drain cadence. -const DefaultPublishInterval = 50 * time.Millisecond - -// DefaultBatchSize bounds how many outbox rows one drain pass handles. -const DefaultBatchSize = 256 - -// PendingEvent is an undelivered outbox row paired with its CDC event payload. -type PendingEvent struct { - OutboxID int64 - Event -} - -// OutboxStore is the publisher's view of the storage layer: read undelivered -// rows in seq order, then mark each delivered or failed. -type OutboxStore interface { - ListUnsent(ctx context.Context, limit int) ([]PendingEvent, error) - MarkSent(ctx context.Context, outboxID int64, at time.Time) error - MarkFailed(ctx context.Context, outboxID int64, errMsg string) error -} - -// Publisher drains the outbox into the JSONL log on a fixed cadence. -type Publisher struct { - src OutboxStore - log *Log - interval time.Duration - batch int - clock func() time.Time - logger *slog.Logger -} - -// PublisherConfig holds optional knobs; zero values fall back to defaults. -type PublisherConfig struct { - Interval time.Duration - Batch int - Clock func() time.Time - Logger *slog.Logger -} - -// NewPublisher constructs a Publisher over src and log. -func NewPublisher(src OutboxStore, log *Log, cfg PublisherConfig) *Publisher { - p := &Publisher{ - src: src, - log: log, - interval: cfg.Interval, - batch: cfg.Batch, - clock: cfg.Clock, - logger: cfg.Logger, - } - if p.interval <= 0 { - p.interval = DefaultPublishInterval - } - if p.batch <= 0 { - p.batch = DefaultBatchSize - } - if p.clock == nil { - p.clock = time.Now - } - if p.logger == nil { - p.logger = slog.Default() - } - return p -} - -// Start runs the drain loop until ctx is cancelled; the returned channel closes -// when the loop has exited. -func (p *Publisher) Start(ctx context.Context) <-chan struct{} { - done := make(chan struct{}) - go func() { - defer close(done) - t := time.NewTicker(p.interval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if err := p.Drain(ctx); err != nil { - p.logger.Error("cdc publisher: drain failed", "err", err) - } - } - } - }() - return done -} - -// Drain runs one pass: append each undelivered row to the log in seq order, -// marking it sent. A write failure stops the pass (the row is marked failed and -// retried next tick) so ordering is never violated by skipping ahead. -func (p *Publisher) Drain(ctx context.Context) error { - pending, err := p.src.ListUnsent(ctx, p.batch) - if err != nil { - return err - } - for _, pe := range pending { - if err := p.log.Append(pe.Event); err != nil { - p.logger.Error("cdc publisher: append failed", "outboxId", pe.OutboxID, "seq", pe.Seq, "err", err) - if merr := p.src.MarkFailed(ctx, pe.OutboxID, err.Error()); merr != nil { - p.logger.Error("cdc publisher: mark failed errored", "outboxId", pe.OutboxID, "err", merr) - } - return nil - } - if err := p.src.MarkSent(ctx, pe.OutboxID, p.clock().UTC()); err != nil { - return err - } - } - return nil -} diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go index c46df18d5f..be195aef4d 100644 --- a/backend/internal/domain/decide/decide.go +++ b/backend/internal/domain/decide/decide.go @@ -1,7 +1,11 @@ // Package decide is the pure DECIDE core: total, deterministic, zero I/O. It -// collapses observed facts (plus the prior detecting/activity memory) into one -// LifecycleDecision. Every function here must remain side-effect free so the -// whole status truth-table can be tested in isolation. +// collapses observed liveness facts (plus the prior detecting memory) into one +// LifecycleDecision. Every function here is side-effect free so the whole +// liveness truth-table can be tested in isolation. +// +// PR-driven behaviour is NOT here: PR display status is derived by +// domain.DeriveStatus from the pr table, and PR-driven nudges are the reaction +// engine's job. decide is only about liveness + the anti-flap quarantine. package decide import ( @@ -30,158 +34,57 @@ const ( // terminal this decider may reach without quarantine); // - a *failed* probe (timeout/error) is never read as death — it routes to // detecting, as does any disagreement between the two probes; -// - only runtime-dead + process-dead + no-recent-activity reaches killed. +// - only runtime-down + process-dead + no-recent-activity reaches terminal. func ResolveProbeDecision(in ProbeInput) LifecycleDecision { if in.KillRequested { + reason := in.KillReason + if reason == "" { + reason = domain.TermManuallyKilled + } return LifecycleDecision{ - Status: domain.StatusKilled, - Evidence: "manual kill requested", - SessionState: domain.SessionTerminated, - SessionReason: domain.ReasonManuallyKilled, + Evidence: "manual kill requested", + SessionState: domain.SessionTerminated, + TerminationReason: reason, + IsAlive: false, } } - if in.RuntimeFailed || in.ProcessFailed || in.Runtime == domain.RuntimeProbeFailed { - ev := fmt.Sprintf("probe_failed runtime=%s runtimeFailed=%t process=%s processFailed=%t", - in.Runtime, in.RuntimeFailed, in.Process, in.ProcessFailed) - return detecting(in, domain.ReasonProbeFailure, ev) + if in.RuntimeFailed || in.ProcessFailed { + ev := fmt.Sprintf("probe_failed runtimeFailed=%t process=%s processFailed=%t", in.RuntimeFailed, in.Process, in.ProcessFailed) + return detecting(in, ev) } - switch in.Runtime { - case domain.RuntimeAlive: + if in.RuntimeAlive { if in.Process == ProcessDead { // Runtime up but the agent process is gone: probes disagree. ev := fmt.Sprintf("disagree runtime=alive process=%s recentActivity=%t", in.Process, in.RecentActivity) - return detecting(in, domain.ReasonAgentProcessExited, ev) - } - return LifecycleDecision{ - Status: domain.StatusWorking, - Evidence: fmt.Sprintf("alive runtime=alive process=%s", in.Process), - SessionState: domain.SessionWorking, - SessionReason: domain.ReasonTaskInProgress, - } - - case domain.RuntimeExited, domain.RuntimeMissing: - // Runtime is gone. Death is only concluded when the process is *also* - // confirmed dead AND nothing has been heard from the agent recently; - // any other shape is ambiguous and quarantines. - if in.Process == ProcessAlive || in.RecentActivity { - ev := fmt.Sprintf("disagree runtime=%s process=%s recentActivity=%t", in.Runtime, in.Process, in.RecentActivity) - return detecting(in, domain.ReasonRuntimeLost, ev) - } - if in.Process == ProcessDead { - return LifecycleDecision{ - Status: domain.StatusKilled, - Evidence: fmt.Sprintf("dead runtime=%s process=dead recentActivity=false", in.Runtime), - SessionState: domain.SessionTerminated, - SessionReason: domain.ReasonRuntimeLost, - } - } - // Process indeterminate: cannot confirm death, so quarantine. - ev := fmt.Sprintf("runtime_lost runtime=%s process=%s recentActivity=false", in.Runtime, in.Process) - return detecting(in, domain.ReasonRuntimeLost, ev) - - default: - // unknown (not yet probed): ambiguous, never conclude death. - ev := fmt.Sprintf("runtime_unknown runtime=%s process=%s recentActivity=%t", in.Runtime, in.Process, in.RecentActivity) - return detecting(in, domain.ReasonRuntimeLost, ev) - } -} - -// ResolveOpenPRDecision walks the PR pipeline ladder. CI failure dominates -// everything. Draft PRs then surface as draft and do not enter the review or -// merge states. Open PRs continue through requested changes, approval/merge -// states, pending review, stalled (idle-beyond-threshold), then plain open. -func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { - // evidence is a stable, timestamp-free summary " # " - // for logs/traceability; it folds in the PR identity inputs (Number/URL). - evidence := func(cond string) string { - s := cond - if in.Number > 0 { - s += fmt.Sprintf(" #%d", in.Number) - } - if in.URL != "" { - s += " " + in.URL + return detecting(in, ev) } - return s - } - prState := domain.PROpen - if in.Draft { - prState = domain.PRDraft - } - base := func(status domain.SessionStatus, cond string, prReason domain.PRReason, ss domain.SessionState, sr domain.SessionReason) LifecycleDecision { return LifecycleDecision{ - Status: status, - Evidence: evidence(cond), - SessionState: ss, - SessionReason: sr, - PRState: prState, - PRReason: prReason, + Evidence: fmt.Sprintf("alive runtime=alive process=%s", in.Process), + SessionState: domain.SessionWorking, + IsAlive: true, } } - switch { - case in.CIFailing: - return base(domain.StatusCIFailed, "ci_failing", domain.PRReasonCIFailing, domain.SessionWorking, domain.ReasonFixingCI) - case in.Draft: - return base(domain.StatusDraft, "draft", domain.PRReasonInProgress, domain.SessionWorking, domain.ReasonPRCreated) - case in.ChangesRequested: - return base(domain.StatusChangesRequested, "changes_requested", domain.PRReasonChangesRequested, domain.SessionWorking, domain.ReasonResolvingReviewComments) - case in.BotComments: - return base(domain.StatusChangesRequested, "bot_comments", domain.PRReasonBotComments, domain.SessionWorking, domain.ReasonResolvingReviewComments) - case in.MergeConflicts: - return base(domain.StatusPROpen, "merge_conflicts", domain.PRReasonMergeConflicts, domain.SessionWorking, domain.ReasonPRCreated) - case in.Mergeable: - // Mergeability is the authoritative merge gate, so it already folds in - // "approved if review is required". Checking it before Approved means a - // PR on a no-required-review repo (mergeable, not formally approved) is - // still surfaced as ready-to-merge instead of falling through to PR_OPEN. - return base(domain.StatusMergeable, "merge_ready", domain.PRReasonMergeReady, domain.SessionIdle, domain.ReasonAwaitingExternalReview) - case in.Approved: - return base(domain.StatusApproved, "approved", domain.PRReasonApproved, domain.SessionIdle, domain.ReasonAwaitingExternalReview) - case in.ReviewPending: - return base(domain.StatusReviewPending, "review_pending", domain.PRReasonReviewPending, domain.SessionIdle, domain.ReasonAwaitingExternalReview) - case in.IdleBeyond: - // A PR open but quiet past the stuck threshold needs a human nudge. - return base(domain.StatusStuck, "idle_beyond", domain.PRReasonInProgress, domain.SessionStuck, domain.ReasonAwaitingUserInput) - default: - return base(domain.StatusPROpen, "pr_open", domain.PRReasonInProgress, domain.SessionWorking, domain.ReasonPRCreated) + // Runtime is gone. Death is only concluded when the process is *also* + // confirmed dead AND nothing has been heard from the agent recently; any + // other shape is ambiguous and quarantines. + if in.Process == ProcessAlive || in.RecentActivity { + ev := fmt.Sprintf("disagree runtime=down process=%s recentActivity=%t", in.Process, in.RecentActivity) + return detecting(in, ev) } -} - -// ResolveTerminalPRStateDecision handles merged/closed PRs. A merge parks the -// session idle awaiting a human's post-merge decision; a close drops to idle. -// none/open are not terminal — callers should route those to the open-PR or -// probe deciders — but the function stays total for safety. -func ResolveTerminalPRStateDecision(pr domain.PRState) LifecycleDecision { - switch pr { - case domain.PRMerged: + if in.Process == ProcessDead { return LifecycleDecision{ - Status: domain.StatusMerged, - Evidence: "pr merged", - SessionState: domain.SessionIdle, - SessionReason: domain.ReasonMergedWaitingDecision, - PRState: domain.PRMerged, - PRReason: domain.PRReasonMerged, - } - case domain.PRClosed: - return LifecycleDecision{ - Status: domain.StatusIdle, - Evidence: "pr closed unmerged", - SessionState: domain.SessionIdle, - SessionReason: domain.ReasonAwaitingUserInput, - PRState: domain.PRClosed, - PRReason: domain.PRReasonClosedUnmerged, - } - default: - return LifecycleDecision{ - Status: domain.StatusWorking, - Evidence: fmt.Sprintf("non-terminal pr state=%s", pr), - SessionState: domain.SessionWorking, - SessionReason: domain.ReasonTaskInProgress, - PRState: pr, + Evidence: "dead runtime=down process=dead recentActivity=false", + SessionState: domain.SessionTerminated, + TerminationReason: domain.TermRuntimeLost, + IsAlive: false, } } + // Process indeterminate: cannot confirm death, so quarantine. + ev := fmt.Sprintf("runtime_lost runtime=down process=%s recentActivity=false", in.Process) + return detecting(in, ev) } // CreateDetectingDecision advances or escalates the anti-flap quarantine. @@ -189,9 +92,10 @@ func ResolveTerminalPRStateDecision(pr domain.PRState) LifecycleDecision { // The attempt counter climbs only while the (timestamp-stripped) evidence hash // is unchanged and resets the moment the evidence moves; StartedAt is preserved // across the whole detecting episode so the duration cap is a real wall-clock -// safety net even when the evidence keeps flapping. Escalation to stuck fires -// at DetectingMaxAttempts consecutive unchanged ticks OR DetectingMaxDuration -// elapsed since first entering detecting. +// safety net even when the evidence keeps flapping. Escalation to stuck fires at +// DetectingMaxAttempts consecutive unchanged ticks OR DetectingMaxDuration +// elapsed since first entering detecting. Detecting/stuck leave IsAlive true: +// the probe was ambiguous, so the session is not confirmed dead. func CreateDetectingDecision(in DetectingInput) LifecycleDecision { hash := HashEvidence(in.Evidence) @@ -207,19 +111,17 @@ func CreateDetectingDecision(in DetectingInput) LifecycleDecision { escalate := attempts >= DetectingMaxAttempts || !in.Now.Before(startedAt.Add(DetectingMaxDuration)) if escalate { return LifecycleDecision{ - Status: domain.StatusStuck, - Evidence: in.Evidence, - SessionState: domain.SessionStuck, - SessionReason: in.ProposedReason, + Evidence: in.Evidence, + SessionState: domain.SessionStuck, + IsAlive: true, } } return LifecycleDecision{ - Status: domain.StatusDetecting, - Evidence: in.Evidence, - Detecting: &domain.DetectingState{Attempts: attempts, StartedAt: startedAt, EvidenceHash: hash}, - SessionState: domain.SessionDetecting, - SessionReason: in.ProposedReason, + Evidence: in.Evidence, + Detecting: &domain.DetectingState{Attempts: attempts, StartedAt: startedAt, EvidenceHash: hash}, + SessionState: domain.SessionDetecting, + IsAlive: true, } } @@ -237,38 +139,20 @@ func HashEvidence(evidence string) string { } // timestampPatterns is the list of regexes HashEvidence applies (in order) to -// delete the time-varying parts of an evidence string before hashing, so the -// same ambiguous signal restamped with a new clock value hashes equal and the -// detecting counter keeps climbing instead of resetting every tick. -// -// Order matters: the full datetime form is removed first so its embedded -// HH:MM:SS isn't half-eaten by the bare time-of-day pattern that follows. -// -// 1. full ISO-8601 / RFC3339 datetime — date, a T or space separator, -// HH:MM:SS, optional fractional seconds, optional Z or ±HH:MM offset. -// e.g. "2026-05-26T12:00:00Z", "2026-05-26 12:00:00.218+05:30" -// 2. a bare time-of-day, e.g. "12:00:00" or "12:00:00.218" -// 3. a bare unix epoch — any 10-13 digit run (seconds or millis), e.g. -// "1716724800". This is broad enough to also clobber a same-width numeric -// ID if one ever appears in evidence; evidence is decider-authored, so keep -// IDs out of evidence strings to preserve hash fidelity. +// delete the time-varying parts of an evidence string before hashing. var timestampPatterns = []*regexp.Regexp{ regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?`), regexp.MustCompile(`\d{2}:\d{2}:\d{2}(?:\.\d+)?`), regexp.MustCompile(`\b\d{10,13}\b`), } -// detecting adapts a probe verdict into the shared anti-flap path. It packages -// the proposed reason + evidence (plus the prior counter from the same probe -// input) into a DetectingInput and defers to CreateDetectingDecision, so every +// detecting packages a probe verdict into the shared anti-flap path, so every // probe-driven ambiguity is counted and escalated by the identical quarantine // logic instead of each probe branch re-implementing the counter. -func detecting(in ProbeInput, reason domain.SessionReason, evidence string) LifecycleDecision { +func detecting(in ProbeInput, evidence string) LifecycleDecision { return CreateDetectingDecision(DetectingInput{ - Evidence: evidence, - ProposedState: domain.SessionDetecting, - ProposedReason: reason, - Prior: in.Prior, - Now: in.Now, + Evidence: evidence, + Prior: in.Prior, + Now: in.Now, }) } diff --git a/backend/internal/domain/decide/decide_test.go b/backend/internal/domain/decide/decide_test.go index 1a81595926..bc25af55ed 100644 --- a/backend/internal/domain/decide/decide_test.go +++ b/backend/internal/domain/decide/decide_test.go @@ -7,570 +7,158 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -var t0 = time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) +var t0 = time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) func TestResolveProbeDecision(t *testing.T) { tests := []struct { - name string - in ProbeInput - wantStatus domain.SessionStatus - wantState domain.SessionState - wantReason domain.SessionReason - wantDetect bool // expect non-nil Detecting memory - wantTermNil bool // expect terminal (Detecting must be nil) - }{ - { - name: "kill requested short-circuits to terminal killed", - in: ProbeInput{KillRequested: true, Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0}, - wantStatus: domain.StatusKilled, - wantState: domain.SessionTerminated, - wantReason: domain.ReasonManuallyKilled, - wantTermNil: true, - }, - { - name: "kill requested wins even over a dead+dead probe", - in: ProbeInput{KillRequested: true, Runtime: domain.RuntimeMissing, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusKilled, - wantState: domain.SessionTerminated, - wantReason: domain.ReasonManuallyKilled, - wantTermNil: true, - }, - { - name: "runtime probe failed routes to detecting, never death", - in: ProbeInput{Runtime: domain.RuntimeMissing, RuntimeFailed: true, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonProbeFailure, - wantDetect: true, - }, - { - name: "process probe failed routes to detecting", - in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessDead, ProcessFailed: true, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonProbeFailure, - wantDetect: true, - }, - { - name: "runtime state probe_failed routes to detecting", - in: ProbeInput{Runtime: domain.RuntimeProbeFailed, Process: ProcessIndeterminate, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonProbeFailure, - wantDetect: true, - }, - { - name: "runtime alive + process alive is working", - in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0}, - wantStatus: domain.StatusWorking, - wantState: domain.SessionWorking, - wantReason: domain.ReasonTaskInProgress, - }, - { - name: "runtime alive + process indeterminate leans alive", - in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessIndeterminate, Now: t0}, - wantStatus: domain.StatusWorking, - wantState: domain.SessionWorking, - wantReason: domain.ReasonTaskInProgress, - }, - { - name: "runtime alive + process dead disagree -> detecting (agent_process_exited)", - in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonAgentProcessExited, - wantDetect: true, - }, - { - name: "runtime dead + process alive disagree -> detecting (runtime_lost)", - in: ProbeInput{Runtime: domain.RuntimeExited, Process: ProcessAlive, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonRuntimeLost, - wantDetect: true, - }, - { - name: "runtime dead + recent activity disagree -> detecting (runtime_lost)", - in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessDead, RecentActivity: true, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonRuntimeLost, - wantDetect: true, - }, - { - name: "runtime dead + process indeterminate cannot confirm -> detecting", - in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonRuntimeLost, - wantDetect: true, - }, - { - name: "runtime exited + process dead + no activity -> killed terminal", - in: ProbeInput{Runtime: domain.RuntimeExited, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusKilled, - wantState: domain.SessionTerminated, - wantReason: domain.ReasonRuntimeLost, - wantTermNil: true, - }, - { - name: "runtime missing + process dead + no activity -> killed terminal", - in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusKilled, - wantState: domain.SessionTerminated, - wantReason: domain.ReasonRuntimeLost, - wantTermNil: true, - }, - { - name: "runtime unknown is ambiguous -> detecting (runtime_lost)", - in: ProbeInput{Runtime: domain.RuntimeUnknown, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonRuntimeLost, - wantDetect: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ResolveProbeDecision(tt.in) - if got.Status != tt.wantStatus { - t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus) - } - if got.SessionState != tt.wantState { - t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState) - } - if got.SessionReason != tt.wantReason { - t.Errorf("SessionReason = %q, want %q", got.SessionReason, tt.wantReason) - } - if tt.wantDetect && got.Detecting == nil { - t.Errorf("expected non-nil Detecting memory, got nil") - } - if tt.wantTermNil && got.Detecting != nil { - t.Errorf("terminal decision must carry nil Detecting, got %+v", got.Detecting) - } - }) - } -} - -func TestResolveOpenPRDecision(t *testing.T) { - tests := []struct { - name string - in OpenPRInput - wantStatus domain.SessionStatus - wantPR domain.PRReason - wantPRState domain.PRState - wantState domain.SessionState + name string + in ProbeInput + wantState domain.SessionState + wantReason domain.TerminationReason + wantAlive bool + wantDetect bool // expect a detecting verdict (first attempt -> SessionDetecting) }{ { - name: "ci failing dominates everything", - in: OpenPRInput{CIFailing: true, ChangesRequested: true, Approved: true, Mergeable: true}, - wantStatus: domain.StatusCIFailed, - wantPR: domain.PRReasonCIFailing, - wantState: domain.SessionWorking, - }, - { - name: "draft with failing CI maps to ci_failed", - in: OpenPRInput{Draft: true, CIFailing: true, ChangesRequested: true, Approved: true, Mergeable: true}, - wantStatus: domain.StatusCIFailed, - wantPR: domain.PRReasonCIFailing, - wantPRState: domain.PRDraft, - wantState: domain.SessionWorking, - }, - { - name: "draft ignores review and merge states", - in: OpenPRInput{Draft: true, ChangesRequested: true, Approved: true, Mergeable: true, ReviewPending: true, IdleBeyond: true}, - wantStatus: domain.StatusDraft, - wantPR: domain.PRReasonInProgress, - wantPRState: domain.PRDraft, - wantState: domain.SessionWorking, - }, - { - name: "changes requested before approval states", - in: OpenPRInput{ChangesRequested: true, Approved: true, Mergeable: true}, - wantStatus: domain.StatusChangesRequested, - wantPR: domain.PRReasonChangesRequested, - wantState: domain.SessionWorking, - }, - { - name: "bot comments get distinct PR reason", - in: OpenPRInput{BotComments: true, Approved: true, Mergeable: true}, - wantStatus: domain.StatusChangesRequested, - wantPR: domain.PRReasonBotComments, - wantState: domain.SessionWorking, - }, - { - name: "merge conflicts get distinct PR reason", - in: OpenPRInput{MergeConflicts: true, Approved: true}, - wantStatus: domain.StatusPROpen, - wantPR: domain.PRReasonMergeConflicts, - wantState: domain.SessionWorking, - }, - { - name: "approved + mergeable -> mergeable", - in: OpenPRInput{Approved: true, Mergeable: true}, - wantStatus: domain.StatusMergeable, - wantPR: domain.PRReasonMergeReady, - wantState: domain.SessionIdle, - }, - { - name: "mergeable without formal approval (no required review) -> mergeable", - in: OpenPRInput{Mergeable: true}, - wantStatus: domain.StatusMergeable, - wantPR: domain.PRReasonMergeReady, - wantState: domain.SessionIdle, - }, - { - name: "approved but not mergeable -> approved", - in: OpenPRInput{Approved: true}, - wantStatus: domain.StatusApproved, - wantPR: domain.PRReasonApproved, - wantState: domain.SessionIdle, - }, - { - name: "review pending", - in: OpenPRInput{ReviewPending: true}, - wantStatus: domain.StatusReviewPending, - wantPR: domain.PRReasonReviewPending, - wantState: domain.SessionIdle, + name: "kill requested -> terminated with reason", + in: ProbeInput{KillRequested: true, KillReason: domain.TermManuallyKilled, Now: t0}, + wantState: domain.SessionTerminated, wantReason: domain.TermManuallyKilled, wantAlive: false, }, { - name: "idle beyond threshold -> stuck", - in: OpenPRInput{IdleBeyond: true}, - wantStatus: domain.StatusStuck, - wantPR: domain.PRReasonInProgress, - wantState: domain.SessionStuck, + name: "kill requested without reason defaults to manually_killed", + in: ProbeInput{KillRequested: true, Now: t0}, + wantState: domain.SessionTerminated, wantReason: domain.TermManuallyKilled, wantAlive: false, }, { - name: "review pending wins over idle-beyond", - in: OpenPRInput{ReviewPending: true, IdleBeyond: true}, - wantStatus: domain.StatusReviewPending, - wantPR: domain.PRReasonReviewPending, - wantState: domain.SessionIdle, + name: "runtime probe failed -> detecting (not death)", + in: ProbeInput{RuntimeFailed: true, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, }, { - name: "nothing set -> plain open", - in: OpenPRInput{}, - wantStatus: domain.StatusPROpen, - wantPR: domain.PRReasonInProgress, - wantState: domain.SessionWorking, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ResolveOpenPRDecision(tt.in) - if got.Status != tt.wantStatus { - t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus) - } - if got.PRReason != tt.wantPR { - t.Errorf("PRReason = %q, want %q", got.PRReason, tt.wantPR) - } - wantPRState := tt.wantPRState - if wantPRState == "" { - wantPRState = domain.PROpen - } - if got.PRState != wantPRState { - t.Errorf("PRState = %q, want %q", got.PRState, wantPRState) - } - if got.SessionState != tt.wantState { - t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState) - } - }) - } -} - -func TestResolveOpenPRDecisionEvidence(t *testing.T) { - tests := []struct { - name string - in OpenPRInput - want string - }{ - { - name: "condition with PR number and URL", - in: OpenPRInput{CIFailing: true, Number: 123, URL: "https://example.com/pr/123"}, - want: "ci_failing #123 https://example.com/pr/123", + name: "process probe failed -> detecting", + in: ProbeInput{RuntimeAlive: true, ProcessFailed: true, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, }, { - name: "condition with number only", - in: OpenPRInput{Approved: true, Mergeable: true, Number: 7}, - want: "merge_ready #7", + name: "runtime alive + process alive -> working", + in: ProbeInput{RuntimeAlive: true, Process: ProcessAlive, Now: t0}, + wantState: domain.SessionWorking, wantAlive: true, }, { - name: "no identity falls back to the bare condition", - in: OpenPRInput{}, - want: "pr_open", + name: "runtime alive + process indeterminate -> working", + in: ProbeInput{RuntimeAlive: true, Process: ProcessIndeterminate, Now: t0}, + wantState: domain.SessionWorking, wantAlive: true, }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ResolveOpenPRDecision(tt.in).Evidence; got != tt.want { - t.Errorf("Evidence = %q, want %q", got, tt.want) - } - }) - } -} - -func TestDecidersDeriveConsistently(t *testing.T) { - // Every decision a decider produces must be self-consistent: the display - // Status it reports must equal what DeriveLegacyStatus produces from the - // canonical (session, pr) sub-states it emits. This locks the deciders and - // the display-derivation against drifting apart. - // - // The ResolveTerminalPRStateDecision none/open default is intentionally - // excluded — it is a documented no-op for misuse, not a real verdict. - var decisions []LifecycleDecision - - for _, in := range []OpenPRInput{ - {Draft: true, CIFailing: true}, - {Draft: true, ChangesRequested: true, Approved: true, Mergeable: true, ReviewPending: true, IdleBeyond: true}, - {CIFailing: true}, - {ChangesRequested: true}, - {BotComments: true}, - {MergeConflicts: true}, - {Approved: true, Mergeable: true}, - {Mergeable: true}, - {Approved: true}, - {ReviewPending: true}, - {IdleBeyond: true}, - {}, - } { - decisions = append(decisions, ResolveOpenPRDecision(in)) - } - - decisions = append(decisions, - ResolveTerminalPRStateDecision(domain.PRMerged), - ResolveTerminalPRStateDecision(domain.PRClosed), - ) - - for _, in := range []ProbeInput{ - {KillRequested: true, Now: t0}, - {Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0}, - {Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0}, - {Runtime: domain.RuntimeExited, Process: ProcessDead, Now: t0}, - } { - decisions = append(decisions, ResolveProbeDecision(in)) - } - - for _, d := range decisions { - l := domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: d.SessionState, Reason: d.SessionReason}, - PR: domain.PRSubstate{State: d.PRState, Reason: d.PRReason}, - } - if got := domain.DeriveLegacyStatus(l); got != d.Status { - t.Errorf("decision %+v: Status=%q but DeriveLegacyStatus=%q", d, d.Status, got) - } - } -} - -func TestResolveTerminalPRStateDecision(t *testing.T) { - tests := []struct { - name string - pr domain.PRState - wantStatus domain.SessionStatus - wantState domain.SessionState - wantReason domain.SessionReason - wantPR domain.PRReason - }{ { - name: "merged parks idle awaiting decision", - pr: domain.PRMerged, - wantStatus: domain.StatusMerged, - wantState: domain.SessionIdle, - wantReason: domain.ReasonMergedWaitingDecision, - wantPR: domain.PRReasonMerged, + name: "runtime alive + process dead -> detecting (disagree)", + in: ProbeInput{RuntimeAlive: true, Process: ProcessDead, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, }, { - name: "closed drops to idle", - pr: domain.PRClosed, - wantStatus: domain.StatusIdle, - wantState: domain.SessionIdle, - wantReason: domain.ReasonAwaitingUserInput, - wantPR: domain.PRReasonClosedUnmerged, + name: "runtime down + process dead + no activity -> terminated runtime_lost", + in: ProbeInput{RuntimeAlive: false, Process: ProcessDead, RecentActivity: false, Now: t0}, + wantState: domain.SessionTerminated, wantReason: domain.TermRuntimeLost, wantAlive: false, }, { - name: "non-terminal none is a working no-op", - pr: domain.PRNone, - wantStatus: domain.StatusWorking, - wantState: domain.SessionWorking, - wantReason: domain.ReasonTaskInProgress, + name: "runtime down + process alive -> detecting (disagree)", + in: ProbeInput{RuntimeAlive: false, Process: ProcessAlive, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, }, { - name: "non-terminal open is a working no-op", - pr: domain.PROpen, - wantStatus: domain.StatusWorking, - wantState: domain.SessionWorking, - wantReason: domain.ReasonTaskInProgress, + name: "runtime down + process dead + recent activity -> detecting", + in: ProbeInput{RuntimeAlive: false, Process: ProcessDead, RecentActivity: true, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, }, { - name: "non-terminal draft is a working no-op", - pr: domain.PRDraft, - wantStatus: domain.StatusWorking, - wantState: domain.SessionWorking, - wantReason: domain.ReasonTaskInProgress, + name: "runtime down + process indeterminate -> detecting", + in: ProbeInput{RuntimeAlive: false, Process: ProcessIndeterminate, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := ResolveTerminalPRStateDecision(tt.pr) - if got.Status != tt.wantStatus { - t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus) + d := ResolveProbeDecision(tt.in) + if d.SessionState != tt.wantState { + t.Errorf("state = %q, want %q", d.SessionState, tt.wantState) } - if got.SessionState != tt.wantState { - t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState) + if d.TerminationReason != tt.wantReason { + t.Errorf("reason = %q, want %q", d.TerminationReason, tt.wantReason) } - if got.SessionReason != tt.wantReason { - t.Errorf("SessionReason = %q, want %q", got.SessionReason, tt.wantReason) + if d.IsAlive != tt.wantAlive { + t.Errorf("isAlive = %v, want %v", d.IsAlive, tt.wantAlive) } - if tt.wantPR != "" && got.PRReason != tt.wantPR { - t.Errorf("PRReason = %q, want %q", got.PRReason, tt.wantPR) + if tt.wantDetect && d.Detecting == nil { + t.Errorf("expected detecting memory, got nil") } }) } } func TestCreateDetectingDecision(t *testing.T) { - const ev = "runtime_lost runtime=missing process=indeterminate" - hash := HashEvidence(ev) - - t.Run("first entry records attempt 1 and stays detecting", func(t *testing.T) { - got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Now: t0}) - if got.Status != domain.StatusDetecting || got.SessionState != domain.SessionDetecting { - t.Fatalf("want detecting, got Status=%q State=%q", got.Status, got.SessionState) - } - if got.Detecting == nil || got.Detecting.Attempts != 1 { - t.Fatalf("want attempts=1, got %+v", got.Detecting) - } - if !got.Detecting.StartedAt.Equal(t0) { - t.Errorf("StartedAt = %v, want %v", got.Detecting.StartedAt, t0) - } - if got.Detecting.EvidenceHash != hash { - t.Errorf("EvidenceHash = %q, want %q", got.Detecting.EvidenceHash, hash) - } - if got.SessionReason != domain.ReasonRuntimeLost { - t.Errorf("SessionReason = %q, want %q", got.SessionReason, domain.ReasonRuntimeLost) + t.Run("first entry sets attempts 1", func(t *testing.T) { + d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Now: t0}) + if d.SessionState != domain.SessionDetecting || d.Detecting == nil || d.Detecting.Attempts != 1 { + t.Fatalf("got %+v", d) } }) - - t.Run("unchanged evidence climbs the counter", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash} - got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)}) - if got.Detecting == nil || got.Detecting.Attempts != 2 { - t.Fatalf("want attempts=2, got %+v", got.Detecting) - } - if !got.Detecting.StartedAt.Equal(t0) { - t.Errorf("StartedAt must be preserved, got %v", got.Detecting.StartedAt) + t.Run("same evidence climbs the counter", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} + d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(time.Second)}) + if d.Detecting == nil || d.Detecting.Attempts != 2 { + t.Fatalf("attempts = %+v, want 2", d.Detecting) } }) - - t.Run("escalates to stuck on the third unchanged tick", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: hash} - got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)}) - if got.Status != domain.StatusStuck || got.SessionState != domain.SessionStuck { - t.Fatalf("want stuck, got Status=%q State=%q", got.Status, got.SessionState) - } - if got.Detecting != nil { - t.Errorf("stuck decision must drop detecting memory, got %+v", got.Detecting) - } - if got.SessionReason != domain.ReasonRuntimeLost { - t.Errorf("escalation should carry the why, got %q", got.SessionReason) - } - }) - - t.Run("changing evidence resets the counter but preserves StartedAt", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: hash} - got := CreateDetectingDecision(DetectingInput{Evidence: "different evidence", ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)}) - if got.Status != domain.StatusDetecting { - t.Fatalf("changed evidence should stay detecting, got %q", got.Status) - } - if got.Detecting == nil || got.Detecting.Attempts != 1 { - t.Fatalf("counter should reset to 1, got %+v", got.Detecting) - } - if !got.Detecting.StartedAt.Equal(t0) { - t.Errorf("StartedAt must survive an evidence change, got %v", got.Detecting.StartedAt) + t.Run("changed evidence resets the counter", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: 2, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} + d := CreateDetectingDecision(DetectingInput{Evidence: "process dead", Prior: prior, Now: t0.Add(time.Second)}) + if d.Detecting == nil || d.Detecting.Attempts != 1 { + t.Fatalf("attempts = %+v, want 1 (evidence changed)", d.Detecting) } }) - - t.Run("duration cap escalates even below the attempt count", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash} - got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(DetectingMaxDuration)}) - if got.Status != domain.StatusStuck { - t.Fatalf("want stuck from duration cap, got %q", got.Status) + t.Run("escalates to stuck at the attempt cap", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} + d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(time.Second)}) + if d.SessionState != domain.SessionStuck { + t.Fatalf("state = %q, want stuck", d.SessionState) } }) - - t.Run("duration cap fires even when evidence keeps flapping", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash} - got := CreateDetectingDecision(DetectingInput{Evidence: "ever-changing", ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(DetectingMaxDuration + time.Minute)}) - if got.Status != domain.StatusStuck { - t.Fatalf("duration cap must override a reset counter, got %q", got.Status) + t.Run("escalates to stuck past the duration cap", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} + d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(DetectingMaxDuration + time.Second)}) + if d.SessionState != domain.SessionStuck { + t.Fatalf("state = %q, want stuck (duration cap)", d.SessionState) } }) } func TestProbeDetectingEscalationFlow(t *testing.T) { - // An unchanging ambiguous probe should escalate to stuck after exactly - // DetectingMaxAttempts ticks. - in := ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0} - d := ResolveProbeDecision(in) + in := ProbeInput{RuntimeAlive: false, Process: ProcessIndeterminate, Now: t0} + var prior *domain.DetectingState for i := 1; i < DetectingMaxAttempts; i++ { - if d.Status != domain.StatusDetecting { - t.Fatalf("tick %d: expected detecting, got %q", i, d.Status) - } - in.Prior = d.Detecting + in.Prior = prior in.Now = t0.Add(time.Duration(i) * time.Second) - d = ResolveProbeDecision(in) + d := ResolveProbeDecision(in) + if d.SessionState != domain.SessionDetecting { + t.Fatalf("attempt %d: state = %q, want detecting", i, d.SessionState) + } + prior = d.Detecting } - if d.Status != domain.StatusStuck { - t.Fatalf("expected escalation to stuck after %d ticks, got %q", DetectingMaxAttempts, d.Status) + in.Prior = prior + in.Now = t0.Add(time.Hour) + if d := ResolveProbeDecision(in); d.SessionState != domain.SessionStuck { + t.Fatalf("final attempt: state = %q, want stuck", d.SessionState) } } func TestHashEvidence(t *testing.T) { - t.Run("identical strings hash identically", func(t *testing.T) { - if HashEvidence("same input") != HashEvidence("same input") { - t.Error("identical evidence must hash equal") - } - }) - - t.Run("different evidence hashes differently", func(t *testing.T) { - if HashEvidence("runtime_lost") == HashEvidence("agent_process_exited") { - t.Error("distinct evidence must hash differently") - } - }) - - t.Run("only the timestamp differs -> equal hash", func(t *testing.T) { - a := "probe failed at 2026-05-26T12:00:00Z runtime=missing" - b := "probe failed at 2026-05-26T12:05:43.218Z runtime=missing" - if HashEvidence(a) != HashEvidence(b) { - t.Errorf("restamped evidence should hash equal:\n a=%q\n b=%q", a, b) - } - }) - - t.Run("bare time-of-day stripped", func(t *testing.T) { - if HashEvidence("idle since 12:00:00") != HashEvidence("idle since 13:30:59") { - t.Error("time-of-day differences should be stripped") - } - }) - - t.Run("unix epoch stripped", func(t *testing.T) { - if HashEvidence("last seen 1716724800") != HashEvidence("last seen 1716728400") { - t.Error("epoch differences should be stripped") - } - }) - - t.Run("a real content change still changes the hash", func(t *testing.T) { - a := "probe at 2026-05-26T12:00:00Z runtime=missing" - b := "probe at 2026-05-26T12:00:00Z runtime=alive" - if HashEvidence(a) == HashEvidence(b) { - t.Error("non-timestamp content change must change the hash") - } - }) - - t.Run("whitespace differences are normalised", func(t *testing.T) { - if HashEvidence("runtime=missing process=dead") != HashEvidence("runtime=missing process=dead") { - t.Error("collapsed whitespace should hash equal") - } - }) + // timestamp-only differences hash equal; a real change differs. + a := HashEvidence("runtime down at 2026-05-31T12:00:00Z") + b := HashEvidence("runtime down at 2026-05-31T13:30:45Z") + if a != b { + t.Errorf("restamped evidence should hash equal") + } + c := HashEvidence("process dead at 2026-05-31T12:00:00Z") + if a == c { + t.Errorf("different evidence should hash differently") + } } diff --git a/backend/internal/domain/decide/types.go b/backend/internal/domain/decide/types.go index 1666fae740..832fab6fe5 100644 --- a/backend/internal/domain/decide/types.go +++ b/backend/internal/domain/decide/types.go @@ -6,39 +6,34 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// LifecycleDecision is the output of every decider: the derived display status -// plus the canonical sub-state values to persist, the human-readable evidence, -// and the (possibly updated) detecting memory. +// LifecycleDecision is the output of a decider: the canonical session sub-state +// to persist (state, the liveness bool, and — only for a terminal state — the +// termination reason), the human-readable evidence, and the (possibly updated) +// detecting memory. The display status is NOT here — it is derived on read by +// domain.DeriveStatus from the persisted lifecycle plus the pr table. // -// Zero-value sub-state fields mean "this decider does not address that -// sub-state — leave it unchanged", NOT "set it to the empty value". SessionState -// is always populated, but the probe/detecting/kill paths legitimately leave -// PRState/PRReason empty: a liveness verdict knows nothing about the PR. When -// the LCM folds a decision into the next full canonical row it must therefore -// leave empty PRState/PRReason unchanged rather than writing them through — -// writing PRNone on a routine probe tick would clobber a live PR. Detecting is -// nil-by-default; the LCM explicitly clears stale detecting memory when a probe -// verdict leaves detecting. +// PR facts are likewise not here: a liveness verdict knows nothing about the PR, +// and PR-driven display/reactions are handled off the pr table, not the session +// state machine. type LifecycleDecision struct { - Status domain.SessionStatus - Evidence string - Detecting *domain.DetectingState - SessionState domain.SessionState - SessionReason domain.SessionReason - PRState domain.PRState - PRReason domain.PRReason + Evidence string + Detecting *domain.DetectingState + SessionState domain.SessionState + TerminationReason domain.TerminationReason // set only when SessionState is terminated + IsAlive bool } -// ProbeInput reconciles runtime + process liveness. A *failed* probe (timeout -// or error) is distinct from a "dead" verdict and must route to detecting, -// never to a death conclusion. KillRequested short-circuits to terminal. +// ProbeInput reconciles runtime + process liveness. A *failed* probe (timeout or +// error) is distinct from a "dead" verdict and must route to detecting, never to +// a death conclusion. KillRequested short-circuits to terminal with KillReason. type ProbeInput struct { - Runtime domain.RuntimeState - RuntimeFailed bool + RuntimeAlive bool // the runtime probe reports the backing runtime is up + RuntimeFailed bool // the runtime probe itself failed (timeout/error) — not "dead" Process ProcessLiveness ProcessFailed bool RecentActivity bool KillRequested bool + KillReason domain.TerminationReason // the terminal reason when KillRequested Prior *domain.DetectingState Now time.Time } @@ -52,28 +47,11 @@ const ( ProcessIndeterminate ProcessLiveness = "indeterminate" ) -// OpenPRInput drives the PR pipeline ladder for an open or draft PR. -type OpenPRInput struct { - Draft bool - CIFailing bool - ChangesRequested bool - BotComments bool - MergeConflicts bool - Approved bool - Mergeable bool - ReviewPending bool - IdleBeyond bool // idle past the stuck threshold - Number int - URL string -} - -// DetectingInput feeds the quarantine counter. Evidence is hashed with +// DetectingInput feeds the anti-flap quarantine counter. Evidence is hashed with // timestamps stripped, so "same ambiguous signal" keeps the counter climbing // while any real change resets it. type DetectingInput struct { - Evidence string - ProposedState domain.SessionState - ProposedReason domain.SessionReason - Prior *domain.DetectingState - Now time.Time + Evidence string + Prior *domain.DetectingState + Now time.Time } diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go index fca87b6b84..b56367616a 100644 --- a/backend/internal/domain/lifecycle.go +++ b/backend/internal/domain/lifecycle.go @@ -11,30 +11,35 @@ import "time" // Greenfield: we start at 1 and carry no migration/synthesis code. const LifecycleVersion = 1 -// CanonicalSessionLifecycle is the ONLY thing persisted for a session's state. -// The display status is derived from it on read (see DeriveLegacyStatus) and is -// never stored — this prevents canonical truth and display from drifting. +// CanonicalSessionLifecycle is the ONLY lifecycle state persisted for a session. +// The display status is derived from it (plus the session's PR facts, which live +// in the separate pr table) on read — see DeriveStatus — and is never stored, so +// canonical truth and display cannot drift. // -// Three orthogonal (state, reason) sub-states describe the session, its PR, and -// its runtime. Activity and Detecting are decider *inputs* that must survive -// between observations (they are read back by the pure decide core), so they -// live in the persisted record too. +// PR facts are deliberately NOT here: a session can own several PRs over its +// life, and PR state is owned by the pr table. The runtime axis is collapsed to +// a single IsAlive boolean. Activity and Detecting are decider *inputs* that +// must survive between observations, so they live in the persisted record. type CanonicalSessionLifecycle struct { // Version is the Go-only schema-shape constant for this record. It is not // persisted and is not part of the CDC payload. Version int - // Revision is the per-write monotonic counter. The storage layer's Upsert - // bumps it when the full row is persisted; the LCM does not. - Revision int `json:"revision"` - Session SessionSubstate `json:"session"` - PR PRSubstate `json:"pr"` - Runtime RuntimeSubstate `json:"runtime"` - - // Activity is the last-known agent activity. It arrives on a different - // cadence (ApplyActivitySignal) than runtime probes (the reaper), so the - // probe decider reads it from here to answer "was there recent activity?". + + Session SessionSubstate `json:"session"` Activity ActivitySubstate `json:"activity"` + // TerminationReason is set only when Session.State is terminated; '' otherwise. + TerminationReason TerminationReason `json:"terminationReason,omitempty"` + + // IsAlive is the single liveness fact: is the runtime/process backing this + // session still up? It replaces the old runtime (state, reason) axis — the + // nuance the probe decider needs (failed-probe != dead, anti-flap) lives in + // the decide core's inputs, not in a persisted enum. + IsAlive bool `json:"isAlive"` + + // Harness is the agent harness the session runs (claude-code, codex, ...). + Harness AgentHarness `json:"harness,omitempty"` + // Detecting is the anti-flap quarantine memory. It is non-nil only while // the session is in the detecting state; it carries the attempt counter, // the first-entry time, and a hash of the (timestamp-stripped) evidence so @@ -42,6 +47,18 @@ type CanonicalSessionLifecycle struct { Detecting *DetectingState `json:"detecting,omitempty"` } +// ---- agent harness ---- + +// AgentHarness identifies which agent CLI/runtime a session drives. +type AgentHarness string + +const ( + HarnessClaudeCode AgentHarness = "claude-code" + HarnessCodex AgentHarness = "codex" + HarnessAider AgentHarness = "aider" + HarnessOpenCode AgentHarness = "opencode" +) + // ---- session sub-state ---- type SessionState string @@ -57,99 +74,76 @@ const ( SessionTerminated SessionState = "terminated" ) -type SessionReason string +// TerminationReason is the typed "why" for a terminated session — the only +// state that carries a reason. Empty for every non-terminal state. It decides +// the terminal display status (killed / cleanup / errored). The PR-pipeline +// "why" (fixing CI, awaiting review, …) is NOT here; it is derived on read from +// the pr table, not persisted on the session. +type TerminationReason string const ( - ReasonSpawnRequested SessionReason = "spawn_requested" - ReasonAgentAcknowledged SessionReason = "agent_acknowledged" - ReasonTaskInProgress SessionReason = "task_in_progress" - ReasonPRCreated SessionReason = "pr_created" - ReasonFixingCI SessionReason = "fixing_ci" - ReasonResolvingReviewComments SessionReason = "resolving_review_comments" - ReasonAwaitingUserInput SessionReason = "awaiting_user_input" - ReasonAwaitingExternalReview SessionReason = "awaiting_external_review" - ReasonResearchComplete SessionReason = "research_complete" - ReasonMergedWaitingDecision SessionReason = "merged_waiting_decision" - ReasonManuallyKilled SessionReason = "manually_killed" - ReasonPRMerged SessionReason = "pr_merged" - ReasonAutoCleanup SessionReason = "auto_cleanup" - ReasonRuntimeLost SessionReason = "runtime_lost" - ReasonAgentProcessExited SessionReason = "agent_process_exited" - ReasonProbeFailure SessionReason = "probe_failure" - ReasonErrorInProcess SessionReason = "error_in_process" + TermNone TerminationReason = "" + TermManuallyKilled TerminationReason = "manually_killed" + TermRuntimeLost TerminationReason = "runtime_lost" + TermAgentProcessExited TerminationReason = "agent_process_exited" + TermProbeFailure TerminationReason = "probe_failure" + TermErrorInProcess TerminationReason = "error_in_process" + TermAutoCleanup TerminationReason = "auto_cleanup" + TermPRMerged TerminationReason = "pr_merged" ) type SessionSubstate struct { - State SessionState `json:"state"` - Reason SessionReason `json:"reason"` + State SessionState `json:"state"` } -// ---- PR sub-state ---- - -type PRState string - -const ( - PRNone PRState = "none" - PRDraft PRState = "draft" - PROpen PRState = "open" - PRMerged PRState = "merged" - PRClosed PRState = "closed" -) +// ---- PR facts (NOT persisted on the session; sourced from the pr table) ---- + +// PRFacts is the per-session PR snapshot the status/reaction derivation reads +// from the pr table. It is the decider input that replaces the old persisted PR +// axis. The zero value (Exists=false) means "no PR", which derivation treats as +// "session has no PR". +type PRFacts struct { + URL string + Number int + Exists bool + Draft bool + Merged bool + Closed bool + CI CIState + Review ReviewDecision + Mergeability Mergeability + BotComments bool + IdleBeyond bool // idle past the stuck threshold +} -type PRReason string +type CIState string const ( - PRReasonNotCreated PRReason = "not_created" - PRReasonInProgress PRReason = "in_progress" - PRReasonCIFailing PRReason = "ci_failing" - PRReasonReviewPending PRReason = "review_pending" - PRReasonChangesRequested PRReason = "changes_requested" - PRReasonBotComments PRReason = "bot_comments" - PRReasonMergeConflicts PRReason = "merge_conflicts" - PRReasonApproved PRReason = "approved" - PRReasonMergeReady PRReason = "merge_ready" - PRReasonMerged PRReason = "merged" - PRReasonClosedUnmerged PRReason = "closed_unmerged" - PRReasonClearedOnRestore PRReason = "cleared_on_restore" + CIUnknown CIState = "unknown" + CIPending CIState = "pending" + CIPassing CIState = "passing" + CIFailing CIState = "failing" ) -type PRSubstate struct { - State PRState `json:"state"` - Reason PRReason `json:"reason"` - Number int `json:"number,omitempty"` - URL string `json:"url,omitempty"` -} - -// ---- runtime sub-state ---- - -type RuntimeState string +type ReviewDecision string const ( - RuntimeUnknown RuntimeState = "unknown" - RuntimeAlive RuntimeState = "alive" - RuntimeExited RuntimeState = "exited" - RuntimeMissing RuntimeState = "missing" - RuntimeProbeFailed RuntimeState = "probe_failed" + ReviewNone ReviewDecision = "none" + ReviewApproved ReviewDecision = "approved" + ReviewChangesRequest ReviewDecision = "changes_requested" + ReviewRequired ReviewDecision = "review_required" ) -type RuntimeReason string +type Mergeability string const ( - RuntimeReasonSpawnIncomplete RuntimeReason = "spawn_incomplete" - RuntimeReasonProcessRunning RuntimeReason = "process_running" - RuntimeReasonProcessMissing RuntimeReason = "process_missing" - RuntimeReasonTmuxMissing RuntimeReason = "tmux_missing" - RuntimeReasonManualKillRequested RuntimeReason = "manual_kill_requested" - RuntimeReasonPRMergedCleanup RuntimeReason = "pr_merged_cleanup" - RuntimeReasonAutoCleanup RuntimeReason = "auto_cleanup" - RuntimeReasonProbeError RuntimeReason = "probe_error" + MergeUnknown Mergeability = "unknown" + MergeMergeable Mergeability = "mergeable" + MergeConflicting Mergeability = "conflicting" + MergeBlocked Mergeability = "blocked" + MergeUnstable Mergeability = "unstable" ) -type RuntimeSubstate struct { - State RuntimeState `json:"state"` - Reason RuntimeReason `json:"reason"` -} - // ---- activity sub-state (decider input) ---- type ActivityState string diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index 1cc4404d39..0ff5c0fd83 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -1,7 +1,8 @@ package domain // SessionStatus is the single-word DISPLAY status the dashboard renders. It is -// derived from the canonical lifecycle on read and never persisted. +// derived from the canonical lifecycle (plus the session's PR facts) on read and +// never persisted. type SessionStatus string const ( @@ -26,27 +27,27 @@ const ( StatusTerminated SessionStatus = "terminated" ) -// DeriveLegacyStatus is the ONLY producer of the display status. It must stay a -// pure, total function of the canonical record. +// DeriveStatus is the ONLY producer of the display status. It is a pure, total +// function of the canonical record plus the session's PR facts (read from the pr +// table by the caller, since PR state is no longer persisted on the session). // // Order matters: // 1. Terminal / hard session states (done, terminated, needs_input, stuck, // detecting, not_started) map directly — these OUTRANK PR facts. -// 2. Otherwise a merged PR wins. -// 3. Otherwise a draft PR maps to draft, except CI failure still dominates. -// 4. Otherwise an open PR maps by its reason. -// 5. Otherwise fall through to the SOFT session state (idle/working). +// 2. Otherwise, if the session has a PR: a merged PR wins, else the PR pipeline +// ladder (CI failure dominates, then draft/review/merge states). +// 3. Otherwise fall through to the SOFT session state (idle/working). // // So "PR facts dominate session facts" applies only to the soft states: an idle // or working session with an open, CI-failing PR displays as ci_failed — but a -// session that is stuck or needs_input shows that regardless of PR state, since -// it needs a human either way. -func DeriveLegacyStatus(l CanonicalSessionLifecycle) SessionStatus { +// session that is stuck or needs_input shows that regardless, since it needs a +// human either way. +func DeriveStatus(l CanonicalSessionLifecycle, pr PRFacts) SessionStatus { switch l.Session.State { case SessionDone: return StatusDone case SessionTerminated: - return terminatedStatus(l.Session.Reason) + return terminatedStatus(l.TerminationReason) case SessionNeedsInput: return StatusNeedsInput case SessionStuck: @@ -57,16 +58,13 @@ func DeriveLegacyStatus(l CanonicalSessionLifecycle) SessionStatus { return StatusSpawning } - if l.PR.State == PRMerged { - return StatusMerged - } - - if l.PR.State == PRDraft { - return draftPRStatus(l.PR.Reason) - } - - if l.PR.State == PROpen { - return openPRStatus(l.PR.Reason) + if pr.Exists { + if pr.Merged { + return StatusMerged + } + if !pr.Closed { + return prPipelineStatus(pr) + } } if l.Session.State == SessionIdle { @@ -75,37 +73,35 @@ func DeriveLegacyStatus(l CanonicalSessionLifecycle) SessionStatus { return StatusWorking } -func terminatedStatus(r SessionReason) SessionStatus { +func terminatedStatus(r TerminationReason) SessionStatus { switch r { - case ReasonManuallyKilled, ReasonRuntimeLost, ReasonAgentProcessExited: + case TermManuallyKilled, TermRuntimeLost, TermAgentProcessExited: return StatusKilled - case ReasonAutoCleanup, ReasonPRMerged: + case TermAutoCleanup, TermPRMerged: return StatusCleanup - case ReasonErrorInProcess, ReasonProbeFailure: + case TermErrorInProcess, TermProbeFailure: return StatusErrored default: return StatusTerminated } } -func draftPRStatus(r PRReason) SessionStatus { - if r == PRReasonCIFailing { +// prPipelineStatus maps an open/draft PR's facts to a display status, preserving +// the old ladder: CI failure dominates everything, then draft, then the review / +// merge states. +func prPipelineStatus(pr PRFacts) SessionStatus { + switch { + case pr.CI == CIFailing: return StatusCIFailed - } - return StatusDraft -} - -func openPRStatus(r PRReason) SessionStatus { - switch r { - case PRReasonCIFailing: - return StatusCIFailed - case PRReasonChangesRequested, PRReasonBotComments: + case pr.Draft: + return StatusDraft + case pr.Review == ReviewChangesRequest || pr.BotComments: return StatusChangesRequested - case PRReasonApproved: - return StatusApproved - case PRReasonMergeReady: + case pr.Mergeability == MergeMergeable: return StatusMergeable - case PRReasonReviewPending: + case pr.Review == ReviewApproved: + return StatusApproved + case pr.Review == ReviewRequired: return StatusReviewPending default: return StatusPROpen diff --git a/backend/internal/domain/status_test.go b/backend/internal/domain/status_test.go index 0985499847..ae63271cf5 100644 --- a/backend/internal/domain/status_test.go +++ b/backend/internal/domain/status_test.go @@ -2,117 +2,58 @@ package domain import "testing" -func TestDeriveLegacyStatus(t *testing.T) { +func TestDeriveStatus(t *testing.T) { + // sess builds a non-terminal lifecycle (no reason). + sess := func(s SessionState) CanonicalSessionLifecycle { + return CanonicalSessionLifecycle{Session: SessionSubstate{State: s}} + } + // term builds a terminated lifecycle carrying a TerminationReason. + term := func(r TerminationReason) CanonicalSessionLifecycle { + return CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated}, TerminationReason: r} + } + openPR := func(mut func(*PRFacts)) PRFacts { + f := PRFacts{Exists: true, CI: CIUnknown, Review: ReviewNone, Mergeability: MergeUnknown} + if mut != nil { + mut(&f) + } + return f + } + tests := []struct { name string in CanonicalSessionLifecycle + pr PRFacts want SessionStatus }{ - { - name: "not_started maps to spawning", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionNotStarted, Reason: ReasonSpawnRequested}}, - want: StatusSpawning, - }, - { - name: "terminated+manually_killed maps to killed", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated, Reason: ReasonManuallyKilled}}, - want: StatusKilled, - }, - { - name: "terminated+auto_cleanup maps to cleanup", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated, Reason: ReasonAutoCleanup}}, - want: StatusCleanup, - }, - { - name: "terminated+error maps to errored", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated, Reason: ReasonErrorInProcess}}, - want: StatusErrored, - }, - { - name: "hard state needs_input maps directly", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionNeedsInput}}, - want: StatusNeedsInput, - }, - { - name: "merged PR dominates an idle session", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionIdle}, - PR: PRSubstate{State: PRMerged}, - }, - want: StatusMerged, - }, - { - name: "open PR with failing CI dominates idle session", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionIdle}, - PR: PRSubstate{State: PROpen, Reason: PRReasonCIFailing}, - }, - want: StatusCIFailed, - }, - { - name: "draft PR with failing CI maps to ci_failed", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PRDraft, Reason: PRReasonCIFailing}, - }, - want: StatusCIFailed, - }, - { - name: "draft PR ignores review and merge reasons", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PRDraft, Reason: PRReasonMergeReady}, - }, - want: StatusDraft, - }, - { - name: "open PR bot comments display as changes_requested", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PROpen, Reason: PRReasonBotComments}, - }, - want: StatusChangesRequested, - }, - { - name: "open PR merge conflicts display as plain open", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PROpen, Reason: PRReasonMergeConflicts}, - }, - want: StatusPROpen, - }, - { - name: "open PR approved", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PROpen, Reason: PRReasonApproved}, - }, - want: StatusApproved, - }, - { - name: "open PR merge_ready maps to mergeable", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PROpen, Reason: PRReasonMergeReady}, - }, - want: StatusMergeable, - }, - { - name: "no PR falls through to idle", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionIdle}}, - want: StatusIdle, - }, - { - name: "no PR falls through to working", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionWorking}}, - want: StatusWorking, - }, + {"not_started maps to spawning", sess(SessionNotStarted), PRFacts{}, StatusSpawning}, + {"terminated+manually_killed -> killed", term(TermManuallyKilled), PRFacts{}, StatusKilled}, + {"terminated+runtime_lost -> killed", term(TermRuntimeLost), PRFacts{}, StatusKilled}, + {"terminated+auto_cleanup -> cleanup", term(TermAutoCleanup), PRFacts{}, StatusCleanup}, + {"terminated+pr_merged -> cleanup", term(TermPRMerged), PRFacts{}, StatusCleanup}, + {"terminated+error -> errored", term(TermErrorInProcess), PRFacts{}, StatusErrored}, + {"needs_input maps directly", sess(SessionNeedsInput), PRFacts{}, StatusNeedsInput}, + {"stuck dominates any PR", sess(SessionStuck), openPR(func(f *PRFacts) { f.CI = CIFailing }), StatusStuck}, + + {"no PR + idle -> idle", sess(SessionIdle), PRFacts{}, StatusIdle}, + {"no PR + working -> working", sess(SessionWorking), PRFacts{}, StatusWorking}, + + {"merged PR dominates idle session", sess(SessionIdle), PRFacts{Exists: true, Merged: true}, StatusMerged}, + {"open PR failing CI -> ci_failed", sess(SessionIdle), openPR(func(f *PRFacts) { f.CI = CIFailing }), StatusCIFailed}, + {"draft PR failing CI -> ci_failed (CI dominates)", sess(SessionWorking), openPR(func(f *PRFacts) { f.Draft = true; f.CI = CIFailing }), StatusCIFailed}, + {"draft PR ignores review state -> draft", sess(SessionWorking), openPR(func(f *PRFacts) { f.Draft = true; f.Review = ReviewApproved }), StatusDraft}, + {"open PR changes_requested", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewChangesRequest }), StatusChangesRequested}, + {"open PR bot comments -> changes_requested", sess(SessionWorking), openPR(func(f *PRFacts) { f.BotComments = true }), StatusChangesRequested}, + {"open PR mergeable", sess(SessionWorking), openPR(func(f *PRFacts) { f.Mergeability = MergeMergeable }), StatusMergeable}, + {"open PR approved", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewApproved }), StatusApproved}, + {"open PR review required -> review_pending", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewRequired }), StatusReviewPending}, + {"open PR no signal -> pr_open", sess(SessionWorking), openPR(nil), StatusPROpen}, + {"closed PR falls through to soft state", sess(SessionIdle), PRFacts{Exists: true, Closed: true}, StatusIdle}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := DeriveLegacyStatus(tt.in); got != tt.want { - t.Errorf("DeriveLegacyStatus() = %q, want %q", got, tt.want) + if got := DeriveStatus(tt.in, tt.pr); got != tt.want { + t.Errorf("DeriveStatus() = %q, want %q", got, tt.want) } }) } diff --git a/backend/internal/storage/sqlite/cdc_store.go b/backend/internal/storage/sqlite/cdc_store.go deleted file mode 100644 index 8f92eda735..0000000000 --- a/backend/internal/storage/sqlite/cdc_store.go +++ /dev/null @@ -1,112 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// OutboxEvent is a single undelivered change, joined from outbox + change_log. -// It is the unit the CDC publisher drains to JSONL. -type OutboxEvent struct { - OutboxID int64 - Seq int64 - SessionID string - EventType string - Revision int64 - Payload string - CreatedAt time.Time -} - -// ListUnsent returns up to limit undelivered events in seq order. -func (s *Store) ListUnsent(ctx context.Context, limit int) ([]OutboxEvent, error) { - rows, err := s.q.ListUnsentOutbox(ctx, int64(limit)) - if err != nil { - return nil, fmt.Errorf("list unsent outbox: %w", err) - } - out := make([]OutboxEvent, 0, len(rows)) - for _, r := range rows { - out = append(out, OutboxEvent{ - OutboxID: r.ID, - Seq: r.ChangeLogSeq, - SessionID: r.SessionID, - EventType: r.EventType, - Revision: r.Revision, - Payload: r.Payload, - CreatedAt: r.CreatedAt, - }) - } - return out, nil -} - -// MarkSent flags an outbox row delivered. -func (s *Store) MarkSent(ctx context.Context, outboxID int64, at time.Time) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.MarkOutboxSent(ctx, gen.MarkOutboxSentParams{ - SentAt: sql.NullTime{Time: at, Valid: true}, - ID: outboxID, - }) -} - -// MarkFailed bumps the attempt count and records the last error for an outbox row. -func (s *Store) MarkFailed(ctx context.Context, outboxID int64, errMsg string) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.MarkOutboxFailed(ctx, gen.MarkOutboxFailedParams{LastError: errMsg, ID: outboxID}) -} - -// GetOffset returns a consumer's last acknowledged seq (0 if it has none). -func (s *Store) GetOffset(ctx context.Context, consumer string) (int64, error) { - seq, err := s.q.GetConsumerOffset(ctx, consumer) - if errors.Is(err, sql.ErrNoRows) { - return 0, nil - } - if err != nil { - return 0, fmt.Errorf("get consumer offset %s: %w", consumer, err) - } - return seq, nil -} - -// SetOffset durably records a consumer's acknowledged seq. -func (s *Store) SetOffset(ctx context.Context, consumer string, seq int64, at time.Time) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.UpsertConsumerOffset(ctx, gen.UpsertConsumerOffsetParams{ - Consumer: consumer, - LastSeq: seq, - UpdatedAt: at, - }) -} - -// MaxChangeLogSeq returns the highest change_log seq (0 if empty). Used by the -// consumer to resume after a snapshot resync. -func (s *Store) MaxChangeLogSeq(ctx context.Context) (int64, error) { - v, err := s.q.MaxChangeLogSeq(ctx) - if err != nil { - return 0, fmt.Errorf("max change_log seq: %w", err) - } - return v, nil -} - -// MinConsumerOffset returns the lowest acknowledged seq across all consumers -// (0 if none). The janitor uses it as the safe outbox-deletion watermark. -func (s *Store) MinConsumerOffset(ctx context.Context) (int64, error) { - v, err := s.q.MinConsumerOffset(ctx) - if err != nil { - return 0, fmt.Errorf("min consumer offset: %w", err) - } - return v, nil -} - -// DeleteSentOutboxBelow removes delivered outbox rows whose seq is below the -// watermark, returning the number removed. -func (s *Store) DeleteSentOutboxBelow(ctx context.Context, seq int64) (int64, error) { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.DeleteSentOutboxBelow(ctx, seq) -} diff --git a/backend/internal/storage/sqlite/changelog_store.go b/backend/internal/storage/sqlite/changelog_store.go new file mode 100644 index 0000000000..927d796865 --- /dev/null +++ b/backend/internal/storage/sqlite/changelog_store.go @@ -0,0 +1,89 @@ +package sqlite + +import ( + "context" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// ChangeLogRow is one durable CDC event. These rows are written by the DB +// triggers (migration 0001), never by application code; the store only reads +// them, for the CDC poller. +type ChangeLogRow struct { + Seq int64 + ProjectID string + SessionID string // empty when the event is project-level (NULL in the DB) + EventType string + Payload string + CreatedAt time.Time +} + +// ReadChangeLogAfter returns up to limit events with seq > after, in seq order +// — the CDC poller's read. The frontend's offset is `after`. +func (s *Store) ReadChangeLogAfter(ctx context.Context, after int64, limit int) ([]ChangeLogRow, error) { + rows, err := s.qr.ReadChangeLogAfter(ctx, gen.ReadChangeLogAfterParams{Seq: after, Limit: int64(limit)}) + if err != nil { + return nil, fmt.Errorf("read change_log after %d: %w", after, err) + } + out := make([]ChangeLogRow, 0, len(rows)) + for _, r := range rows { + out = append(out, changeLogRowFromGen(r)) + } + return out, nil +} + +// ReadChangeLogAfterForProject is the project-scoped variant — a client +// subscribed to one project reads only its events. +func (s *Store) ReadChangeLogAfterForProject(ctx context.Context, project string, after int64, limit int) ([]ChangeLogRow, error) { + rows, err := s.qr.ReadChangeLogAfterForProject(ctx, gen.ReadChangeLogAfterForProjectParams{ + ProjectID: project, Seq: after, Limit: int64(limit), + }) + if err != nil { + return nil, fmt.Errorf("read change_log for %s after %d: %w", project, after, err) + } + out := make([]ChangeLogRow, 0, len(rows)) + for _, r := range rows { + out = append(out, changeLogRowFromGen(r)) + } + return out, nil +} + +// MaxChangeLogSeq returns the highest seq (0 if empty) — a fresh consumer's +// starting offset. +func (s *Store) MaxChangeLogSeq(ctx context.Context) (int64, error) { + v, err := s.qr.MaxChangeLogSeq(ctx) + if err != nil { + return 0, fmt.Errorf("max change_log seq: %w", err) + } + return asInt64(v), nil +} + +func changeLogRowFromGen(r gen.ChangeLog) ChangeLogRow { + row := ChangeLogRow{ + Seq: r.Seq, + ProjectID: r.ProjectID, + EventType: r.EventType, + Payload: r.Payload, + CreatedAt: r.CreatedAt, + } + if r.SessionID.Valid { + row.SessionID = r.SessionID.String + } + return row +} + +// asInt64 coerces sqlc's interface{} result for COALESCE(MAX(...)) — sqlc's +// SQLite type inference can't narrow the aggregate, so the generated signature +// is interface{}. modernc returns int64 for an integer aggregate. +func asInt64(v interface{}) int64 { + switch n := v.(type) { + case int64: + return n + case int: + return int64(n) + default: + return 0 + } +} diff --git a/backend/internal/storage/sqlite/db.go b/backend/internal/storage/sqlite/db.go index 0a2555e4e6..63f6b7dd5f 100644 --- a/backend/internal/storage/sqlite/db.go +++ b/backend/internal/storage/sqlite/db.go @@ -1,7 +1,6 @@ -// Package sqlite is the durable persistence adapter behind ports.LifecycleStore. -// It owns the SQLite schema (goose migrations), the revision-CAS upsert, and the -// transactional outbox (one txn writes the session row, a change_log entry, and -// the outbox row that the CDC publisher later drains to JSONL). +// Package sqlite is the durable persistence adapter: the 6-table schema (goose +// migrations), typed CRUD over sqlc-generated queries, and the read side of the +// trigger-driven CDC (it reads change_log; the DB triggers write it). package sqlite import ( @@ -20,40 +19,52 @@ var migrationsFS embed.FS // pragmas are applied on every connection open. WAL + NORMAL lets readers run // concurrently with the writer; busy_timeout absorbs brief writer contention; -// foreign_keys enforces the cascades. +// foreign_keys enforces the cascades and the CDC triggers' lookups. const pragmas = "?_pragma=journal_mode(WAL)" + "&_pragma=busy_timeout(5000)" + "&_pragma=foreign_keys(ON)" + "&_pragma=synchronous(NORMAL)" -// maxConnections caps the pool. WAL allows many concurrent readers, so reads -// (List/Get/GetPR/...) scale across the pool instead of queuing behind a single -// connection. Writes do NOT rely on the pool for serialization — the Store funnels -// every write through its writeMu (see store.go), which keeps WAL's single-writer -// rule and the revision-CAS read-then-write atomic regardless of pool size. -const maxConnections = 8 +// maxReaders caps the reader pool. WAL allows many concurrent readers. +const maxReaders = 8 -// Open opens (creating if absent) the SQLite database under dataDir, applies the -// connection pragmas, and runs all goose migrations up. The returned *sql.DB is -// sized for the many-reader / serialized-single-writer workload the LCM and -// readers impose. -func Open(dataDir string) (*sql.DB, error) { +// Open opens (creating if absent) the SQLite database under dataDir and returns +// a Store. It uses TWO pools against the same file: +// +// - a single WRITER connection (writeDB, MaxOpenConns=1): every write goes +// here, so a write and the CDC triggers' subqueries it fires always see the +// prior writes on the same connection (read-your-writes). This is required +// because the pr/pr_checks triggers SELECT from sessions/pr to fill in the +// event's project_id; a pooled writer could land that read on a connection +// that hasn't caught up to the commit and read NULL. +// - a READER pool (readDB, MaxOpenConns=maxReaders): all reads scale across +// it; WAL readers see the latest committed snapshot. +func Open(dataDir string) (*Store, error) { if err := os.MkdirAll(dataDir, 0o755); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } dsn := "file:" + filepath.Join(dataDir, "ao.db") + pragmas - db, err := sql.Open("sqlite", dsn) + + writeDB, err := sql.Open("sqlite", dsn) if err != nil { - return nil, fmt.Errorf("open sqlite: %w", err) + return nil, fmt.Errorf("open sqlite writer: %w", err) } - db.SetMaxOpenConns(maxConnections) - db.SetMaxIdleConns(maxConnections) // keep reader conns warm; avoid open/close churn - - if err := migrate(db); err != nil { - db.Close() + writeDB.SetMaxOpenConns(1) + writeDB.SetMaxIdleConns(1) + if err := migrate(writeDB); err != nil { + writeDB.Close() return nil, err } - return db, nil + + readDB, err := sql.Open("sqlite", dsn) + if err != nil { + writeDB.Close() + return nil, fmt.Errorf("open sqlite reader: %w", err) + } + readDB.SetMaxOpenConns(maxReaders) + readDB.SetMaxIdleConns(maxReaders) + + return NewStore(writeDB, readDB), nil } func migrate(db *sql.DB) error { diff --git a/backend/internal/storage/sqlite/gen/cdc.sql.go b/backend/internal/storage/sqlite/gen/cdc.sql.go deleted file mode 100644 index c2eedc8c73..0000000000 --- a/backend/internal/storage/sqlite/gen/cdc.sql.go +++ /dev/null @@ -1,199 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: cdc.sql - -package gen - -import ( - "context" - "database/sql" - "time" -) - -const deleteSentOutboxBelow = `-- name: DeleteSentOutboxBelow :execrows -DELETE FROM outbox WHERE sent = 1 AND change_log_seq < ? -` - -func (q *Queries) DeleteSentOutboxBelow(ctx context.Context, changeLogSeq int64) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteSentOutboxBelow, changeLogSeq) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -const getConsumerOffset = `-- name: GetConsumerOffset :one -SELECT last_seq FROM consumer_offsets WHERE consumer = ? -` - -func (q *Queries) GetConsumerOffset(ctx context.Context, consumer string) (int64, error) { - row := q.db.QueryRowContext(ctx, getConsumerOffset, consumer) - var last_seq int64 - err := row.Scan(&last_seq) - return last_seq, err -} - -const insertChangeLog = `-- name: InsertChangeLog :one -INSERT INTO change_log (session_id, event_type, revision, payload, created_at) -VALUES (?, ?, ?, ?, ?) -RETURNING seq -` - -type InsertChangeLogParams struct { - SessionID string - EventType string - Revision int64 - Payload string - CreatedAt time.Time -} - -// Appends a canonical-write record and returns its monotonic seq so the same -// transaction can thread it into the outbox row. -func (q *Queries) InsertChangeLog(ctx context.Context, arg InsertChangeLogParams) (int64, error) { - row := q.db.QueryRowContext(ctx, insertChangeLog, - arg.SessionID, - arg.EventType, - arg.Revision, - arg.Payload, - arg.CreatedAt, - ) - var seq int64 - err := row.Scan(&seq) - return seq, err -} - -const insertOutbox = `-- name: InsertOutbox :exec -INSERT INTO outbox (change_log_seq, created_at) -VALUES (?, ?) -` - -type InsertOutboxParams struct { - ChangeLogSeq int64 - CreatedAt time.Time -} - -func (q *Queries) InsertOutbox(ctx context.Context, arg InsertOutboxParams) error { - _, err := q.db.ExecContext(ctx, insertOutbox, arg.ChangeLogSeq, arg.CreatedAt) - return err -} - -const listUnsentOutbox = `-- name: ListUnsentOutbox :many -SELECT o.id, o.change_log_seq, o.attempts, - c.session_id, c.event_type, c.revision, c.payload, c.created_at -FROM outbox o -JOIN change_log c ON c.seq = o.change_log_seq -WHERE o.sent = 0 -ORDER BY o.change_log_seq -LIMIT ? -` - -type ListUnsentOutboxRow struct { - ID int64 - ChangeLogSeq int64 - Attempts int64 - SessionID string - EventType string - Revision int64 - Payload string - CreatedAt time.Time -} - -func (q *Queries) ListUnsentOutbox(ctx context.Context, limit int64) ([]ListUnsentOutboxRow, error) { - rows, err := q.db.QueryContext(ctx, listUnsentOutbox, limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListUnsentOutboxRow{} - for rows.Next() { - var i ListUnsentOutboxRow - if err := rows.Scan( - &i.ID, - &i.ChangeLogSeq, - &i.Attempts, - &i.SessionID, - &i.EventType, - &i.Revision, - &i.Payload, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const markOutboxFailed = `-- name: MarkOutboxFailed :exec -UPDATE outbox SET attempts = attempts + 1, last_error = ? WHERE id = ? -` - -type MarkOutboxFailedParams struct { - LastError string - ID int64 -} - -func (q *Queries) MarkOutboxFailed(ctx context.Context, arg MarkOutboxFailedParams) error { - _, err := q.db.ExecContext(ctx, markOutboxFailed, arg.LastError, arg.ID) - return err -} - -const markOutboxSent = `-- name: MarkOutboxSent :exec -UPDATE outbox SET sent = 1, sent_at = ? WHERE id = ? -` - -type MarkOutboxSentParams struct { - SentAt sql.NullTime - ID int64 -} - -func (q *Queries) MarkOutboxSent(ctx context.Context, arg MarkOutboxSentParams) error { - _, err := q.db.ExecContext(ctx, markOutboxSent, arg.SentAt, arg.ID) - return err -} - -const maxChangeLogSeq = `-- name: MaxChangeLogSeq :one -SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) FROM change_log -` - -func (q *Queries) MaxChangeLogSeq(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, maxChangeLogSeq) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err -} - -const minConsumerOffset = `-- name: MinConsumerOffset :one -SELECT CAST(COALESCE(MIN(last_seq), 0) AS INTEGER) FROM consumer_offsets -` - -func (q *Queries) MinConsumerOffset(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, minConsumerOffset) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err -} - -const upsertConsumerOffset = `-- name: UpsertConsumerOffset :exec -INSERT INTO consumer_offsets (consumer, last_seq, updated_at) -VALUES (?, ?, ?) -ON CONFLICT (consumer) DO UPDATE SET last_seq = excluded.last_seq, updated_at = excluded.updated_at -` - -type UpsertConsumerOffsetParams struct { - Consumer string - LastSeq int64 - UpdatedAt time.Time -} - -func (q *Queries) UpsertConsumerOffset(ctx context.Context, arg UpsertConsumerOffsetParams) error { - _, err := q.db.ExecContext(ctx, upsertConsumerOffset, arg.Consumer, arg.LastSeq, arg.UpdatedAt) - return err -} diff --git a/backend/internal/storage/sqlite/gen/changelog.sql.go b/backend/internal/storage/sqlite/gen/changelog.sql.go new file mode 100644 index 0000000000..6568fdcc11 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/changelog.sql.go @@ -0,0 +1,102 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: changelog.sql + +package gen + +import ( + "context" +) + +const maxChangeLogSeq = `-- name: MaxChangeLogSeq :one +SELECT COALESCE(MAX(seq), 0) AS seq FROM change_log +` + +func (q *Queries) MaxChangeLogSeq(ctx context.Context) (interface{}, error) { + row := q.db.QueryRowContext(ctx, maxChangeLogSeq) + var seq interface{} + err := row.Scan(&seq) + return seq, err +} + +const readChangeLogAfter = `-- name: ReadChangeLogAfter :many +SELECT seq, project_id, session_id, event_type, payload, created_at +FROM change_log WHERE seq > ? ORDER BY seq LIMIT ? +` + +type ReadChangeLogAfterParams struct { + Seq int64 + Limit int64 +} + +func (q *Queries) ReadChangeLogAfter(ctx context.Context, arg ReadChangeLogAfterParams) ([]ChangeLog, error) { + rows, err := q.db.QueryContext(ctx, readChangeLogAfter, arg.Seq, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ChangeLog{} + for rows.Next() { + var i ChangeLog + if err := rows.Scan( + &i.Seq, + &i.ProjectID, + &i.SessionID, + &i.EventType, + &i.Payload, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const readChangeLogAfterForProject = `-- name: ReadChangeLogAfterForProject :many +SELECT seq, project_id, session_id, event_type, payload, created_at +FROM change_log WHERE project_id = ? AND seq > ? ORDER BY seq LIMIT ? +` + +type ReadChangeLogAfterForProjectParams struct { + ProjectID string + Seq int64 + Limit int64 +} + +func (q *Queries) ReadChangeLogAfterForProject(ctx context.Context, arg ReadChangeLogAfterForProjectParams) ([]ChangeLog, error) { + rows, err := q.db.QueryContext(ctx, readChangeLogAfterForProject, arg.ProjectID, arg.Seq, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ChangeLog{} + for rows.Next() { + var i ChangeLog + if err := rows.Scan( + &i.Seq, + &i.ProjectID, + &i.SessionID, + &i.EventType, + &i.Payload, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/backend/internal/storage/sqlite/gen/metadata.sql.go b/backend/internal/storage/sqlite/gen/metadata.sql.go deleted file mode 100644 index 2c0396f734..0000000000 --- a/backend/internal/storage/sqlite/gen/metadata.sql.go +++ /dev/null @@ -1,82 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: metadata.sql - -package gen - -import ( - "context" - "time" -) - -const getSessionMetadata = `-- name: GetSessionMetadata :one -SELECT branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt -FROM session_metadata -WHERE session_id = ? -` - -type GetSessionMetadataRow struct { - Branch string - WorkspacePath string - RuntimeHandleID string - RuntimeName string - AgentSessionID string - Prompt string -} - -func (q *Queries) GetSessionMetadata(ctx context.Context, sessionID string) (GetSessionMetadataRow, error) { - row := q.db.QueryRowContext(ctx, getSessionMetadata, sessionID) - var i GetSessionMetadataRow - err := row.Scan( - &i.Branch, - &i.WorkspacePath, - &i.RuntimeHandleID, - &i.RuntimeName, - &i.AgentSessionID, - &i.Prompt, - ) - return i, err -} - -const upsertSessionMetadata = `-- name: UpsertSessionMetadata :exec -INSERT INTO session_metadata ( - session_id, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id) DO UPDATE SET - branch = CASE WHEN excluded.branch <> '' THEN excluded.branch ELSE session_metadata.branch END, - workspace_path = CASE WHEN excluded.workspace_path <> '' THEN excluded.workspace_path ELSE session_metadata.workspace_path END, - runtime_handle_id = CASE WHEN excluded.runtime_handle_id <> '' THEN excluded.runtime_handle_id ELSE session_metadata.runtime_handle_id END, - runtime_name = CASE WHEN excluded.runtime_name <> '' THEN excluded.runtime_name ELSE session_metadata.runtime_name END, - agent_session_id = CASE WHEN excluded.agent_session_id <> '' THEN excluded.agent_session_id ELSE session_metadata.agent_session_id END, - prompt = CASE WHEN excluded.prompt <> '' THEN excluded.prompt ELSE session_metadata.prompt END, - updated_at = excluded.updated_at -` - -type UpsertSessionMetadataParams struct { - SessionID string - Branch string - WorkspacePath string - RuntimeHandleID string - RuntimeName string - AgentSessionID string - Prompt string - UpdatedAt time.Time -} - -// Merge semantics: an empty incoming column is "leave unchanged", so a partial -// patch (e.g. spawn writing only the runtime handle) never clobbers a value set -// earlier (e.g. the branch set at creation). Mirrors the old per-key map merge. -func (q *Queries) UpsertSessionMetadata(ctx context.Context, arg UpsertSessionMetadataParams) error { - _, err := q.db.ExecContext(ctx, upsertSessionMetadata, - arg.SessionID, - arg.Branch, - arg.WorkspacePath, - arg.RuntimeHandleID, - arg.RuntimeName, - arg.AgentSessionID, - arg.Prompt, - arg.UpdatedAt, - ) - return err -} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 339062bf06..0c5b5c913f 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -11,50 +11,36 @@ import ( type ChangeLog struct { Seq int64 - SessionID string + ProjectID string + SessionID sql.NullString EventType string - Revision int64 Payload string CreatedAt time.Time } -type ConsumerOffset struct { - Consumer string - LastSeq int64 - UpdatedAt time.Time -} - -type Outbox struct { - ID int64 - ChangeLogSeq int64 - Sent int64 - SentAt sql.NullTime - Attempts int64 - LastError string - CreatedAt time.Time -} - type Pr struct { + Url string SessionID string + Number int64 + PrState string ReviewDecision string - Mergeability string CiState string - CiPassed int64 - CiFailed int64 - CiPending int64 - CiLogTail string - LastFetchedAt time.Time + Mergeability string + UpdatedAt time.Time } type PrCheck struct { - SessionID string - Name string - Status string - Url string + PrUrl string + Name string + CommitHash string + Status string + Url string + LogTail string + CreatedAt time.Time } type PrComment struct { - SessionID string + PrUrl string CommentID string Author string File string @@ -67,58 +53,34 @@ type PrComment struct { type Project struct { ID string Path string - RepoOwner string - RepoName string - RepoPlatform string RepoOriginUrl string - DefaultBranch string DisplayName string - SessionPrefix string - Source string RegisteredAt time.Time ArchivedAt sql.NullTime } -type ReactionTracker struct { - SessionID string - ReactionKey string - Attempts int64 - Escalated int64 - FirstAttemptAt sql.NullTime - ProjectID string -} - type Session struct { ID string ProjectID string + Num int64 IssueID string Kind string - CreatedAt time.Time - UpdatedAt time.Time - Revision int64 + Harness string SessionState string - SessionReason string - PrState string - PrReason string - PrNumber int64 - PrUrl string - RuntimeState string - RuntimeReason string + TerminationReason string + IsAlive int64 ActivityState string ActivityLastAt time.Time ActivitySource string DetectingAttempts sql.NullInt64 DetectingStartedAt sql.NullTime DetectingEvidenceHash sql.NullString -} - -type SessionMetadatum struct { - SessionID string - Branch string - WorkspacePath string - RuntimeHandleID string - RuntimeName string - AgentSessionID string - Prompt string - UpdatedAt time.Time + Branch string + WorkspacePath string + RuntimeHandleID string + RuntimeName string + AgentSessionID string + Prompt string + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/backend/internal/storage/sqlite/gen/pr.sql.go b/backend/internal/storage/sqlite/gen/pr.sql.go index 95cbd20ae5..f9fa362029 100644 --- a/backend/internal/storage/sqlite/gen/pr.sql.go +++ b/backend/internal/storage/sqlite/gen/pr.sql.go @@ -11,173 +11,56 @@ import ( ) const deletePR = `-- name: DeletePR :exec -DELETE FROM pr WHERE session_id = ? +DELETE FROM pr WHERE url = ? ` -func (q *Queries) DeletePR(ctx context.Context, sessionID string) error { - _, err := q.db.ExecContext(ctx, deletePR, sessionID) - return err -} - -const deletePRChecks = `-- name: DeletePRChecks :exec -DELETE FROM pr_check WHERE session_id = ? -` - -func (q *Queries) DeletePRChecks(ctx context.Context, sessionID string) error { - _, err := q.db.ExecContext(ctx, deletePRChecks, sessionID) - return err -} - -const deletePRComments = `-- name: DeletePRComments :exec -DELETE FROM pr_comment WHERE session_id = ? -` - -func (q *Queries) DeletePRComments(ctx context.Context, sessionID string) error { - _, err := q.db.ExecContext(ctx, deletePRComments, sessionID) +func (q *Queries) DeletePR(ctx context.Context, url string) error { + _, err := q.db.ExecContext(ctx, deletePR, url) return err } const getPR = `-- name: GetPR :one -SELECT session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at -FROM pr -WHERE session_id = ? +SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at FROM pr WHERE url = ? ` -func (q *Queries) GetPR(ctx context.Context, sessionID string) (Pr, error) { - row := q.db.QueryRowContext(ctx, getPR, sessionID) +func (q *Queries) GetPR(ctx context.Context, url string) (Pr, error) { + row := q.db.QueryRowContext(ctx, getPR, url) var i Pr err := row.Scan( + &i.Url, &i.SessionID, + &i.Number, + &i.PrState, &i.ReviewDecision, - &i.Mergeability, &i.CiState, - &i.CiPassed, - &i.CiFailed, - &i.CiPending, - &i.CiLogTail, - &i.LastFetchedAt, + &i.Mergeability, + &i.UpdatedAt, ) return i, err } -const insertPRCheck = `-- name: InsertPRCheck :exec -INSERT INTO pr_check (session_id, name, status, url) VALUES (?, ?, ?, ?) -` - -type InsertPRCheckParams struct { - SessionID string - Name string - Status string - Url string -} - -func (q *Queries) InsertPRCheck(ctx context.Context, arg InsertPRCheckParams) error { - _, err := q.db.ExecContext(ctx, insertPRCheck, - arg.SessionID, - arg.Name, - arg.Status, - arg.Url, - ) - return err -} - -const insertPRComment = `-- name: InsertPRComment :exec -INSERT INTO pr_comment (session_id, comment_id, author, file, line, body, resolved, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) -` - -type InsertPRCommentParams struct { - SessionID string - CommentID string - Author string - File string - Line int64 - Body string - Resolved int64 - CreatedAt time.Time -} - -func (q *Queries) InsertPRComment(ctx context.Context, arg InsertPRCommentParams) error { - _, err := q.db.ExecContext(ctx, insertPRComment, - arg.SessionID, - arg.CommentID, - arg.Author, - arg.File, - arg.Line, - arg.Body, - arg.Resolved, - arg.CreatedAt, - ) - return err -} - -const listPRChecks = `-- name: ListPRChecks :many -SELECT name, status, url FROM pr_check WHERE session_id = ? ORDER BY name +const listPRsBySession = `-- name: ListPRsBySession :many +SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at FROM pr WHERE session_id = ? ORDER BY updated_at DESC ` -type ListPRChecksRow struct { - Name string - Status string - Url string -} - -func (q *Queries) ListPRChecks(ctx context.Context, sessionID string) ([]ListPRChecksRow, error) { - rows, err := q.db.QueryContext(ctx, listPRChecks, sessionID) +func (q *Queries) ListPRsBySession(ctx context.Context, sessionID string) ([]Pr, error) { + rows, err := q.db.QueryContext(ctx, listPRsBySession, sessionID) if err != nil { return nil, err } defer rows.Close() - items := []ListPRChecksRow{} + items := []Pr{} for rows.Next() { - var i ListPRChecksRow - if err := rows.Scan(&i.Name, &i.Status, &i.Url); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listPRComments = `-- name: ListPRComments :many -SELECT comment_id, author, file, line, body, resolved, created_at -FROM pr_comment -WHERE session_id = ? -ORDER BY created_at, comment_id -` - -type ListPRCommentsRow struct { - CommentID string - Author string - File string - Line int64 - Body string - Resolved int64 - CreatedAt time.Time -} - -func (q *Queries) ListPRComments(ctx context.Context, sessionID string) ([]ListPRCommentsRow, error) { - rows, err := q.db.QueryContext(ctx, listPRComments, sessionID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListPRCommentsRow{} - for rows.Next() { - var i ListPRCommentsRow + var i Pr if err := rows.Scan( - &i.CommentID, - &i.Author, - &i.File, - &i.Line, - &i.Body, - &i.Resolved, - &i.CreatedAt, + &i.Url, + &i.SessionID, + &i.Number, + &i.PrState, + &i.ReviewDecision, + &i.CiState, + &i.Mergeability, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -193,43 +76,39 @@ func (q *Queries) ListPRComments(ctx context.Context, sessionID string) ([]ListP } const upsertPR = `-- name: UpsertPR :exec -INSERT INTO pr ( - session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id) DO UPDATE SET +INSERT INTO pr (url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (url) DO UPDATE SET + session_id = excluded.session_id, + number = excluded.number, + pr_state = excluded.pr_state, review_decision = excluded.review_decision, - mergeability = excluded.mergeability, - ci_state = excluded.ci_state, - ci_passed = excluded.ci_passed, - ci_failed = excluded.ci_failed, - ci_pending = excluded.ci_pending, - ci_log_tail = excluded.ci_log_tail, - last_fetched_at = excluded.last_fetched_at + ci_state = excluded.ci_state, + mergeability = excluded.mergeability, + updated_at = excluded.updated_at ` type UpsertPRParams struct { + Url string SessionID string + Number int64 + PrState string ReviewDecision string - Mergeability string CiState string - CiPassed int64 - CiFailed int64 - CiPending int64 - CiLogTail string - LastFetchedAt time.Time + Mergeability string + UpdatedAt time.Time } func (q *Queries) UpsertPR(ctx context.Context, arg UpsertPRParams) error { _, err := q.db.ExecContext(ctx, upsertPR, + arg.Url, arg.SessionID, + arg.Number, + arg.PrState, arg.ReviewDecision, - arg.Mergeability, arg.CiState, - arg.CiPassed, - arg.CiFailed, - arg.CiPending, - arg.CiLogTail, - arg.LastFetchedAt, + arg.Mergeability, + arg.UpdatedAt, ) return err } diff --git a/backend/internal/storage/sqlite/gen/pr_checks.sql.go b/backend/internal/storage/sqlite/gen/pr_checks.sql.go new file mode 100644 index 0000000000..58668ab127 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/pr_checks.sql.go @@ -0,0 +1,119 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: pr_checks.sql + +package gen + +import ( + "context" + "time" +) + +const listChecksByPR = `-- name: ListChecksByPR :many +SELECT pr_url, name, commit_hash, status, url, log_tail, created_at FROM pr_checks WHERE pr_url = ? ORDER BY name, created_at +` + +func (q *Queries) ListChecksByPR(ctx context.Context, prUrl string) ([]PrCheck, error) { + rows, err := q.db.QueryContext(ctx, listChecksByPR, prUrl) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PrCheck{} + for rows.Next() { + var i PrCheck + if err := rows.Scan( + &i.PrUrl, + &i.Name, + &i.CommitHash, + &i.Status, + &i.Url, + &i.LogTail, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRecentChecks = `-- name: ListRecentChecks :many +SELECT status, commit_hash, created_at FROM pr_checks +WHERE pr_url = ? AND name = ? +ORDER BY created_at DESC LIMIT ? +` + +type ListRecentChecksParams struct { + PrUrl string + Name string + Limit int64 +} + +type ListRecentChecksRow struct { + Status string + CommitHash string + CreatedAt time.Time +} + +func (q *Queries) ListRecentChecks(ctx context.Context, arg ListRecentChecksParams) ([]ListRecentChecksRow, error) { + rows, err := q.db.QueryContext(ctx, listRecentChecks, arg.PrUrl, arg.Name, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListRecentChecksRow{} + for rows.Next() { + var i ListRecentChecksRow + if err := rows.Scan(&i.Status, &i.CommitHash, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertPRCheck = `-- name: UpsertPRCheck :exec +INSERT INTO pr_checks (pr_url, name, commit_hash, status, url, log_tail, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (pr_url, name, commit_hash) DO UPDATE SET + status = excluded.status, + url = excluded.url, + log_tail = excluded.log_tail +` + +type UpsertPRCheckParams struct { + PrUrl string + Name string + CommitHash string + Status string + Url string + LogTail string + CreatedAt time.Time +} + +func (q *Queries) UpsertPRCheck(ctx context.Context, arg UpsertPRCheckParams) error { + _, err := q.db.ExecContext(ctx, upsertPRCheck, + arg.PrUrl, + arg.Name, + arg.CommitHash, + arg.Status, + arg.Url, + arg.LogTail, + arg.CreatedAt, + ) + return err +} diff --git a/backend/internal/storage/sqlite/gen/pr_comment.sql.go b/backend/internal/storage/sqlite/gen/pr_comment.sql.go new file mode 100644 index 0000000000..a2f09f3443 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/pr_comment.sql.go @@ -0,0 +1,89 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: pr_comment.sql + +package gen + +import ( + "context" + "time" +) + +const deletePRComments = `-- name: DeletePRComments :exec +DELETE FROM pr_comment WHERE pr_url = ? +` + +func (q *Queries) DeletePRComments(ctx context.Context, prUrl string) error { + _, err := q.db.ExecContext(ctx, deletePRComments, prUrl) + return err +} + +const listPRComments = `-- name: ListPRComments :many +SELECT pr_url, comment_id, author, file, line, body, resolved, created_at FROM pr_comment WHERE pr_url = ? ORDER BY created_at, comment_id +` + +func (q *Queries) ListPRComments(ctx context.Context, prUrl string) ([]PrComment, error) { + rows, err := q.db.QueryContext(ctx, listPRComments, prUrl) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PrComment{} + for rows.Next() { + var i PrComment + if err := rows.Scan( + &i.PrUrl, + &i.CommentID, + &i.Author, + &i.File, + &i.Line, + &i.Body, + &i.Resolved, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertPRComment = `-- name: UpsertPRComment :exec +INSERT INTO pr_comment (pr_url, comment_id, author, file, line, body, resolved, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (pr_url, comment_id) DO UPDATE SET + author = excluded.author, file = excluded.file, line = excluded.line, + body = excluded.body, resolved = excluded.resolved +` + +type UpsertPRCommentParams struct { + PrUrl string + CommentID string + Author string + File string + Line int64 + Body string + Resolved int64 + CreatedAt time.Time +} + +func (q *Queries) UpsertPRComment(ctx context.Context, arg UpsertPRCommentParams) error { + _, err := q.db.ExecContext(ctx, upsertPRComment, + arg.PrUrl, + arg.CommentID, + arg.Author, + arg.File, + arg.Line, + arg.Body, + arg.Resolved, + arg.CreatedAt, + ) + return err +} diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index 33959b765a..a7c953cd3f 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -25,19 +25,9 @@ func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) return err } -const deleteProject = `-- name: DeleteProject :exec -DELETE FROM projects WHERE id = ? -` - -func (q *Queries) DeleteProject(ctx context.Context, id string) error { - _, err := q.db.ExecContext(ctx, deleteProject, id) - return err -} - const getProject = `-- name: GetProject :one -SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at -FROM projects -WHERE id = ? +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +FROM projects WHERE id = ? ` func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { @@ -46,14 +36,8 @@ func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { err := row.Scan( &i.ID, &i.Path, - &i.RepoOwner, - &i.RepoName, - &i.RepoPlatform, &i.RepoOriginUrl, - &i.DefaultBranch, &i.DisplayName, - &i.SessionPrefix, - &i.Source, &i.RegisteredAt, &i.ArchivedAt, ) @@ -61,10 +45,8 @@ func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { } const listProjects = `-- name: ListProjects :many -SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at -FROM projects -WHERE archived_at IS NULL -ORDER BY id +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +FROM projects WHERE archived_at IS NULL ORDER BY id ` func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { @@ -79,14 +61,8 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { if err := rows.Scan( &i.ID, &i.Path, - &i.RepoOwner, - &i.RepoName, - &i.RepoPlatform, &i.RepoOriginUrl, - &i.DefaultBranch, &i.DisplayName, - &i.SessionPrefix, - &i.Source, &i.RegisteredAt, &i.ArchivedAt, ); err != nil { @@ -104,33 +80,20 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { } const upsertProject = `-- name: UpsertProject :exec -INSERT INTO projects (id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at) +VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET path = excluded.path, - repo_owner = excluded.repo_owner, - repo_name = excluded.repo_name, - repo_platform = excluded.repo_platform, repo_origin_url = excluded.repo_origin_url, - default_branch = excluded.default_branch, display_name = excluded.display_name, - session_prefix = excluded.session_prefix, - source = excluded.source, - registered_at = excluded.registered_at, archived_at = excluded.archived_at ` type UpsertProjectParams struct { ID string Path string - RepoOwner string - RepoName string - RepoPlatform string RepoOriginUrl string - DefaultBranch string DisplayName string - SessionPrefix string - Source string RegisteredAt time.Time ArchivedAt sql.NullTime } @@ -139,14 +102,8 @@ func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) er _, err := q.db.ExecContext(ctx, upsertProject, arg.ID, arg.Path, - arg.RepoOwner, - arg.RepoName, - arg.RepoPlatform, arg.RepoOriginUrl, - arg.DefaultBranch, arg.DisplayName, - arg.SessionPrefix, - arg.Source, arg.RegisteredAt, arg.ArchivedAt, ) diff --git a/backend/internal/storage/sqlite/gen/querier.go b/backend/internal/storage/sqlite/gen/querier.go index 83aa0c7edb..365113b1e2 100644 --- a/backend/internal/storage/sqlite/gen/querier.go +++ b/backend/internal/storage/sqlite/gen/querier.go @@ -10,50 +10,29 @@ import ( type Querier interface { ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error - DeletePR(ctx context.Context, sessionID string) error - DeletePRChecks(ctx context.Context, sessionID string) error - DeletePRComments(ctx context.Context, sessionID string) error - DeleteProject(ctx context.Context, id string) error - DeleteReactionTracker(ctx context.Context, arg DeleteReactionTrackerParams) error - DeleteSentOutboxBelow(ctx context.Context, changeLogSeq int64) (int64, error) - DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error - GetConsumerOffset(ctx context.Context, consumer string) (int64, error) - GetPR(ctx context.Context, sessionID string) (Pr, error) + DeletePR(ctx context.Context, url string) error + DeletePRComments(ctx context.Context, prUrl string) error + DeleteSession(ctx context.Context, id string) error + GetPR(ctx context.Context, url string) (Pr, error) GetProject(ctx context.Context, id string) (Project, error) GetSession(ctx context.Context, id string) (Session, error) - GetSessionMetadata(ctx context.Context, sessionID string) (GetSessionMetadataRow, error) - GetSessionRevision(ctx context.Context, id string) (int64, error) - // Appends a canonical-write record and returns its monotonic seq so the same - // transaction can thread it into the outbox row. - InsertChangeLog(ctx context.Context, arg InsertChangeLogParams) (int64, error) - InsertOutbox(ctx context.Context, arg InsertOutboxParams) error - InsertPRCheck(ctx context.Context, arg InsertPRCheckParams) error - InsertPRComment(ctx context.Context, arg InsertPRCommentParams) error - // CAS insert: only succeeds for a brand-new id. Incoming revision must be 0; - // the row is persisted at revision 1. - InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) + InsertSession(ctx context.Context, arg InsertSessionParams) error ListAllSessions(ctx context.Context) ([]Session, error) - ListPRChecks(ctx context.Context, sessionID string) ([]ListPRChecksRow, error) - ListPRComments(ctx context.Context, sessionID string) ([]ListPRCommentsRow, error) + ListChecksByPR(ctx context.Context, prUrl string) ([]PrCheck, error) + ListPRComments(ctx context.Context, prUrl string) ([]PrComment, error) + ListPRsBySession(ctx context.Context, sessionID string) ([]Pr, error) ListProjects(ctx context.Context) ([]Project, error) - ListReactionTrackers(ctx context.Context) ([]ReactionTracker, error) + ListRecentChecks(ctx context.Context, arg ListRecentChecksParams) ([]ListRecentChecksRow, error) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) - ListUnsentOutbox(ctx context.Context, limit int64) ([]ListUnsentOutboxRow, error) - MarkOutboxFailed(ctx context.Context, arg MarkOutboxFailedParams) error - MarkOutboxSent(ctx context.Context, arg MarkOutboxSentParams) error - MaxChangeLogSeq(ctx context.Context) (int64, error) - MinConsumerOffset(ctx context.Context) (int64, error) - // CAS update: succeeds only when the stored revision equals the caller's loaded - // revision (@expected_revision). 0 rows affected => revision mismatch. - UpdateSessionCAS(ctx context.Context, arg UpdateSessionCASParams) (int64, error) - UpsertConsumerOffset(ctx context.Context, arg UpsertConsumerOffsetParams) error + MaxChangeLogSeq(ctx context.Context) (interface{}, error) + NextSessionNum(ctx context.Context, projectID string) (int64, error) + ReadChangeLogAfter(ctx context.Context, arg ReadChangeLogAfterParams) ([]ChangeLog, error) + ReadChangeLogAfterForProject(ctx context.Context, arg ReadChangeLogAfterForProjectParams) ([]ChangeLog, error) + UpdateSession(ctx context.Context, arg UpdateSessionParams) error UpsertPR(ctx context.Context, arg UpsertPRParams) error + UpsertPRCheck(ctx context.Context, arg UpsertPRCheckParams) error + UpsertPRComment(ctx context.Context, arg UpsertPRCommentParams) error UpsertProject(ctx context.Context, arg UpsertProjectParams) error - UpsertReactionTracker(ctx context.Context, arg UpsertReactionTrackerParams) error - // Merge semantics: an empty incoming column is "leave unchanged", so a partial - // patch (e.g. spawn writing only the runtime handle) never clobbers a value set - // earlier (e.g. the branch set at creation). Mirrors the old per-key map merge. - UpsertSessionMetadata(ctx context.Context, arg UpsertSessionMetadataParams) error } var _ Querier = (*Queries)(nil) diff --git a/backend/internal/storage/sqlite/gen/reactions.sql.go b/backend/internal/storage/sqlite/gen/reactions.sql.go deleted file mode 100644 index dc7b01c2a5..0000000000 --- a/backend/internal/storage/sqlite/gen/reactions.sql.go +++ /dev/null @@ -1,100 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: reactions.sql - -package gen - -import ( - "context" - "database/sql" -) - -const deleteReactionTracker = `-- name: DeleteReactionTracker :exec -DELETE FROM reaction_trackers WHERE session_id = ? AND reaction_key = ? -` - -type DeleteReactionTrackerParams struct { - SessionID string - ReactionKey string -} - -func (q *Queries) DeleteReactionTracker(ctx context.Context, arg DeleteReactionTrackerParams) error { - _, err := q.db.ExecContext(ctx, deleteReactionTracker, arg.SessionID, arg.ReactionKey) - return err -} - -const deleteSessionReactionTrackers = `-- name: DeleteSessionReactionTrackers :exec -DELETE FROM reaction_trackers WHERE session_id = ? -` - -func (q *Queries) DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error { - _, err := q.db.ExecContext(ctx, deleteSessionReactionTrackers, sessionID) - return err -} - -const listReactionTrackers = `-- name: ListReactionTrackers :many -SELECT session_id, reaction_key, attempts, escalated, first_attempt_at, project_id -FROM reaction_trackers -` - -func (q *Queries) ListReactionTrackers(ctx context.Context) ([]ReactionTracker, error) { - rows, err := q.db.QueryContext(ctx, listReactionTrackers) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ReactionTracker{} - for rows.Next() { - var i ReactionTracker - if err := rows.Scan( - &i.SessionID, - &i.ReactionKey, - &i.Attempts, - &i.Escalated, - &i.FirstAttemptAt, - &i.ProjectID, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const upsertReactionTracker = `-- name: UpsertReactionTracker :exec -INSERT INTO reaction_trackers (session_id, reaction_key, attempts, escalated, first_attempt_at, project_id) -VALUES (?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id, reaction_key) DO UPDATE SET - attempts = excluded.attempts, - escalated = excluded.escalated, - first_attempt_at = excluded.first_attempt_at, - project_id = excluded.project_id -` - -type UpsertReactionTrackerParams struct { - SessionID string - ReactionKey string - Attempts int64 - Escalated int64 - FirstAttemptAt sql.NullTime - ProjectID string -} - -func (q *Queries) UpsertReactionTracker(ctx context.Context, arg UpsertReactionTrackerParams) error { - _, err := q.db.ExecContext(ctx, upsertReactionTracker, - arg.SessionID, - arg.ReactionKey, - arg.Attempts, - arg.Escalated, - arg.FirstAttemptAt, - arg.ProjectID, - ) - return err -} diff --git a/backend/internal/storage/sqlite/gen/sessions.sql.go b/backend/internal/storage/sqlite/gen/sessions.sql.go index 00d97ad663..5365a22c4c 100644 --- a/backend/internal/storage/sqlite/gen/sessions.sql.go +++ b/backend/internal/storage/sqlite/gen/sessions.sql.go @@ -11,8 +11,17 @@ import ( "time" ) +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM sessions WHERE id = ? +` + +func (q *Queries) DeleteSession(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteSession, id) + return err +} + const getSession = `-- name: GetSession :one -SELECT id, project_id, issue_id, kind, created_at, updated_at, revision, session_state, session_reason, pr_state, pr_reason, pr_number, pr_url, runtime_state, runtime_reason, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash FROM sessions WHERE id = ? +SELECT id, project_id, num, issue_id, kind, harness, session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, created_at, updated_at FROM sessions WHERE id = ? ` func (q *Queries) GetSession(ctx context.Context, id string) (Session, error) { @@ -21,117 +30,99 @@ func (q *Queries) GetSession(ctx context.Context, id string) (Session, error) { err := row.Scan( &i.ID, &i.ProjectID, + &i.Num, &i.IssueID, &i.Kind, - &i.CreatedAt, - &i.UpdatedAt, - &i.Revision, + &i.Harness, &i.SessionState, - &i.SessionReason, - &i.PrState, - &i.PrReason, - &i.PrNumber, - &i.PrUrl, - &i.RuntimeState, - &i.RuntimeReason, + &i.TerminationReason, + &i.IsAlive, &i.ActivityState, &i.ActivityLastAt, &i.ActivitySource, &i.DetectingAttempts, &i.DetectingStartedAt, &i.DetectingEvidenceHash, + &i.Branch, + &i.WorkspacePath, + &i.RuntimeHandleID, + &i.RuntimeName, + &i.AgentSessionID, + &i.Prompt, + &i.CreatedAt, + &i.UpdatedAt, ) return i, err } -const getSessionRevision = `-- name: GetSessionRevision :one -SELECT revision FROM sessions WHERE id = ? -` - -func (q *Queries) GetSessionRevision(ctx context.Context, id string) (int64, error) { - row := q.db.QueryRowContext(ctx, getSessionRevision, id) - var revision int64 - err := row.Scan(&revision) - return revision, err -} - -const insertSession = `-- name: InsertSession :execrows +const insertSession = `-- name: InsertSession :exec INSERT INTO sessions ( - id, project_id, issue_id, kind, created_at, updated_at, - revision, - session_state, session_reason, - pr_state, pr_reason, pr_number, pr_url, - runtime_state, runtime_reason, + id, project_id, num, issue_id, kind, harness, + session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, - detecting_attempts, detecting_started_at, detecting_evidence_hash -) VALUES ( - ?, ?, ?, ?, ?, ?, - 1, - ?, ?, - ?, ?, ?, ?, - ?, ?, - ?, ?, ?, - ?, ?, ? -) -ON CONFLICT (id) DO NOTHING + detecting_attempts, detecting_started_at, detecting_evidence_hash, + branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, + created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertSessionParams struct { ID string ProjectID string + Num int64 IssueID string Kind string - CreatedAt time.Time - UpdatedAt time.Time + Harness string SessionState string - SessionReason string - PrState string - PrReason string - PrNumber int64 - PrUrl string - RuntimeState string - RuntimeReason string + TerminationReason string + IsAlive int64 ActivityState string ActivityLastAt time.Time ActivitySource string DetectingAttempts sql.NullInt64 DetectingStartedAt sql.NullTime DetectingEvidenceHash sql.NullString + Branch string + WorkspacePath string + RuntimeHandleID string + RuntimeName string + AgentSessionID string + Prompt string + CreatedAt time.Time + UpdatedAt time.Time } -// CAS insert: only succeeds for a brand-new id. Incoming revision must be 0; -// the row is persisted at revision 1. -func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) { - result, err := q.db.ExecContext(ctx, insertSession, +func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) error { + _, err := q.db.ExecContext(ctx, insertSession, arg.ID, arg.ProjectID, + arg.Num, arg.IssueID, arg.Kind, - arg.CreatedAt, - arg.UpdatedAt, + arg.Harness, arg.SessionState, - arg.SessionReason, - arg.PrState, - arg.PrReason, - arg.PrNumber, - arg.PrUrl, - arg.RuntimeState, - arg.RuntimeReason, + arg.TerminationReason, + arg.IsAlive, arg.ActivityState, arg.ActivityLastAt, arg.ActivitySource, arg.DetectingAttempts, arg.DetectingStartedAt, arg.DetectingEvidenceHash, + arg.Branch, + arg.WorkspacePath, + arg.RuntimeHandleID, + arg.RuntimeName, + arg.AgentSessionID, + arg.Prompt, + arg.CreatedAt, + arg.UpdatedAt, ) - if err != nil { - return 0, err - } - return result.RowsAffected() + return err } const listAllSessions = `-- name: ListAllSessions :many -SELECT id, project_id, issue_id, kind, created_at, updated_at, revision, session_state, session_reason, pr_state, pr_reason, pr_number, pr_url, runtime_state, runtime_reason, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash FROM sessions +SELECT id, project_id, num, issue_id, kind, harness, session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, created_at, updated_at FROM sessions ORDER BY project_id, num ` func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { @@ -146,25 +137,27 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { if err := rows.Scan( &i.ID, &i.ProjectID, + &i.Num, &i.IssueID, &i.Kind, - &i.CreatedAt, - &i.UpdatedAt, - &i.Revision, + &i.Harness, &i.SessionState, - &i.SessionReason, - &i.PrState, - &i.PrReason, - &i.PrNumber, - &i.PrUrl, - &i.RuntimeState, - &i.RuntimeReason, + &i.TerminationReason, + &i.IsAlive, &i.ActivityState, &i.ActivityLastAt, &i.ActivitySource, &i.DetectingAttempts, &i.DetectingStartedAt, &i.DetectingEvidenceHash, + &i.Branch, + &i.WorkspacePath, + &i.RuntimeHandleID, + &i.RuntimeName, + &i.AgentSessionID, + &i.Prompt, + &i.CreatedAt, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -180,7 +173,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { } const listSessionsByProject = `-- name: ListSessionsByProject :many -SELECT id, project_id, issue_id, kind, created_at, updated_at, revision, session_state, session_reason, pr_state, pr_reason, pr_number, pr_url, runtime_state, runtime_reason, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash FROM sessions WHERE project_id = ? +SELECT id, project_id, num, issue_id, kind, harness, session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, created_at, updated_at FROM sessions WHERE project_id = ? ORDER BY num ` func (q *Queries) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) { @@ -195,25 +188,27 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID string) ( if err := rows.Scan( &i.ID, &i.ProjectID, + &i.Num, &i.IssueID, &i.Kind, - &i.CreatedAt, - &i.UpdatedAt, - &i.Revision, + &i.Harness, &i.SessionState, - &i.SessionReason, - &i.PrState, - &i.PrReason, - &i.PrNumber, - &i.PrUrl, - &i.RuntimeState, - &i.RuntimeReason, + &i.TerminationReason, + &i.IsAlive, &i.ActivityState, &i.ActivityLastAt, &i.ActivitySource, &i.DetectingAttempts, &i.DetectingStartedAt, &i.DetectingEvidenceHash, + &i.Branch, + &i.WorkspacePath, + &i.RuntimeHandleID, + &i.RuntimeName, + &i.AgentSessionID, + &i.Prompt, + &i.CreatedAt, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -228,80 +223,73 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID string) ( return items, nil } -const updateSessionCAS = `-- name: UpdateSessionCAS :execrows +const nextSessionNum = `-- name: NextSessionNum :one +SELECT COALESCE(MAX(num), 0) + 1 AS next FROM sessions WHERE project_id = ? +` + +func (q *Queries) NextSessionNum(ctx context.Context, projectID string) (int64, error) { + row := q.db.QueryRowContext(ctx, nextSessionNum, projectID) + var next int64 + err := row.Scan(&next) + return next, err +} + +const updateSession = `-- name: UpdateSession :exec UPDATE sessions SET - project_id = ?, - issue_id = ?, - kind = ?, - updated_at = ?, - revision = revision + 1, - session_state = ?, - session_reason = ?, - pr_state = ?, - pr_reason = ?, - pr_number = ?, - pr_url = ?, - runtime_state = ?, - runtime_reason = ?, - activity_state = ?, - activity_last_at = ?, - activity_source = ?, - detecting_attempts = ?, - detecting_started_at = ?, - detecting_evidence_hash = ? -WHERE id = ? AND revision = ? + issue_id = ?, kind = ?, harness = ?, + session_state = ?, termination_reason = ?, is_alive = ?, + activity_state = ?, activity_last_at = ?, activity_source = ?, + detecting_attempts = ?, detecting_started_at = ?, detecting_evidence_hash = ?, + branch = ?, workspace_path = ?, runtime_handle_id = ?, runtime_name = ?, agent_session_id = ?, prompt = ?, + updated_at = ? +WHERE id = ? ` -type UpdateSessionCASParams struct { - ProjectID string +type UpdateSessionParams struct { IssueID string Kind string - UpdatedAt time.Time + Harness string SessionState string - SessionReason string - PrState string - PrReason string - PrNumber int64 - PrUrl string - RuntimeState string - RuntimeReason string + TerminationReason string + IsAlive int64 ActivityState string ActivityLastAt time.Time ActivitySource string DetectingAttempts sql.NullInt64 DetectingStartedAt sql.NullTime DetectingEvidenceHash sql.NullString + Branch string + WorkspacePath string + RuntimeHandleID string + RuntimeName string + AgentSessionID string + Prompt string + UpdatedAt time.Time ID string - Revision int64 } -// CAS update: succeeds only when the stored revision equals the caller's loaded -// revision (@expected_revision). 0 rows affected => revision mismatch. -func (q *Queries) UpdateSessionCAS(ctx context.Context, arg UpdateSessionCASParams) (int64, error) { - result, err := q.db.ExecContext(ctx, updateSessionCAS, - arg.ProjectID, +func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) error { + _, err := q.db.ExecContext(ctx, updateSession, arg.IssueID, arg.Kind, - arg.UpdatedAt, + arg.Harness, arg.SessionState, - arg.SessionReason, - arg.PrState, - arg.PrReason, - arg.PrNumber, - arg.PrUrl, - arg.RuntimeState, - arg.RuntimeReason, + arg.TerminationReason, + arg.IsAlive, arg.ActivityState, arg.ActivityLastAt, arg.ActivitySource, arg.DetectingAttempts, arg.DetectingStartedAt, arg.DetectingEvidenceHash, + arg.Branch, + arg.WorkspacePath, + arg.RuntimeHandleID, + arg.RuntimeName, + arg.AgentSessionID, + arg.Prompt, + arg.UpdatedAt, arg.ID, - arg.Revision, ) - if err != nil { - return 0, err - } - return result.RowsAffected() + return err } diff --git a/backend/internal/storage/sqlite/mapping.go b/backend/internal/storage/sqlite/mapping.go index 39ae212754..792854cf20 100644 --- a/backend/internal/storage/sqlite/mapping.go +++ b/backend/internal/storage/sqlite/mapping.go @@ -7,104 +7,100 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -// recordToInsert maps a domain record to the generated insert params. The -// revision column is fixed to 1 by the query itself (insert path), so it is not -// carried here. -func recordToInsert(rec domain.SessionRecord) gen.InsertSessionParams { - lc := rec.Lifecycle - da, ds, dh := detectingToNull(lc.Detecting) +func boolToInt(b bool) int64 { + if b { + return 1 + } + return 0 +} + +// rowToRecord maps a stored session row to a domain record. The folded-in +// operational columns become Metadata; the canonical lifecycle is reassembled +// from the typed columns. Display status is never reconstructed here. +func rowToRecord(row gen.Session) domain.SessionRecord { + return domain.SessionRecord{ + ID: domain.SessionID(row.ID), + ProjectID: domain.ProjectID(row.ProjectID), + IssueID: domain.IssueID(row.IssueID), + Kind: domain.SessionKind(row.Kind), + Lifecycle: domain.CanonicalSessionLifecycle{ + Version: domain.LifecycleVersion, + Harness: domain.AgentHarness(row.Harness), + IsAlive: row.IsAlive != 0, + Session: domain.SessionSubstate{State: domain.SessionState(row.SessionState)}, + TerminationReason: domain.TerminationReason(row.TerminationReason), + Activity: domain.ActivitySubstate{ + State: domain.ActivityState(row.ActivityState), + LastActivityAt: row.ActivityLastAt, + Source: domain.ActivitySource(row.ActivitySource), + }, + Detecting: nullToDetecting(row), + }, + Metadata: domain.SessionMetadata{ + Branch: row.Branch, + WorkspacePath: row.WorkspacePath, + RuntimeHandleID: row.RuntimeHandleID, + RuntimeName: row.RuntimeName, + AgentSessionID: row.AgentSessionID, + Prompt: row.Prompt, + }, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func recordToInsert(rec domain.SessionRecord, num int64) gen.InsertSessionParams { + da, ds, dh := detectingToNull(rec.Lifecycle.Detecting) return gen.InsertSessionParams{ ID: string(rec.ID), ProjectID: string(rec.ProjectID), + Num: num, IssueID: string(rec.IssueID), Kind: string(rec.Kind), - CreatedAt: rec.CreatedAt, - UpdatedAt: rec.UpdatedAt, - SessionState: string(lc.Session.State), - SessionReason: string(lc.Session.Reason), - PrState: string(lc.PR.State), - PrReason: string(lc.PR.Reason), - PrNumber: int64(lc.PR.Number), - PrUrl: lc.PR.URL, - RuntimeState: string(lc.Runtime.State), - RuntimeReason: string(lc.Runtime.Reason), - ActivityState: string(lc.Activity.State), - ActivityLastAt: lc.Activity.LastActivityAt, - ActivitySource: string(lc.Activity.Source), + Harness: string(rec.Lifecycle.Harness), + SessionState: string(rec.Lifecycle.Session.State), + TerminationReason: string(rec.Lifecycle.TerminationReason), + IsAlive: boolToInt(rec.Lifecycle.IsAlive), + ActivityState: string(rec.Lifecycle.Activity.State), + ActivityLastAt: rec.Lifecycle.Activity.LastActivityAt, + ActivitySource: string(rec.Lifecycle.Activity.Source), DetectingAttempts: da, DetectingStartedAt: ds, DetectingEvidenceHash: dh, + Branch: rec.Metadata.Branch, + WorkspacePath: rec.Metadata.WorkspacePath, + RuntimeHandleID: rec.Metadata.RuntimeHandleID, + RuntimeName: rec.Metadata.RuntimeName, + AgentSessionID: rec.Metadata.AgentSessionID, + Prompt: rec.Metadata.Prompt, + CreatedAt: rec.CreatedAt, + UpdatedAt: rec.UpdatedAt, } } -// recordToUpdate maps a domain record to the CAS update params. expectedRevision -// is the caller's loaded revision, used in the WHERE clause for the CAS check. -func recordToUpdate(rec domain.SessionRecord, expectedRevision int64) gen.UpdateSessionCASParams { - lc := rec.Lifecycle - da, ds, dh := detectingToNull(lc.Detecting) - return gen.UpdateSessionCASParams{ - ProjectID: string(rec.ProjectID), +func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams { + da, ds, dh := detectingToNull(rec.Lifecycle.Detecting) + return gen.UpdateSessionParams{ IssueID: string(rec.IssueID), Kind: string(rec.Kind), - UpdatedAt: rec.UpdatedAt, - SessionState: string(lc.Session.State), - SessionReason: string(lc.Session.Reason), - PrState: string(lc.PR.State), - PrReason: string(lc.PR.Reason), - PrNumber: int64(lc.PR.Number), - PrUrl: lc.PR.URL, - RuntimeState: string(lc.Runtime.State), - RuntimeReason: string(lc.Runtime.Reason), - ActivityState: string(lc.Activity.State), - ActivityLastAt: lc.Activity.LastActivityAt, - ActivitySource: string(lc.Activity.Source), + Harness: string(rec.Lifecycle.Harness), + SessionState: string(rec.Lifecycle.Session.State), + TerminationReason: string(rec.Lifecycle.TerminationReason), + IsAlive: boolToInt(rec.Lifecycle.IsAlive), + ActivityState: string(rec.Lifecycle.Activity.State), + ActivityLastAt: rec.Lifecycle.Activity.LastActivityAt, + ActivitySource: string(rec.Lifecycle.Activity.Source), DetectingAttempts: da, DetectingStartedAt: ds, DetectingEvidenceHash: dh, + Branch: rec.Metadata.Branch, + WorkspacePath: rec.Metadata.WorkspacePath, + RuntimeHandleID: rec.Metadata.RuntimeHandleID, + RuntimeName: rec.Metadata.RuntimeName, + AgentSessionID: rec.Metadata.AgentSessionID, + Prompt: rec.Metadata.Prompt, + UpdatedAt: rec.UpdatedAt, ID: string(rec.ID), - Revision: expectedRevision, - } -} - -// rowToRecord maps a stored session row back to a domain record. Metadata is -// deliberately left nil: it is a side-channel (session_metadata) read only by -// GetMetadata, never reconstructed here — mirroring the in-memory fakeStore. -func rowToRecord(row gen.Session) domain.SessionRecord { - return domain.SessionRecord{ - ID: domain.SessionID(row.ID), - ProjectID: domain.ProjectID(row.ProjectID), - IssueID: domain.IssueID(row.IssueID), - Kind: domain.SessionKind(row.Kind), - Lifecycle: rowToLifecycle(row), - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } -} - -func rowToLifecycle(row gen.Session) domain.CanonicalSessionLifecycle { - return domain.CanonicalSessionLifecycle{ - Version: domain.LifecycleVersion, - Revision: int(row.Revision), - Session: domain.SessionSubstate{ - State: domain.SessionState(row.SessionState), - Reason: domain.SessionReason(row.SessionReason), - }, - PR: domain.PRSubstate{ - State: domain.PRState(row.PrState), - Reason: domain.PRReason(row.PrReason), - Number: int(row.PrNumber), - URL: row.PrUrl, - }, - Runtime: domain.RuntimeSubstate{ - State: domain.RuntimeState(row.RuntimeState), - Reason: domain.RuntimeReason(row.RuntimeReason), - }, - Activity: domain.ActivitySubstate{ - State: domain.ActivityState(row.ActivityState), - LastActivityAt: row.ActivityLastAt, - Source: domain.ActivitySource(row.ActivitySource), - }, - Detecting: nullToDetecting(row), } } diff --git a/backend/internal/storage/sqlite/migrations/0001_init.sql b/backend/internal/storage/sqlite/migrations/0001_init.sql index 3822412569..6534816d7d 100644 --- a/backend/internal/storage/sqlite/migrations/0001_init.sql +++ b/backend/internal/storage/sqlite/migrations/0001_init.sql @@ -1,116 +1,213 @@ -- +goose Up -- +goose StatementBegin --- sessions holds identity + the canonical lifecycle as typed columns. The --- display status is NEVER stored (it is derived on read). Metadata is NOT here — --- it lives in session_metadata, written by a side-channel that bypasses CDC. -CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - issue_id TEXT NOT NULL DEFAULT '', - kind TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - - -- canonical lifecycle: revision is the optimistic-concurrency (CAS) counter, - -- bumped only by the storage layer's Upsert. - revision INTEGER NOT NULL, - - session_state TEXT NOT NULL, - session_reason TEXT NOT NULL, - - pr_state TEXT NOT NULL, - pr_reason TEXT NOT NULL, - pr_number INTEGER NOT NULL DEFAULT 0, - pr_url TEXT NOT NULL DEFAULT '', - - runtime_state TEXT NOT NULL, - runtime_reason TEXT NOT NULL, +-- projects is the durable registry of repos AO manages (the SQLite twin of the +-- YAML config). id is a short human/LLM-friendly slug (mer, ao) with a numeric +-- suffix on collision (ao, ao1, ao2). Soft-delete via archived_at keeps the row +-- so a session's project_id always resolves. +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL, + repo_origin_url TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + registered_at TIMESTAMP NOT NULL, + archived_at TIMESTAMP +); - activity_state TEXT NOT NULL, - activity_last_at TIMESTAMP NOT NULL, - activity_source TEXT NOT NULL, +-- sessions is the canonical record. id is "{project_id}-{num}" (e.g. mer-1) — a +-- single string key, so every inbound FK is single-column. num is the per-project +-- counter (computed at insert under the write mutex). Operational metadata is +-- folded in (no separate table). is_alive replaces the old runtime axis; there is +-- no revision column — the per-session write mutex serializes and change_log.seq +-- orders. The display status is derived on read (from this + the pr row), never +-- stored. +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects (id), + num INTEGER NOT NULL, + issue_id TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT 'worker', + harness TEXT NOT NULL DEFAULT '' + CHECK (harness IN ('', 'claude-code', 'codex', 'aider', 'opencode')), + + session_state TEXT NOT NULL + CHECK (session_state IN ('not_started', 'working', 'idle', 'needs_input', 'stuck', 'detecting', 'done', 'terminated')), + -- only terminal sessions carry a reason; '' otherwise. + termination_reason TEXT NOT NULL DEFAULT '' + CHECK (termination_reason IN ('', 'manually_killed', 'runtime_lost', 'agent_process_exited', 'probe_failure', 'error_in_process', 'auto_cleanup', 'pr_merged')), + is_alive INTEGER NOT NULL DEFAULT 0, + + activity_state TEXT NOT NULL DEFAULT 'idle', + activity_last_at TIMESTAMP NOT NULL, + activity_source TEXT NOT NULL DEFAULT 'none', -- detecting quarantine memory; NULL when the session is not in detecting. - detecting_attempts INTEGER, - detecting_started_at TIMESTAMP, - detecting_evidence_hash TEXT + detecting_attempts INTEGER, + detecting_started_at TIMESTAMP, + detecting_evidence_hash TEXT, + + -- folded-in operational handles (was the session_metadata table) + branch TEXT NOT NULL DEFAULT '', + workspace_path TEXT NOT NULL DEFAULT '', + runtime_handle_id TEXT NOT NULL DEFAULT '', + runtime_name TEXT NOT NULL DEFAULT '', + agent_session_id TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + UNIQUE (project_id, num) ); - CREATE INDEX idx_sessions_project ON sessions (project_id); --- session_metadata is the 1:1 typed side-channel for a session's operational --- handles and seed inputs — the fields the Session Manager and reaper need but --- that are NOT part of the canonical lifecycle. One row per session, named --- columns (not a free-form key/value bag), so the set of metadata a session can --- carry is fixed by the schema. Written by PatchMetadata; never bumps revision --- and never emits a CDC event. -CREATE TABLE session_metadata ( - session_id TEXT PRIMARY KEY REFERENCES sessions (id) ON DELETE CASCADE, - branch TEXT NOT NULL DEFAULT '', - workspace_path TEXT NOT NULL DEFAULT '', - runtime_handle_id TEXT NOT NULL DEFAULT '', - runtime_name TEXT NOT NULL DEFAULT '', - agent_session_id TEXT NOT NULL DEFAULT '', - prompt TEXT NOT NULL DEFAULT '', - updated_at TIMESTAMP NOT NULL +-- pr holds PR facts keyed by the normalized PR URL. One session can own many PRs +-- (session_id FK), but a PR belongs to one session (enforced at runtime). ci_state +-- is the rolled-up status; the per-check history lives in pr_checks. +CREATE TABLE pr ( + url TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE CASCADE, + number INTEGER NOT NULL DEFAULT 0, + pr_state TEXT NOT NULL DEFAULT 'open' + CHECK (pr_state IN ('draft', 'open', 'merged', 'closed')), + review_decision TEXT NOT NULL DEFAULT 'none' + CHECK (review_decision IN ('none', 'approved', 'changes_requested', 'review_required')), + ci_state TEXT NOT NULL DEFAULT 'unknown' + CHECK (ci_state IN ('unknown', 'pending', 'passing', 'failing')), + mergeability TEXT NOT NULL DEFAULT 'unknown' + CHECK (mergeability IN ('unknown', 'mergeable', 'conflicting', 'blocked', 'unstable')), + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_pr_session ON pr (session_id); + +-- pr_checks is CI run history: one row per (PR, check, commit). The CI-fix-loop +-- brake is a LIMIT 3 query over it ("last 3 runs of this check all failed?") — no +-- counter is stored. Re-polling the same commit upserts the same row. +CREATE TABLE pr_checks ( + pr_url TEXT NOT NULL REFERENCES pr (url) ON DELETE CASCADE, + name TEXT NOT NULL, + commit_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown' + CHECK (status IN ('unknown', 'queued', 'in_progress', 'passed', 'failed', 'skipped', 'cancelled')), + url TEXT NOT NULL DEFAULT '', + log_tail TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (pr_url, name, commit_hash) +); +CREATE INDEX idx_pr_checks_lookup ON pr_checks (pr_url, name, created_at); + +-- pr_comment holds review comments, persisted so a session page does not wait on +-- GitHub. Cascades from pr. +CREATE TABLE pr_comment ( + pr_url TEXT NOT NULL REFERENCES pr (url) ON DELETE CASCADE, + comment_id TEXT NOT NULL, + author TEXT NOT NULL DEFAULT '', + file TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0, + body TEXT NOT NULL DEFAULT '', + resolved INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (pr_url, comment_id) ); --- change_log is the durable, ordered record of every canonical write. seq is the --- monotonic CDC ordering/idempotency key. +-- change_log is the durable, append-only CDC event log. seq is the monotonic +-- ordering + idempotency key. Rows are written by TRIGGERS on the user-visible +-- tables (DB-native capture, atomic with the change) — never by application +-- emit-code. project_id is required, session_id is nullable (project-level events +-- have no session). The log is immutable (no published flag); consumers track +-- their own offset (SSE Last-Event-ID). CREATE TABLE change_log ( seq INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, + project_id TEXT NOT NULL REFERENCES projects (id), + session_id TEXT REFERENCES sessions (id), event_type TEXT NOT NULL, - revision INTEGER NOT NULL, payload TEXT NOT NULL, - created_at TIMESTAMP NOT NULL + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) ); +CREATE INDEX idx_change_log_project ON change_log (project_id, seq); --- outbox is the transactional-outbox: one unsent row per canonical write, drained --- by the publisher into JSONL. change_log_seq links it to its change_log row. -CREATE TABLE outbox ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - change_log_seq INTEGER NOT NULL REFERENCES change_log (seq), - sent INTEGER NOT NULL DEFAULT 0, - sent_at TIMESTAMP, - attempts INTEGER NOT NULL DEFAULT 0, - last_error TEXT NOT NULL DEFAULT '', - created_at TIMESTAMP NOT NULL -); +-- +goose StatementEnd -CREATE INDEX idx_outbox_unsent ON outbox (change_log_seq) WHERE sent = 0; +-- CDC capture triggers. Each is its own goose statement (the trigger body holds +-- semicolons). They write change_log atomically with the originating change, so +-- the application never emits events — it just writes sessions/pr/pr_checks. --- consumer_offsets is the durable per-consumer cursor (at-least-once delivery). -CREATE TABLE consumer_offsets ( - consumer TEXT PRIMARY KEY, - last_seq INTEGER NOT NULL DEFAULT 0, - updated_at TIMESTAMP NOT NULL -); +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_insert +AFTER INSERT ON sessions +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_created', + json_object('id', NEW.id, 'state', NEW.session_state, 'terminationReason', NEW.termination_reason, + 'isAlive', NEW.is_alive, 'activity', NEW.activity_state), + NEW.updated_at); +END; +-- +goose StatementEnd --- reaction_trackers is the durable escalation budget (persisted so a restart does --- not re-fire human pages). Off the canonical CDC path. Mirrors the LCM's --- in-memory reactionTracker: attempts (numeric budget), escalated (silences --- further auto-dispatch), first_attempt_at (duration-escalation anchor), --- project_id (captured at first attempt for the escalation event). -CREATE TABLE reaction_trackers ( - session_id TEXT NOT NULL, - reaction_key TEXT NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, - escalated INTEGER NOT NULL DEFAULT 0, - first_attempt_at TIMESTAMP, - project_id TEXT NOT NULL DEFAULT '', - PRIMARY KEY (session_id, reaction_key) -); +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_update +AFTER UPDATE ON sessions +WHEN OLD.session_state <> NEW.session_state + OR OLD.termination_reason <> NEW.termination_reason + OR OLD.is_alive <> NEW.is_alive + OR OLD.activity_state <> NEW.activity_state +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_updated', + json_object('id', NEW.id, 'state', NEW.session_state, 'terminationReason', NEW.termination_reason, + 'isAlive', NEW.is_alive, 'activity', NEW.activity_state), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_insert +AFTER INSERT ON pr +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_created', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_update +AFTER UPDATE ON pr +WHEN OLD.pr_state <> NEW.pr_state + OR OLD.ci_state <> NEW.ci_state + OR OLD.review_decision <> NEW.review_decision + OR OLD.mergeability <> NEW.mergeability +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_updated', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_insert +AFTER INSERT ON pr_checks +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + NEW.created_at); +END; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin -DROP TABLE reaction_trackers; -DROP TABLE consumer_offsets; -DROP TABLE outbox; DROP TABLE change_log; -DROP TABLE session_metadata; +DROP TABLE pr_comment; +DROP TABLE pr_checks; +DROP TABLE pr; DROP TABLE sessions; +DROP TABLE projects; -- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql b/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql deleted file mode 100644 index da987ed5c6..0000000000 --- a/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql +++ /dev/null @@ -1,85 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- projects is the durable registry of repos AO manages, the SQLite twin of the --- old YAML config (global config.yaml + per-repo agent-orchestrator.yaml). id is --- the {basename}_{sha256(path:originUrl)[:10]} key the session layer references --- via sessions.project_id. The relationship is app-enforced, NOT a hard FK: --- SQLite cannot ALTER ADD a FK without a table rebuild, and an existing-session --- backfill may land sessions before their project row. -CREATE TABLE projects ( - id TEXT PRIMARY KEY, - path TEXT NOT NULL, - repo_owner TEXT NOT NULL DEFAULT '', - repo_name TEXT NOT NULL DEFAULT '', - repo_platform TEXT NOT NULL DEFAULT '', - repo_origin_url TEXT NOT NULL DEFAULT '', - default_branch TEXT NOT NULL DEFAULT '', - display_name TEXT NOT NULL DEFAULT '', - session_prefix TEXT NOT NULL DEFAULT '', - source TEXT NOT NULL DEFAULT '', - registered_at TIMESTAMP NOT NULL, - - -- soft delete: NULL = active. Archiving keeps the row so a session's - -- project_id always resolves (there is no FK to enforce it), avoiding - -- dangling references; active-only reads filter archived_at IS NULL. - archived_at TIMESTAMP -); - --- pr is the SCM observer's per-session cache of the rich PR facts that do NOT --- live in the canonical lifecycle (which keeps only pr_state/reason/number/url). --- 1:1 with a session (a PR is tied to a session by its branch), written by the --- SCM observer OFF the canonical CDC path (no revision bump, no change_log/outbox --- event), and cascades away with its session. Scalar facts are typed columns — --- review_decision/mergeability/ci_state are CHECK-constrained enums and the CI --- counts are integers, not opaque strings; the list facts (individual checks and --- review comments) are normalized into pr_check / pr_comment. -CREATE TABLE pr ( - session_id TEXT PRIMARY KEY REFERENCES sessions (id) ON DELETE CASCADE, - review_decision TEXT NOT NULL DEFAULT 'none' - CHECK (review_decision IN ('none', 'approved', 'changes_requested', 'review_required')), - mergeability TEXT NOT NULL DEFAULT 'unknown' - CHECK (mergeability IN ('unknown', 'mergeable', 'conflicting', 'blocked', 'unstable')), - ci_state TEXT NOT NULL DEFAULT 'unknown' - CHECK (ci_state IN ('unknown', 'pending', 'passing', 'failing')), - ci_passed INTEGER NOT NULL DEFAULT 0, - ci_failed INTEGER NOT NULL DEFAULT 0, - ci_pending INTEGER NOT NULL DEFAULT 0, - ci_log_tail TEXT NOT NULL DEFAULT '', - last_fetched_at TIMESTAMP NOT NULL -); - --- pr_check is one CI check belonging to a pr (the normalized form of the old --- ci_summary string). It cascades from pr, so it cannot outlive its PR facts. -CREATE TABLE pr_check ( - session_id TEXT NOT NULL REFERENCES pr (session_id) ON DELETE CASCADE, - name TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'unknown' - CHECK (status IN ('unknown', 'queued', 'in_progress', 'passed', 'failed', 'skipped', 'cancelled')), - url TEXT NOT NULL DEFAULT '', - PRIMARY KEY (session_id, name) -); - --- pr_comment is one unresolved review comment belonging to a pr (the normalized --- form of the old pending_comments JSON-in-a-string). Cascades from pr. -CREATE TABLE pr_comment ( - session_id TEXT NOT NULL REFERENCES pr (session_id) ON DELETE CASCADE, - comment_id TEXT NOT NULL, - author TEXT NOT NULL DEFAULT '', - file TEXT NOT NULL DEFAULT '', - line INTEGER NOT NULL DEFAULT 0, - body TEXT NOT NULL DEFAULT '', - resolved INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL, - PRIMARY KEY (session_id, comment_id) -); - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TABLE pr_comment; -DROP TABLE pr_check; -DROP TABLE pr; -DROP TABLE projects; --- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/pr_projects_test.go b/backend/internal/storage/sqlite/pr_projects_test.go deleted file mode 100644 index 58227b1f3c..0000000000 --- a/backend/internal/storage/sqlite/pr_projects_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package sqlite - -import ( - "context" - "reflect" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -func TestProjectUpsertGetListDelete(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - now := time.Now().UTC().Truncate(time.Second) - - if _, ok, err := s.GetProject(ctx, "p1"); err != nil || ok { - t.Fatalf("get missing: ok=%v err=%v", ok, err) - } - - p := ProjectRow{ - ID: "p1", Path: "/repo", RepoOwner: "acme", RepoName: "widget", - RepoPlatform: "github", RepoOriginURL: "git@github.com:acme/widget.git", - DefaultBranch: "main", DisplayName: "Widget", SessionPrefix: "wid", - Source: "local", RegisteredAt: now, - } - if err := s.UpsertProject(ctx, p); err != nil { - t.Fatalf("upsert: %v", err) - } - - got, ok, err := s.GetProject(ctx, "p1") - if err != nil || !ok { - t.Fatalf("get: ok=%v err=%v", ok, err) - } - if got != p { - t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, p) - } - - // Upsert again with a changed field updates in place (no duplicate). - p.DisplayName = "Widget 2" - if err := s.UpsertProject(ctx, p); err != nil { - t.Fatalf("re-upsert: %v", err) - } - list, err := s.ListProjects(ctx) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(list) != 1 || list[0].DisplayName != "Widget 2" { - t.Fatalf("list after re-upsert = %+v", list) - } - - if err := s.DeleteProject(ctx, "p1"); err != nil { - t.Fatalf("delete: %v", err) - } - if _, ok, _ := s.GetProject(ctx, "p1"); ok { - t.Fatal("project should be gone after delete") - } -} - -func TestArchiveProjectHidesFromListButGetResolves(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - now := time.Now().UTC().Truncate(time.Second) - - if err := s.UpsertProject(ctx, ProjectRow{ID: "p1", Path: "/repo", RegisteredAt: now}); err != nil { - t.Fatalf("upsert: %v", err) - } - if err := s.ArchiveProject(ctx, "p1", now); err != nil { - t.Fatalf("archive: %v", err) - } - - // Active-only list hides it. - list, err := s.ListProjects(ctx) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(list) != 0 { - t.Fatalf("archived project should not appear in ListProjects, got %+v", list) - } - - // Get still resolves it (a session's project_id must not dangle) and reports - // the archived marker. - got, ok, err := s.GetProject(ctx, "p1") - if err != nil || !ok { - t.Fatalf("get archived: ok=%v err=%v", ok, err) - } - if got.ArchivedAt.IsZero() { - t.Fatal("archived project should carry a non-zero ArchivedAt") - } -} - -func TestPRUpsertGetDelete(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - now := time.Now().UTC().Truncate(time.Second) - - // pr FKs sessions(id); seed the session first. - if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { - t.Fatalf("seed session: %v", err) - } - - if _, ok, err := s.GetPR(ctx, "s1"); err != nil || ok { - t.Fatalf("get missing: ok=%v err=%v", ok, err) - } - - pr := PRRow{ - SessionID: "s1", ReviewDecision: "changes_requested", Mergeability: "blocked", - CIState: "failing", CIPassed: 3, CIFailed: 1, CIPending: 0, CILogTail: "FAIL TestX", - LastFetchedAt: now, - } - if err := s.UpsertPR(ctx, pr); err != nil { - t.Fatalf("upsert: %v", err) - } - - got, ok, err := s.GetPR(ctx, "s1") - if err != nil || !ok { - t.Fatalf("get: ok=%v err=%v", ok, err) - } - if got != pr { - t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, pr) - } - - if err := s.DeletePR(ctx, "s1"); err != nil { - t.Fatalf("delete: %v", err) - } - if _, ok, _ := s.GetPR(ctx, "s1"); ok { - t.Fatal("pr should be gone after delete") - } -} - -func TestPRRejectsBadEnum(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { - t.Fatalf("seed session: %v", err) - } - // review_decision is a CHECK-constrained enum; an off-list value must fail. - err := s.UpsertPR(ctx, PRRow{ - SessionID: "s1", ReviewDecision: "definitely_not_a_decision", - Mergeability: "unknown", CIState: "unknown", LastFetchedAt: time.Now().UTC(), - }) - if err == nil { - t.Fatal("expected CHECK constraint to reject an invalid review_decision") - } -} - -func TestPRChecksAndCommentsReplaceAndList(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - now := time.Now().UTC().Truncate(time.Second) - - if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { - t.Fatalf("seed session: %v", err) - } - // pr_check / pr_comment FK pr(session_id); the pr row must exist first. - if err := s.UpsertPR(ctx, PRRow{ - SessionID: "s1", ReviewDecision: "review_required", Mergeability: "unknown", - CIState: "pending", LastFetchedAt: now, - }); err != nil { - t.Fatalf("upsert pr: %v", err) - } - - checks := []PRCheck{ - {Name: "build", Status: "passed", URL: "https://ci/build"}, - {Name: "test", Status: "failed", URL: "https://ci/test"}, - } - if err := s.ReplacePRChecks(ctx, "s1", checks); err != nil { - t.Fatalf("replace checks: %v", err) - } - gotChecks, err := s.ListPRChecks(ctx, "s1") - if err != nil { - t.Fatalf("list checks: %v", err) - } - if !reflect.DeepEqual(gotChecks, checks) { - t.Fatalf("checks = %+v, want %+v", gotChecks, checks) - } - // Replace is a set-replace, not a merge: a shorter set removes the rest. - if err := s.ReplacePRChecks(ctx, "s1", []PRCheck{{Name: "build", Status: "passed"}}); err != nil { - t.Fatalf("replace checks 2: %v", err) - } - if gotChecks, _ = s.ListPRChecks(ctx, "s1"); len(gotChecks) != 1 { - t.Fatalf("after replace, checks = %+v, want 1", gotChecks) - } - - comments := []PRComment{ - {CommentID: "c1", Author: "alice", File: "a.go", Line: 10, Body: "nit", Resolved: false, CreatedAt: now}, - {CommentID: "c2", Author: "bob", File: "b.go", Line: 20, Body: "bug", Resolved: true, CreatedAt: now.Add(time.Second)}, - } - if err := s.ReplacePRComments(ctx, "s1", comments); err != nil { - t.Fatalf("replace comments: %v", err) - } - gotComments, err := s.ListPRComments(ctx, "s1") - if err != nil { - t.Fatalf("list comments: %v", err) - } - if !reflect.DeepEqual(gotComments, comments) { - t.Fatalf("comments = %+v, want %+v", gotComments, comments) - } - - // Deleting the pr cascades its checks and comments. - if err := s.DeletePR(ctx, "s1"); err != nil { - t.Fatalf("delete pr: %v", err) - } - if c, _ := s.ListPRChecks(ctx, "s1"); len(c) != 0 { - t.Fatalf("checks not cascaded: %+v", c) - } - if c, _ := s.ListPRComments(ctx, "s1"); len(c) != 0 { - t.Fatalf("comments not cascaded: %+v", c) - } -} diff --git a/backend/internal/storage/sqlite/pr_store.go b/backend/internal/storage/sqlite/pr_store.go index 1eca08f8f2..4170da4d99 100644 --- a/backend/internal/storage/sqlite/pr_store.go +++ b/backend/internal/storage/sqlite/pr_store.go @@ -10,136 +10,184 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -// PRRow is the SCM observer's cache of the scalar PR facts that do not live in -// the canonical lifecycle (which keeps only pr_state/reason/number/url). It is -// 1:1 with a session and written OFF the canonical CDC path: upserting it never -// bumps revision and never emits a change_log/outbox event. The list facts -// (checks, comments) are separate rows — see PRCheck / PRComment. +// PRRow is the scalar PR facts row (the pr table), keyed by normalized URL. One +// session can own many PRs; a PR belongs to one session (session_id FK). type PRRow struct { + URL string SessionID string + Number int64 + State string // draft | open | merged | closed ReviewDecision string // none | approved | changes_requested | review_required - Mergeability string // unknown | mergeable | conflicting | blocked | unstable CIState string // unknown | pending | passing | failing - CIPassed int64 - CIFailed int64 - CIPending int64 - CILogTail string - LastFetchedAt time.Time -} - -// PRCheck is one CI check belonging to a session's PR. -type PRCheck struct { - Name string - Status string // unknown | queued | in_progress | passed | failed | skipped | cancelled - URL string -} - -// PRComment is one review comment belonging to a session's PR. -type PRComment struct { - CommentID string - Author string - File string - Line int64 - Body string - Resolved bool - CreatedAt time.Time + Mergeability string // unknown | mergeable | conflicting | blocked | unstable + UpdatedAt time.Time } -// UpsertPR inserts or replaces the scalar PR facts for one session. +// UpsertPR inserts or replaces the scalar PR facts for a PR URL. Empty enum +// fields default to their "nothing known yet" value so a partial row is valid +// against the CHECK constraints (matches the domain zero values none/unknown). func (s *Store) UpsertPR(ctx context.Context, r PRRow) error { + if r.State == "" { + r.State = "open" + } + if r.ReviewDecision == "" { + r.ReviewDecision = "none" + } + if r.CIState == "" { + r.CIState = "unknown" + } + if r.Mergeability == "" { + r.Mergeability = "unknown" + } s.writeMu.Lock() defer s.writeMu.Unlock() - return s.q.UpsertPR(ctx, gen.UpsertPRParams{ + return s.qw.UpsertPR(ctx, gen.UpsertPRParams{ + Url: r.URL, SessionID: r.SessionID, + Number: r.Number, + PrState: r.State, ReviewDecision: r.ReviewDecision, - Mergeability: r.Mergeability, CiState: r.CIState, - CiPassed: r.CIPassed, - CiFailed: r.CIFailed, - CiPending: r.CIPending, - CiLogTail: r.CILogTail, - LastFetchedAt: r.LastFetchedAt, + Mergeability: r.Mergeability, + UpdatedAt: r.UpdatedAt, }) } -// GetPR returns the scalar PR facts for one session. ok is false when no row -// exists (the SCM observer has not fetched yet, or the session has no PR). -func (s *Store) GetPR(ctx context.Context, sessionID string) (PRRow, bool, error) { - p, err := s.q.GetPR(ctx, sessionID) +// GetPR returns the PR facts for a URL, or ok=false if absent. +func (s *Store) GetPR(ctx context.Context, url string) (PRRow, bool, error) { + p, err := s.qr.GetPR(ctx, url) if errors.Is(err, sql.ErrNoRows) { return PRRow{}, false, nil } if err != nil { - return PRRow{}, false, fmt.Errorf("get pr: %w", err) + return PRRow{}, false, fmt.Errorf("get pr %s: %w", url, err) } + return prRowFromGen(p), true, nil +} + +// ListPRsBySession returns every PR owned by a session, newest first. +func (s *Store) ListPRsBySession(ctx context.Context, sessionID string) ([]PRRow, error) { + rows, err := s.qr.ListPRsBySession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("list prs for %s: %w", sessionID, err) + } + out := make([]PRRow, 0, len(rows)) + for _, p := range rows { + out = append(out, prRowFromGen(p)) + } + return out, nil +} + +// DeletePR removes a PR (cascades to its checks + comments). +func (s *Store) DeletePR(ctx context.Context, url string) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.DeletePR(ctx, url) +} + +func prRowFromGen(p gen.Pr) PRRow { return PRRow{ + URL: p.Url, SessionID: p.SessionID, + Number: p.Number, + State: p.PrState, ReviewDecision: p.ReviewDecision, - Mergeability: p.Mergeability, CIState: p.CiState, - CIPassed: p.CiPassed, - CIFailed: p.CiFailed, - CIPending: p.CiPending, - CILogTail: p.CiLogTail, - LastFetchedAt: p.LastFetchedAt, - }, true, nil + Mergeability: p.Mergeability, + UpdatedAt: p.UpdatedAt, + } } -// DeletePR drops the scalar PR facts for one session, cascading its checks and -// comments. Normally unnecessary (the chain cascades on session delete); exposed -// for explicit eviction. -func (s *Store) DeletePR(ctx context.Context, sessionID string) error { +// ---- pr_checks: CI run history ---- + +// PRCheckRow is one CI check run for a PR (one row per check name per commit). +type PRCheckRow struct { + PRURL string + Name string + CommitHash string + Status string // unknown | queued | in_progress | passed | failed | skipped | cancelled + URL string + LogTail string + CreatedAt time.Time +} + +// RecordCheck upserts a CI check run. Re-polling the same (pr, name, commit) +// updates the same row; a new commit creates a new row (a fresh agent attempt). +func (s *Store) RecordCheck(ctx context.Context, r PRCheckRow) error { + if r.Status == "" { + r.Status = "unknown" + } s.writeMu.Lock() defer s.writeMu.Unlock() - return s.q.DeletePR(ctx, sessionID) + return s.qw.UpsertPRCheck(ctx, gen.UpsertPRCheckParams{ + PrUrl: r.PRURL, + Name: r.Name, + CommitHash: r.CommitHash, + Status: r.Status, + Url: r.URL, + LogTail: r.LogTail, + CreatedAt: r.CreatedAt, + }) } -// ReplacePRChecks atomically replaces the full set of CI checks for a session's -// PR — each SCM fetch reports the current set, so a replace (not a merge) keeps -// the table in sync (a check that disappeared upstream is removed). The PR row -// must already exist (pr_check FKs pr). -func (s *Store) ReplacePRChecks(ctx context.Context, sessionID string, checks []PRCheck) error { - return s.inTx(ctx, "replace pr checks", func(qtx *gen.Queries) error { - if err := qtx.DeletePRChecks(ctx, sessionID); err != nil { - return err - } - for _, c := range checks { - if err := qtx.InsertPRCheck(ctx, gen.InsertPRCheckParams{ - SessionID: sessionID, - Name: c.Name, - Status: c.Status, - Url: c.URL, - }); err != nil { - return fmt.Errorf("check %q: %w", c.Name, err) - } - } - return nil +// RecentCheckStatuses returns the statuses of the last `limit` runs of a check, +// most-recent first. The CI-fix-loop brake reads this: "last 3 all failed?". +func (s *Store) RecentCheckStatuses(ctx context.Context, prURL, name string, limit int) ([]string, error) { + rows, err := s.qr.ListRecentChecks(ctx, gen.ListRecentChecksParams{ + PrUrl: prURL, Name: name, Limit: int64(limit), }) + if err != nil { + return nil, fmt.Errorf("recent checks %s/%s: %w", prURL, name, err) + } + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.Status) + } + return out, nil } -// ListPRChecks returns a session's CI checks, ordered by name. -func (s *Store) ListPRChecks(ctx context.Context, sessionID string) ([]PRCheck, error) { - rows, err := s.q.ListPRChecks(ctx, sessionID) +// ListChecks returns every recorded check run for a PR. +func (s *Store) ListChecks(ctx context.Context, prURL string) ([]PRCheckRow, error) { + rows, err := s.qr.ListChecksByPR(ctx, prURL) if err != nil { - return nil, fmt.Errorf("list pr checks: %w", err) + return nil, fmt.Errorf("list checks %s: %w", prURL, err) } - out := make([]PRCheck, 0, len(rows)) - for _, r := range rows { - out = append(out, PRCheck{Name: r.Name, Status: r.Status, URL: r.Url}) + out := make([]PRCheckRow, 0, len(rows)) + for _, c := range rows { + out = append(out, PRCheckRow{ + PRURL: c.PrUrl, Name: c.Name, CommitHash: c.CommitHash, + Status: c.Status, URL: c.Url, LogTail: c.LogTail, CreatedAt: c.CreatedAt, + }) } return out, nil } -// ReplacePRComments atomically replaces the full set of review comments for a -// session's PR (same replace-not-merge rationale as ReplacePRChecks). -func (s *Store) ReplacePRComments(ctx context.Context, sessionID string, comments []PRComment) error { - return s.inTx(ctx, "replace pr comments", func(qtx *gen.Queries) error { - if err := qtx.DeletePRComments(ctx, sessionID); err != nil { +// ---- pr_comment ---- + +// PRCommentRow is one review comment on a PR. +type PRCommentRow struct { + PRURL string + CommentID string + Author string + File string + Line int64 + Body string + Resolved bool + CreatedAt time.Time +} + +// ReplacePRComments atomically replaces the full comment set for a PR (each SCM +// fetch reports the current set, so a replace keeps it in sync). +func (s *Store) ReplacePRComments(ctx context.Context, prURL string, comments []PRCommentRow) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.inTx(ctx, "replace pr comments", func(q *gen.Queries) error { + if err := q.DeletePRComments(ctx, prURL); err != nil { return err } for _, c := range comments { - if err := qtx.InsertPRComment(ctx, gen.InsertPRCommentParams{ - SessionID: sessionID, + if err := q.UpsertPRComment(ctx, gen.UpsertPRCommentParams{ + PrUrl: prURL, CommentID: c.CommentID, Author: c.Author, File: c.File, @@ -155,47 +203,18 @@ func (s *Store) ReplacePRComments(ctx context.Context, sessionID string, comment }) } -// ListPRComments returns a session's review comments, ordered by creation time. -func (s *Store) ListPRComments(ctx context.Context, sessionID string) ([]PRComment, error) { - rows, err := s.q.ListPRComments(ctx, sessionID) +// ListPRComments returns a PR's review comments, oldest first. +func (s *Store) ListPRComments(ctx context.Context, prURL string) ([]PRCommentRow, error) { + rows, err := s.qr.ListPRComments(ctx, prURL) if err != nil { - return nil, fmt.Errorf("list pr comments: %w", err) + return nil, fmt.Errorf("list pr comments %s: %w", prURL, err) } - out := make([]PRComment, 0, len(rows)) - for _, r := range rows { - out = append(out, PRComment{ - CommentID: r.CommentID, - Author: r.Author, - File: r.File, - Line: r.Line, - Body: r.Body, - Resolved: r.Resolved != 0, - CreatedAt: r.CreatedAt, + out := make([]PRCommentRow, 0, len(rows)) + for _, c := range rows { + out = append(out, PRCommentRow{ + PRURL: c.PrUrl, CommentID: c.CommentID, Author: c.Author, File: c.File, + Line: c.Line, Body: c.Body, Resolved: c.Resolved != 0, CreatedAt: c.CreatedAt, }) } return out, nil } - -// inTx runs fn inside a single write transaction over the store's queries, -// rolling back on error. It holds writeMu for the duration, so callers must not -// already hold it. -func (s *Store) inTx(ctx context.Context, what string, fn func(*gen.Queries) error) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin %s: %w", what, err) - } - defer tx.Rollback() - if err := fn(s.q.WithTx(tx)); err != nil { - return fmt.Errorf("%s: %w", what, err) - } - return tx.Commit() -} - -func boolToInt(b bool) int64 { - if b { - return 1 - } - return 0 -} diff --git a/backend/internal/storage/sqlite/project_store.go b/backend/internal/storage/sqlite/project_store.go index 4837cafccf..d81943c3cb 100644 --- a/backend/internal/storage/sqlite/project_store.go +++ b/backend/internal/storage/sqlite/project_store.go @@ -10,74 +10,46 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -// ProjectRow is one registered repo, the durable twin of the old YAML config -// entry. It is the unit the registration path upserts and cross-project readers -// list. Off the canonical CDC path: writing a project never emits a change_log -// or outbox event. +// ProjectRow is one registered repo (the projects table). id is a short slug +// (mer, ao). ArchivedAt zero means active. type ProjectRow struct { ID string Path string - RepoOwner string - RepoName string - RepoPlatform string RepoOriginURL string - DefaultBranch string DisplayName string - SessionPrefix string - Source string RegisteredAt time.Time - // ArchivedAt is the soft-delete marker; zero means active. GetProject returns - // it regardless of state (so a session can resolve its archived project); - // ListProjects returns only rows where it is zero. - ArchivedAt time.Time + ArchivedAt time.Time } -// UpsertProject inserts or updates one registered project. +// UpsertProject inserts or updates a registered project. func (s *Store) UpsertProject(ctx context.Context, r ProjectRow) error { s.writeMu.Lock() defer s.writeMu.Unlock() - return s.q.UpsertProject(ctx, gen.UpsertProjectParams{ + return s.qw.UpsertProject(ctx, gen.UpsertProjectParams{ ID: r.ID, Path: r.Path, - RepoOwner: r.RepoOwner, - RepoName: r.RepoName, - RepoPlatform: r.RepoPlatform, RepoOriginUrl: r.RepoOriginURL, - DefaultBranch: r.DefaultBranch, DisplayName: r.DisplayName, - SessionPrefix: r.SessionPrefix, - Source: r.Source, RegisteredAt: r.RegisteredAt, ArchivedAt: nullTime(r.ArchivedAt), }) } -// ArchiveProject soft-deletes one project, keeping the row so a session's -// project_id still resolves. Active-only reads (ListProjects) then hide it. -func (s *Store) ArchiveProject(ctx context.Context, id string, t time.Time) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.ArchiveProject(ctx, gen.ArchiveProjectParams{ - ArchivedAt: nullTime(t), - ID: id, - }) -} - -// GetProject returns one project by id. ok is false when no row exists. +// GetProject returns a project by id (active or archived), or ok=false. func (s *Store) GetProject(ctx context.Context, id string) (ProjectRow, bool, error) { - p, err := s.q.GetProject(ctx, id) + p, err := s.qr.GetProject(ctx, id) if errors.Is(err, sql.ErrNoRows) { return ProjectRow{}, false, nil } if err != nil { - return ProjectRow{}, false, fmt.Errorf("get project: %w", err) + return ProjectRow{}, false, fmt.Errorf("get project %s: %w", id, err) } return projectRowFromGen(p), true, nil } -// ListProjects returns every registered project, ordered by id. +// ListProjects returns active (non-archived) projects, ordered by id. func (s *Store) ListProjects(ctx context.Context) ([]ProjectRow, error) { - rows, err := s.q.ListProjects(ctx) + rows, err := s.qr.ListProjects(ctx) if err != nil { return nil, fmt.Errorf("list projects: %w", err) } @@ -88,31 +60,31 @@ func (s *Store) ListProjects(ctx context.Context) ([]ProjectRow, error) { return out, nil } -// DeleteProject removes one project by id. -func (s *Store) DeleteProject(ctx context.Context, id string) error { +// ArchiveProject soft-deletes a project (the row stays so session.project_id +// still resolves). +func (s *Store) ArchiveProject(ctx context.Context, id string, at time.Time) error { s.writeMu.Lock() defer s.writeMu.Unlock() - return s.q.DeleteProject(ctx, id) + return s.qw.ArchiveProject(ctx, gen.ArchiveProjectParams{ + ArchivedAt: nullTime(at), + ID: id, + }) } func projectRowFromGen(p gen.Project) ProjectRow { - return ProjectRow{ + r := ProjectRow{ ID: p.ID, Path: p.Path, - RepoOwner: p.RepoOwner, - RepoName: p.RepoName, - RepoPlatform: p.RepoPlatform, RepoOriginURL: p.RepoOriginUrl, - DefaultBranch: p.DefaultBranch, DisplayName: p.DisplayName, - SessionPrefix: p.SessionPrefix, - Source: p.Source, RegisteredAt: p.RegisteredAt, - ArchivedAt: p.ArchivedAt.Time, } + if p.ArchivedAt.Valid { + r.ArchivedAt = p.ArchivedAt.Time + } + return r } -// nullTime maps a zero time.Time to a NULL column, else a valid timestamp. func nullTime(t time.Time) sql.NullTime { if t.IsZero() { return sql.NullTime{} diff --git a/backend/internal/storage/sqlite/queries/cdc.sql b/backend/internal/storage/sqlite/queries/cdc.sql deleted file mode 100644 index b818194a51..0000000000 --- a/backend/internal/storage/sqlite/queries/cdc.sql +++ /dev/null @@ -1,42 +0,0 @@ --- name: InsertChangeLog :one --- Appends a canonical-write record and returns its monotonic seq so the same --- transaction can thread it into the outbox row. -INSERT INTO change_log (session_id, event_type, revision, payload, created_at) -VALUES (?, ?, ?, ?, ?) -RETURNING seq; - --- name: InsertOutbox :exec -INSERT INTO outbox (change_log_seq, created_at) -VALUES (?, ?); - --- name: ListUnsentOutbox :many -SELECT o.id, o.change_log_seq, o.attempts, - c.session_id, c.event_type, c.revision, c.payload, c.created_at -FROM outbox o -JOIN change_log c ON c.seq = o.change_log_seq -WHERE o.sent = 0 -ORDER BY o.change_log_seq -LIMIT ?; - --- name: MarkOutboxSent :exec -UPDATE outbox SET sent = 1, sent_at = ? WHERE id = ?; - --- name: MarkOutboxFailed :exec -UPDATE outbox SET attempts = attempts + 1, last_error = ? WHERE id = ?; - --- name: GetConsumerOffset :one -SELECT last_seq FROM consumer_offsets WHERE consumer = ?; - --- name: UpsertConsumerOffset :exec -INSERT INTO consumer_offsets (consumer, last_seq, updated_at) -VALUES (?, ?, ?) -ON CONFLICT (consumer) DO UPDATE SET last_seq = excluded.last_seq, updated_at = excluded.updated_at; - --- name: MaxChangeLogSeq :one -SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) FROM change_log; - --- name: MinConsumerOffset :one -SELECT CAST(COALESCE(MIN(last_seq), 0) AS INTEGER) FROM consumer_offsets; - --- name: DeleteSentOutboxBelow :execrows -DELETE FROM outbox WHERE sent = 1 AND change_log_seq < ?; diff --git a/backend/internal/storage/sqlite/queries/changelog.sql b/backend/internal/storage/sqlite/queries/changelog.sql new file mode 100644 index 0000000000..0e11899c2a --- /dev/null +++ b/backend/internal/storage/sqlite/queries/changelog.sql @@ -0,0 +1,10 @@ +-- name: ReadChangeLogAfter :many +SELECT seq, project_id, session_id, event_type, payload, created_at +FROM change_log WHERE seq > ? ORDER BY seq LIMIT ?; + +-- name: ReadChangeLogAfterForProject :many +SELECT seq, project_id, session_id, event_type, payload, created_at +FROM change_log WHERE project_id = ? AND seq > ? ORDER BY seq LIMIT ?; + +-- name: MaxChangeLogSeq :one +SELECT COALESCE(MAX(seq), 0) AS seq FROM change_log; diff --git a/backend/internal/storage/sqlite/queries/metadata.sql b/backend/internal/storage/sqlite/queries/metadata.sql deleted file mode 100644 index 158552daa3..0000000000 --- a/backend/internal/storage/sqlite/queries/metadata.sql +++ /dev/null @@ -1,20 +0,0 @@ --- name: GetSessionMetadata :one -SELECT branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt -FROM session_metadata -WHERE session_id = ?; - --- name: UpsertSessionMetadata :exec --- Merge semantics: an empty incoming column is "leave unchanged", so a partial --- patch (e.g. spawn writing only the runtime handle) never clobbers a value set --- earlier (e.g. the branch set at creation). Mirrors the old per-key map merge. -INSERT INTO session_metadata ( - session_id, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id) DO UPDATE SET - branch = CASE WHEN excluded.branch <> '' THEN excluded.branch ELSE session_metadata.branch END, - workspace_path = CASE WHEN excluded.workspace_path <> '' THEN excluded.workspace_path ELSE session_metadata.workspace_path END, - runtime_handle_id = CASE WHEN excluded.runtime_handle_id <> '' THEN excluded.runtime_handle_id ELSE session_metadata.runtime_handle_id END, - runtime_name = CASE WHEN excluded.runtime_name <> '' THEN excluded.runtime_name ELSE session_metadata.runtime_name END, - agent_session_id = CASE WHEN excluded.agent_session_id <> '' THEN excluded.agent_session_id ELSE session_metadata.agent_session_id END, - prompt = CASE WHEN excluded.prompt <> '' THEN excluded.prompt ELSE session_metadata.prompt END, - updated_at = excluded.updated_at; diff --git a/backend/internal/storage/sqlite/queries/pr.sql b/backend/internal/storage/sqlite/queries/pr.sql index 13c14a78da..e6b41cf1af 100644 --- a/backend/internal/storage/sqlite/queries/pr.sql +++ b/backend/internal/storage/sqlite/queries/pr.sql @@ -1,43 +1,20 @@ -- name: UpsertPR :exec -INSERT INTO pr ( - session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id) DO UPDATE SET +INSERT INTO pr (url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (url) DO UPDATE SET + session_id = excluded.session_id, + number = excluded.number, + pr_state = excluded.pr_state, review_decision = excluded.review_decision, - mergeability = excluded.mergeability, - ci_state = excluded.ci_state, - ci_passed = excluded.ci_passed, - ci_failed = excluded.ci_failed, - ci_pending = excluded.ci_pending, - ci_log_tail = excluded.ci_log_tail, - last_fetched_at = excluded.last_fetched_at; + ci_state = excluded.ci_state, + mergeability = excluded.mergeability, + updated_at = excluded.updated_at; -- name: GetPR :one -SELECT session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at -FROM pr -WHERE session_id = ?; +SELECT * FROM pr WHERE url = ?; --- name: DeletePR :exec -DELETE FROM pr WHERE session_id = ?; - --- name: DeletePRChecks :exec -DELETE FROM pr_check WHERE session_id = ?; - --- name: InsertPRCheck :exec -INSERT INTO pr_check (session_id, name, status, url) VALUES (?, ?, ?, ?); - --- name: ListPRChecks :many -SELECT name, status, url FROM pr_check WHERE session_id = ? ORDER BY name; +-- name: ListPRsBySession :many +SELECT * FROM pr WHERE session_id = ? ORDER BY updated_at DESC; --- name: DeletePRComments :exec -DELETE FROM pr_comment WHERE session_id = ?; - --- name: InsertPRComment :exec -INSERT INTO pr_comment (session_id, comment_id, author, file, line, body, resolved, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?); - --- name: ListPRComments :many -SELECT comment_id, author, file, line, body, resolved, created_at -FROM pr_comment -WHERE session_id = ? -ORDER BY created_at, comment_id; +-- name: DeletePR :exec +DELETE FROM pr WHERE url = ?; diff --git a/backend/internal/storage/sqlite/queries/pr_checks.sql b/backend/internal/storage/sqlite/queries/pr_checks.sql new file mode 100644 index 0000000000..2e3e3c1547 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/pr_checks.sql @@ -0,0 +1,15 @@ +-- name: UpsertPRCheck :exec +INSERT INTO pr_checks (pr_url, name, commit_hash, status, url, log_tail, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (pr_url, name, commit_hash) DO UPDATE SET + status = excluded.status, + url = excluded.url, + log_tail = excluded.log_tail; + +-- name: ListRecentChecks :many +SELECT status, commit_hash, created_at FROM pr_checks +WHERE pr_url = ? AND name = ? +ORDER BY created_at DESC LIMIT ?; + +-- name: ListChecksByPR :many +SELECT * FROM pr_checks WHERE pr_url = ? ORDER BY name, created_at; diff --git a/backend/internal/storage/sqlite/queries/pr_comment.sql b/backend/internal/storage/sqlite/queries/pr_comment.sql new file mode 100644 index 0000000000..df4f99d01e --- /dev/null +++ b/backend/internal/storage/sqlite/queries/pr_comment.sql @@ -0,0 +1,12 @@ +-- name: UpsertPRComment :exec +INSERT INTO pr_comment (pr_url, comment_id, author, file, line, body, resolved, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (pr_url, comment_id) DO UPDATE SET + author = excluded.author, file = excluded.file, line = excluded.line, + body = excluded.body, resolved = excluded.resolved; + +-- name: DeletePRComments :exec +DELETE FROM pr_comment WHERE pr_url = ?; + +-- name: ListPRComments :many +SELECT * FROM pr_comment WHERE pr_url = ? ORDER BY created_at, comment_id; diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql index 054b8f0e8d..3dc28950b7 100644 --- a/backend/internal/storage/sqlite/queries/projects.sql +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -1,32 +1,19 @@ -- name: UpsertProject :exec -INSERT INTO projects (id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at) +VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET path = excluded.path, - repo_owner = excluded.repo_owner, - repo_name = excluded.repo_name, - repo_platform = excluded.repo_platform, repo_origin_url = excluded.repo_origin_url, - default_branch = excluded.default_branch, display_name = excluded.display_name, - session_prefix = excluded.session_prefix, - source = excluded.source, - registered_at = excluded.registered_at, archived_at = excluded.archived_at; -- name: GetProject :one -SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at -FROM projects -WHERE id = ?; +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +FROM projects WHERE id = ?; -- name: ListProjects :many -SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at -FROM projects -WHERE archived_at IS NULL -ORDER BY id; +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +FROM projects WHERE archived_at IS NULL ORDER BY id; -- name: ArchiveProject :exec UPDATE projects SET archived_at = ? WHERE id = ?; - --- name: DeleteProject :exec -DELETE FROM projects WHERE id = ?; diff --git a/backend/internal/storage/sqlite/queries/reactions.sql b/backend/internal/storage/sqlite/queries/reactions.sql deleted file mode 100644 index 0ccd99c3f0..0000000000 --- a/backend/internal/storage/sqlite/queries/reactions.sql +++ /dev/null @@ -1,18 +0,0 @@ --- name: ListReactionTrackers :many -SELECT session_id, reaction_key, attempts, escalated, first_attempt_at, project_id -FROM reaction_trackers; - --- name: UpsertReactionTracker :exec -INSERT INTO reaction_trackers (session_id, reaction_key, attempts, escalated, first_attempt_at, project_id) -VALUES (?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id, reaction_key) DO UPDATE SET - attempts = excluded.attempts, - escalated = excluded.escalated, - first_attempt_at = excluded.first_attempt_at, - project_id = excluded.project_id; - --- name: DeleteReactionTracker :exec -DELETE FROM reaction_trackers WHERE session_id = ? AND reaction_key = ?; - --- name: DeleteSessionReactionTrackers :exec -DELETE FROM reaction_trackers WHERE session_id = ?; diff --git a/backend/internal/storage/sqlite/queries/sessions.sql b/backend/internal/storage/sqlite/queries/sessions.sql index 48cdcacf14..9b294de3c9 100644 --- a/backend/internal/storage/sqlite/queries/sessions.sql +++ b/backend/internal/storage/sqlite/queries/sessions.sql @@ -1,58 +1,34 @@ --- name: InsertSession :execrows --- CAS insert: only succeeds for a brand-new id. Incoming revision must be 0; --- the row is persisted at revision 1. +-- name: NextSessionNum :one +SELECT COALESCE(MAX(num), 0) + 1 AS next FROM sessions WHERE project_id = ?; + +-- name: InsertSession :exec INSERT INTO sessions ( - id, project_id, issue_id, kind, created_at, updated_at, - revision, - session_state, session_reason, - pr_state, pr_reason, pr_number, pr_url, - runtime_state, runtime_reason, + id, project_id, num, issue_id, kind, harness, + session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, - detecting_attempts, detecting_started_at, detecting_evidence_hash -) VALUES ( - ?, ?, ?, ?, ?, ?, - 1, - ?, ?, - ?, ?, ?, ?, - ?, ?, - ?, ?, ?, - ?, ?, ? -) -ON CONFLICT (id) DO NOTHING; + detecting_attempts, detecting_started_at, detecting_evidence_hash, + branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, + created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); --- name: UpdateSessionCAS :execrows --- CAS update: succeeds only when the stored revision equals the caller's loaded --- revision (@expected_revision). 0 rows affected => revision mismatch. +-- name: UpdateSession :exec UPDATE sessions SET - project_id = ?, - issue_id = ?, - kind = ?, - updated_at = ?, - revision = revision + 1, - session_state = ?, - session_reason = ?, - pr_state = ?, - pr_reason = ?, - pr_number = ?, - pr_url = ?, - runtime_state = ?, - runtime_reason = ?, - activity_state = ?, - activity_last_at = ?, - activity_source = ?, - detecting_attempts = ?, - detecting_started_at = ?, - detecting_evidence_hash = ? -WHERE id = ? AND revision = ?; - --- name: GetSessionRevision :one -SELECT revision FROM sessions WHERE id = ?; + issue_id = ?, kind = ?, harness = ?, + session_state = ?, termination_reason = ?, is_alive = ?, + activity_state = ?, activity_last_at = ?, activity_source = ?, + detecting_attempts = ?, detecting_started_at = ?, detecting_evidence_hash = ?, + branch = ?, workspace_path = ?, runtime_handle_id = ?, runtime_name = ?, agent_session_id = ?, prompt = ?, + updated_at = ? +WHERE id = ?; -- name: GetSession :one SELECT * FROM sessions WHERE id = ?; -- name: ListSessionsByProject :many -SELECT * FROM sessions WHERE project_id = ?; +SELECT * FROM sessions WHERE project_id = ? ORDER BY num; -- name: ListAllSessions :many -SELECT * FROM sessions; +SELECT * FROM sessions ORDER BY project_id, num; + +-- name: DeleteSession :exec +DELETE FROM sessions WHERE id = ?; diff --git a/backend/internal/storage/sqlite/reaction_store.go b/backend/internal/storage/sqlite/reaction_store.go deleted file mode 100644 index c703a21b2b..0000000000 --- a/backend/internal/storage/sqlite/reaction_store.go +++ /dev/null @@ -1,86 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// ReactionTrackerRow is one persisted escalation budget, the durable mirror of -// the LCM's in-memory reactionTracker. It is the unit the lifecycle Manager -// hydrates on startup and writes through on each mutation. -type ReactionTrackerRow struct { - SessionID string - ReactionKey string - Attempts int - Escalated bool - FirstAttemptAt time.Time - ProjectID string -} - -// ListReactionTrackers returns every persisted escalation budget so the Manager -// can rehydrate its in-memory trackers after a restart. -func (s *Store) ListReactionTrackers(ctx context.Context) ([]ReactionTrackerRow, error) { - rows, err := s.q.ListReactionTrackers(ctx) - if err != nil { - return nil, fmt.Errorf("list reaction trackers: %w", err) - } - out := make([]ReactionTrackerRow, 0, len(rows)) - for _, r := range rows { - var first time.Time - if r.FirstAttemptAt.Valid { - first = r.FirstAttemptAt.Time - } - out = append(out, ReactionTrackerRow{ - SessionID: r.SessionID, - ReactionKey: r.ReactionKey, - Attempts: int(r.Attempts), - Escalated: r.Escalated != 0, - FirstAttemptAt: first, - ProjectID: r.ProjectID, - }) - } - return out, nil -} - -// SaveReactionTracker durably persists one escalation budget (insert or update). -func (s *Store) SaveReactionTracker(ctx context.Context, r ReactionTrackerRow) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - escalated := int64(0) - if r.Escalated { - escalated = 1 - } - first := sql.NullTime{} - if !r.FirstAttemptAt.IsZero() { - first = sql.NullTime{Time: r.FirstAttemptAt, Valid: true} - } - return s.q.UpsertReactionTracker(ctx, gen.UpsertReactionTrackerParams{ - SessionID: r.SessionID, - ReactionKey: r.ReactionKey, - Attempts: int64(r.Attempts), - Escalated: escalated, - FirstAttemptAt: first, - ProjectID: r.ProjectID, - }) -} - -// DeleteReactionTracker drops one escalation budget. -func (s *Store) DeleteReactionTracker(ctx context.Context, sessionID, reactionKey string) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.DeleteReactionTracker(ctx, gen.DeleteReactionTrackerParams{ - SessionID: sessionID, - ReactionKey: reactionKey, - }) -} - -// DeleteSessionReactionTrackers drops every escalation budget for a session. -func (s *Store) DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.DeleteSessionReactionTrackers(ctx, sessionID) -} diff --git a/backend/internal/storage/sqlite/spike_test.go b/backend/internal/storage/sqlite/spike_test.go deleted file mode 100644 index 30b43fc7cd..0000000000 --- a/backend/internal/storage/sqlite/spike_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package sqlite - -import ( - "context" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// TestSpikeOutboxTxn de-risks the whole adapter: it proves the sqlc-generated -// Querier composes inside one *sql.Tx and that the change_log seq returned -// mid-transaction threads into the outbox row — the transactional-outbox shape -// the publisher later drains. Step 0 of the implementation plan. -func TestSpikeOutboxTxn(t *testing.T) { - db, err := Open(t.TempDir()) - if err != nil { - t.Fatalf("open: %v", err) - } - defer db.Close() - - ctx := context.Background() - now := time.Now().UTC() - - tx, err := db.BeginTx(ctx, nil) - if err != nil { - t.Fatalf("begin: %v", err) - } - defer tx.Rollback() - - q := gen.New(db).WithTx(tx) - - // 1. CAS insert of a brand-new session (revision 0 -> persisted 1). - rows, err := q.InsertSession(ctx, gen.InsertSessionParams{ - ID: "s1", - ProjectID: "p1", - Kind: "worker", - CreatedAt: now, - UpdatedAt: now, - SessionState: "working", - SessionReason: "spawn_requested", - PrState: "none", - PrReason: "not_created", - RuntimeState: "unknown", - RuntimeReason: "spawn_incomplete", - ActivityState: "active", - ActivityLastAt: now, - ActivitySource: "none", - }) - if err != nil { - t.Fatalf("insert session: %v", err) - } - if rows != 1 { - t.Fatalf("insert session affected %d rows, want 1", rows) - } - - // 2. Append the change_log entry and capture its seq mid-transaction. - seq, err := q.InsertChangeLog(ctx, gen.InsertChangeLogParams{ - SessionID: "s1", - EventType: "session_created", - Revision: 1, - Payload: `{"id":"s1"}`, - CreatedAt: now, - }) - if err != nil { - t.Fatalf("insert change_log: %v", err) - } - if seq != 1 { - t.Fatalf("change_log seq = %d, want 1", seq) - } - - // 3. Thread the seq into the outbox row — the key thing the spike validates. - if err := q.InsertOutbox(ctx, gen.InsertOutboxParams{ChangeLogSeq: seq, CreatedAt: now}); err != nil { - t.Fatalf("insert outbox: %v", err) - } - - if err := tx.Commit(); err != nil { - t.Fatalf("commit: %v", err) - } - - // Verify the outbox row is visible, unsent, and linked to change_log seq 1. - unsent, err := gen.New(db).ListUnsentOutbox(ctx, 10) - if err != nil { - t.Fatalf("list unsent: %v", err) - } - if len(unsent) != 1 { - t.Fatalf("unsent outbox = %d rows, want 1", len(unsent)) - } - if unsent[0].ChangeLogSeq != 1 || unsent[0].SessionID != "s1" || unsent[0].EventType != "session_created" { - t.Fatalf("unexpected outbox row: %+v", unsent[0]) - } -} diff --git a/backend/internal/storage/sqlite/store.go b/backend/internal/storage/sqlite/store.go index 2effeaee86..800c18240e 100644 --- a/backend/internal/storage/sqlite/store.go +++ b/backend/internal/storage/sqlite/store.go @@ -6,48 +6,77 @@ import ( "errors" "fmt" "sync" - "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -// Store is the SQLite-backed ports.LifecycleStore. Reads (Load/Get/List/...) run -// concurrently across the connection pool; every write is funnelled through -// writeMu so there is exactly one writer at a time. That single-writer guarantee -// is load-bearing: it keeps WAL's single-writer rule and makes the revision-CAS -// (read-then-write in Upsert) atomic without depending on the pool size. Hold -// writeMu only around writes — never around a read — and never call one -// write method from inside another (the mutex is not reentrant). +// Store is the SQLite-backed persistence layer. It routes writes to a single +// writer connection (qw) and reads to a reader pool (qr) — see Open. writeMu +// guards the read-modify-write write methods (e.g. CreateSession's +// next-num-then-insert) so concurrent writes can't interleave them. +// +// CDC is captured by DB triggers (migration 0001), NOT by this layer: the store +// never writes change_log, it only reads it for the CDC poller. type Store struct { - db *sql.DB - q *gen.Queries + writeDB *sql.DB + readDB *sql.DB + qw *gen.Queries // bound to the single writer connection + qr *gen.Queries // bound to the reader pool writeMu sync.Mutex } -var _ ports.LifecycleStore = (*Store)(nil) - -// NewStore wraps an opened *sql.DB (see Open) as a LifecycleStore. -func NewStore(db *sql.DB) *Store { - return &Store{db: db, q: gen.New(db)} +// NewStore wraps an opened writer + reader *sql.DB (see Open) as a Store. +func NewStore(writeDB, readDB *sql.DB) *Store { + return &Store{ + writeDB: writeDB, + readDB: readDB, + qw: gen.New(writeDB), + qr: gen.New(readDB), + } } -// Load returns the canonical lifecycle for a session, or ok=false if absent. -func (s *Store) Load(ctx context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) { - row, err := s.q.GetSession(ctx, string(id)) - if errors.Is(err, sql.ErrNoRows) { - return domain.CanonicalSessionLifecycle{}, false, nil +// Close closes both pools. +func (s *Store) Close() error { + err := s.writeDB.Close() + if e := s.readDB.Close(); e != nil && err == nil { + err = e } + return err +} + +// ---- sessions ---- + +// CreateSession assigns the per-project identity ("{project}-{num}") and inserts +// the record, returning it with ID populated. The next-num read and the insert +// run on the writer connection under writeMu, so two concurrent creates in the +// same project can't collide on num. +func (s *Store) CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + + num, err := s.qw.NextSessionNum(ctx, string(rec.ProjectID)) if err != nil { - return domain.CanonicalSessionLifecycle{}, false, fmt.Errorf("load session %s: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("next session num for %s: %w", rec.ProjectID, err) } - return rowToLifecycle(row), true, nil + rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, num)) + if err := s.qw.InsertSession(ctx, recordToInsert(rec, num)); err != nil { + return domain.SessionRecord{}, fmt.Errorf("insert session %s: %w", rec.ID, err) + } + return rec, nil +} + +// UpdateSession writes the full mutable state of an existing session. The +// id/project/num/created_at are immutable and not touched here. +func (s *Store) UpdateSession(ctx context.Context, rec domain.SessionRecord) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.UpdateSession(ctx, recordToUpdate(rec)) } -// Get returns the full record (no derived status) for a session. -func (s *Store) Get(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { - row, err := s.q.GetSession(ctx, string(id)) +// GetSession returns the full record for a session, or ok=false if absent. +func (s *Store) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { + row, err := s.qr.GetSession(ctx, string(id)) if errors.Is(err, sql.ErrNoRows) { return domain.SessionRecord{}, false, nil } @@ -57,71 +86,49 @@ func (s *Store) Get(ctx context.Context, id domain.SessionID) (domain.SessionRec return rowToRecord(row), true, nil } -// List returns every record for a project (no archive filter — mirrors the -// in-memory store contract; terminal filtering is the caller's job). -func (s *Store) List(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { - rows, err := s.q.ListSessionsByProject(ctx, string(project)) +// ListSessions returns every session in a project, ordered by num. +func (s *Store) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { + rows, err := s.qr.ListSessionsByProject(ctx, string(project)) if err != nil { return nil, fmt.Errorf("list sessions for %s: %w", project, err) } - out := make([]domain.SessionRecord, 0, len(rows)) - for _, row := range rows { - out = append(out, rowToRecord(row)) - } - return out, nil + return mapSessionRows(rows), nil } -// ListAll returns every persisted session across all projects. The CDC snapshot -// source uses it to rebuild current state after a log-rotation gap. -func (s *Store) ListAll(ctx context.Context) ([]domain.SessionRecord, error) { - rows, err := s.q.ListAllSessions(ctx) +// ListAllSessions returns every session across all projects. +func (s *Store) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) { + rows, err := s.qr.ListAllSessions(ctx) if err != nil { return nil, fmt.Errorf("list all sessions: %w", err) } + return mapSessionRows(rows), nil +} + +// DeleteSession removes a session (cascades to its pr/checks/comments). +func (s *Store) DeleteSession(ctx context.Context, id domain.SessionID) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.DeleteSession(ctx, string(id)) +} + +func mapSessionRows(rows []gen.Session) []domain.SessionRecord { out := make([]domain.SessionRecord, 0, len(rows)) - for _, row := range rows { - out = append(out, rowToRecord(row)) + for _, r := range rows { + out = append(out, rowToRecord(r)) } - return out, nil + return out } -// GetMetadata returns the typed metadata for a session, or the zero value if the -// session has no metadata row yet. -func (s *Store) GetMetadata(ctx context.Context, id domain.SessionID) (domain.SessionMetadata, error) { - row, err := s.q.GetSessionMetadata(ctx, string(id)) - if errors.Is(err, sql.ErrNoRows) { - return domain.SessionMetadata{}, nil - } +// inTx runs fn inside a single write transaction on the writer connection, +// rolling back on error. The caller must already hold writeMu. +func (s *Store) inTx(ctx context.Context, what string, fn func(*gen.Queries) error) error { + tx, err := s.writeDB.BeginTx(ctx, nil) if err != nil { - return domain.SessionMetadata{}, fmt.Errorf("get metadata %s: %w", id, err) + return fmt.Errorf("begin %s: %w", what, err) } - return domain.SessionMetadata{ - Branch: row.Branch, - WorkspacePath: row.WorkspacePath, - RuntimeHandleID: row.RuntimeHandleID, - RuntimeName: row.RuntimeName, - AgentSessionID: row.AgentSessionID, - Prompt: row.Prompt, - }, nil -} - -// PatchMetadata merges meta into the session's metadata. It is outside the -// canonical write path: no revision bump, no CDC event. Empty fields are left -// unchanged (see UpsertSessionMetadata), so a partial patch is non-destructive. -func (s *Store) PatchMetadata(ctx context.Context, id domain.SessionID, meta domain.SessionMetadata) error { - if meta.IsZero() { - return nil + defer tx.Rollback() + if err := fn(s.qw.WithTx(tx)); err != nil { + return fmt.Errorf("%s: %w", what, err) } - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.UpsertSessionMetadata(ctx, gen.UpsertSessionMetadataParams{ - SessionID: string(id), - Branch: meta.Branch, - WorkspacePath: meta.WorkspacePath, - RuntimeHandleID: meta.RuntimeHandleID, - RuntimeName: meta.RuntimeName, - AgentSessionID: meta.AgentSessionID, - Prompt: meta.Prompt, - UpdatedAt: time.Now().UTC(), - }) + return tx.Commit() } diff --git a/backend/internal/storage/sqlite/store_test.go b/backend/internal/storage/sqlite/store_test.go index a197f3af14..55165c41fe 100644 --- a/backend/internal/storage/sqlite/store_test.go +++ b/backend/internal/storage/sqlite/store_test.go @@ -3,306 +3,314 @@ package sqlite import ( "context" "fmt" - "strings" "sync" "testing" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) func newTestStore(t *testing.T) *Store { t.Helper() - db, err := Open(t.TempDir()) + s, err := Open(t.TempDir()) if err != nil { t.Fatalf("open: %v", err) } - t.Cleanup(func() { db.Close() }) - return NewStore(db) + t.Cleanup(func() { _ = s.Close() }) + return s } -func sampleRecord(id string) domain.SessionRecord { +func seedProject(t *testing.T, s *Store, id string) { + t.Helper() + if err := s.UpsertProject(context.Background(), ProjectRow{ + ID: id, Path: "/tmp/" + id, RegisteredAt: time.Now().UTC().Truncate(time.Second), + }); err != nil { + t.Fatalf("seed project %s: %v", id, err) + } +} + +func sampleRecord(project string) domain.SessionRecord { now := time.Now().UTC().Truncate(time.Second) return domain.SessionRecord{ - ID: domain.SessionID(id), - ProjectID: "proj", - IssueID: "issue-1", + ProjectID: domain.ProjectID(project), Kind: domain.KindWorker, - CreatedAt: now, - UpdatedAt: now, Lifecycle: domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress}, - PR: domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonNotCreated}, - Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, - Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, + Version: domain.LifecycleVersion, + Harness: domain.HarnessClaudeCode, + IsAlive: true, + Session: domain.SessionSubstate{State: domain.SessionWorking}, + Activity: domain.ActivitySubstate{ + State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative, + }, }, + Metadata: domain.SessionMetadata{Branch: "feat/x", WorkspacePath: "/ws"}, + CreatedAt: now, + UpdatedAt: now, } } -func TestUpsertInsertThenUpdateBumpsRevision(t *testing.T) { +func TestProjectCRUDAndArchive(t *testing.T) { s := newTestStore(t) ctx := context.Background() - rec := sampleRecord("s1") + seedProject(t, s, "mer") - if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { - t.Fatalf("insert: %v", err) - } - lc, ok, err := s.Load(ctx, "s1") + got, ok, err := s.GetProject(ctx, "mer") if err != nil || !ok { - t.Fatalf("load after insert: ok=%v err=%v", ok, err) + t.Fatalf("get: ok=%v err=%v", ok, err) } - if lc.Revision != 1 { - t.Fatalf("revision after insert = %d, want 1", lc.Revision) + if got.ID != "mer" || got.Path != "/tmp/mer" { + t.Fatalf("project = %+v", got) } - - // Update must carry the loaded revision (1) and persist as 2. - rec.Lifecycle.Revision = 1 - rec.Lifecycle.Session.State = domain.SessionIdle - if err := s.Upsert(ctx, rec, ports.EventSessionStateChanged); err != nil { - t.Fatalf("update: %v", err) + if list, _ := s.ListProjects(ctx); len(list) != 1 { + t.Fatalf("active list = %d, want 1", len(list)) } - lc, _, _ = s.Load(ctx, "s1") - if lc.Revision != 2 { - t.Fatalf("revision after update = %d, want 2", lc.Revision) + // archive hides from the active list but still resolves by id. + if err := s.ArchiveProject(ctx, "mer", time.Now().UTC()); err != nil { + t.Fatal(err) } - if lc.Session.State != domain.SessionIdle { - t.Fatalf("state after update = %q, want idle", lc.Session.State) + if list, _ := s.ListProjects(ctx); len(list) != 0 { + t.Fatalf("after archive, active list = %d, want 0", len(list)) } -} - -func TestUpsertStaleRevisionMismatch(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - rec := sampleRecord("s1") - if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { - t.Fatalf("insert: %v", err) + if _, ok, _ := s.GetProject(ctx, "mer"); !ok { + t.Fatal("archived project must still resolve by id") } - - // Stored revision is 1; submitting revision 0 (stale) must mismatch and - // write nothing new (no extra outbox/change_log rows). - rec.Lifecycle.Revision = 0 - err := s.Upsert(ctx, rec, ports.EventSessionStateChanged) - if err == nil || !strings.Contains(err.Error(), "revision mismatch") { - t.Fatalf("stale update err = %v, want revision mismatch", err) - } - assertOutboxCount(t, s, ctx, 1) } -func TestUpsertInsertNonZeroRevisionErrors(t *testing.T) { +func TestSessionCreateAssignsPerProjectID(t *testing.T) { s := newTestStore(t) ctx := context.Background() - rec := sampleRecord("s1") - rec.Lifecycle.Revision = 5 - err := s.Upsert(ctx, rec, ports.EventSessionCreated) - if err == nil || !strings.Contains(err.Error(), "revision mismatch") { - t.Fatalf("insert with revision 5 err = %v, want revision mismatch", err) + seedProject(t, s, "mer") + seedProject(t, s, "ao") + + r1, err := s.CreateSession(ctx, sampleRecord("mer")) + if err != nil { + t.Fatal(err) } - // Nothing should be persisted. - if _, ok, _ := s.Get(ctx, "s1"); ok { - t.Fatal("session persisted despite revision-mismatch insert") + r2, _ := s.CreateSession(ctx, sampleRecord("mer")) + r3, _ := s.CreateSession(ctx, sampleRecord("ao")) + if r1.ID != "mer-1" || r2.ID != "mer-2" || r3.ID != "ao-1" { + t.Fatalf("ids = %s, %s, %s; want mer-1, mer-2, ao-1", r1.ID, r2.ID, r3.ID) + } + got, ok, err := s.GetSession(ctx, "mer-1") + if err != nil || !ok { + t.Fatalf("get: ok=%v err=%v", ok, err) + } + if got.Lifecycle.Session.State != domain.SessionWorking || !got.Lifecycle.IsAlive || + got.Lifecycle.Harness != domain.HarnessClaudeCode || got.Metadata.Branch != "feat/x" { + t.Fatalf("round-trip mismatch: %+v", got) + } + if list, _ := s.ListSessions(ctx, "mer"); len(list) != 2 { + t.Fatalf("list mer = %d, want 2", len(list)) + } + if all, _ := s.ListAllSessions(ctx); len(all) != 3 { + t.Fatalf("list all = %d, want 3", len(all)) } - assertOutboxCount(t, s, ctx, 0) } -func TestUpsertOutboxAtomicityAndOrdering(t *testing.T) { +func TestSessionUpdateAndDetecting(t *testing.T) { s := newTestStore(t) ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) - rec := sampleRecord("s1") - if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { - t.Fatalf("insert: %v", err) - } - rec.Lifecycle.Revision = 1 - if err := s.Upsert(ctx, rec, ports.EventSessionStateChanged); err != nil { - t.Fatalf("update: %v", err) - } - - rows, err := NewStore(s.db).q.ListUnsentOutbox(ctx, 100) - if err != nil { - t.Fatalf("list outbox: %v", err) - } - if len(rows) != 2 { - t.Fatalf("outbox rows = %d, want 2", len(rows)) + r.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionDetecting} + r.Lifecycle.IsAlive = false + r.Lifecycle.Detecting = &domain.DetectingState{Attempts: 2, StartedAt: r.CreatedAt, EvidenceHash: "abc"} + if err := s.UpdateSession(ctx, r); err != nil { + t.Fatal(err) } - // seq strictly monotonic, event types verbatim, revisions 1 then 2. - if rows[0].ChangeLogSeq != 1 || rows[1].ChangeLogSeq != 2 { - t.Fatalf("seq not monotonic: %d, %d", rows[0].ChangeLogSeq, rows[1].ChangeLogSeq) + got, _, _ := s.GetSession(ctx, r.ID) + if got.Lifecycle.Session.State != domain.SessionDetecting || got.Lifecycle.IsAlive { + t.Fatalf("update not persisted: %+v", got.Lifecycle.Session) } - if rows[0].EventType != string(ports.EventSessionCreated) || rows[1].EventType != string(ports.EventSessionStateChanged) { - t.Fatalf("event types = %q, %q", rows[0].EventType, rows[1].EventType) + if got.Lifecycle.Detecting == nil || got.Lifecycle.Detecting.Attempts != 2 || got.Lifecycle.Detecting.EvidenceHash != "abc" { + t.Fatalf("detecting not round-tripped: %+v", got.Lifecycle.Detecting) } - if rows[0].Revision != 1 || rows[1].Revision != 2 { - t.Fatalf("revisions = %d, %d, want 1, 2", rows[0].Revision, rows[1].Revision) + // clearing detecting persists as nil. + got.Lifecycle.Detecting = nil + got.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionWorking} + _ = s.UpdateSession(ctx, got) + again, _, _ := s.GetSession(ctx, r.ID) + if again.Lifecycle.Detecting != nil { + t.Fatalf("detecting should clear to nil, got %+v", again.Lifecycle.Detecting) } } -func TestGetListRoundTrip(t *testing.T) { +func TestPRCRUD(t *testing.T) { s := newTestStore(t) ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + now := time.Now().UTC().Truncate(time.Second) - a := sampleRecord("a") - b := sampleRecord("b") - b.ProjectID = "other" - if err := s.Upsert(ctx, a, ports.EventSessionCreated); err != nil { - t.Fatal(err) + pr := PRRow{ + URL: "https://gh/pr/1", SessionID: string(r.ID), Number: 1, State: "open", + ReviewDecision: "review_required", CIState: "failing", Mergeability: "blocked", UpdatedAt: now, } - if err := s.Upsert(ctx, b, ports.EventSessionCreated); err != nil { + if err := s.UpsertPR(ctx, pr); err != nil { t.Fatal(err) } - - got, ok, err := s.Get(ctx, "a") - if err != nil || !ok { - t.Fatalf("get a: ok=%v err=%v", ok, err) + got, ok, err := s.GetPR(ctx, pr.URL) + if err != nil || !ok || got != pr { + t.Fatalf("get pr: ok=%v err=%v got=%+v", ok, err, got) } - if got.ID != "a" || got.Lifecycle.Revision != 1 || got.IssueID != "issue-1" { - t.Fatalf("unexpected record: %+v", got) + if list, _ := s.ListPRsBySession(ctx, string(r.ID)); len(list) != 1 { + t.Fatalf("list prs = %d, want 1", len(list)) } - if !got.Metadata.IsZero() { - t.Fatalf("Get must not reconstruct metadata, got %v", got.Metadata) - } - - list, err := s.List(ctx, "proj") - if err != nil { + if err := s.DeletePR(ctx, pr.URL); err != nil { t.Fatal(err) } - if len(list) != 1 || list[0].ID != "a" { - t.Fatalf("List(proj) = %+v, want only a", list) + if _, ok, _ := s.GetPR(ctx, pr.URL); ok { + t.Fatal("pr should be gone") } } -func TestMetadataSideChannel(t *testing.T) { +func TestPRChecksLoopBrakeQuery(t *testing.T) { s := newTestStore(t) ctx := context.Background() - if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { - t.Fatal(err) - } - - if err := s.PatchMetadata(ctx, "s1", domain.SessionMetadata{Branch: "feat/x", Prompt: "do it"}); err != nil { - t.Fatalf("patch: %v", err) - } - // A partial patch (only Branch) must not clobber the earlier Prompt. - if err := s.PatchMetadata(ctx, "s1", domain.SessionMetadata{Branch: "feat/y"}); err != nil { - t.Fatalf("patch overwrite: %v", err) - } + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + now := time.Now().UTC().Truncate(time.Second) + _ = s.UpsertPR(ctx, PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: now}) - m, err := s.GetMetadata(ctx, "s1") + // three consecutive failing runs of "build" (one per commit). + for i := 1; i <= 3; i++ { + if err := s.RecordCheck(ctx, PRCheckRow{ + PRURL: "pr1", Name: "build", CommitHash: fmt.Sprintf("c%d", i), + Status: "failed", CreatedAt: now.Add(time.Duration(i) * time.Second), + }); err != nil { + t.Fatal(err) + } + } + last3, err := s.RecentCheckStatuses(ctx, "pr1", "build", 3) if err != nil { t.Fatal(err) } - if m.Branch != "feat/y" || m.Prompt != "do it" { - t.Fatalf("metadata = %+v", m) + if len(last3) != 3 || last3[0] != "failed" || last3[1] != "failed" || last3[2] != "failed" { + t.Fatalf("recent statuses = %v, want 3x failed (loop brake would trip)", last3) } - // Metadata writes must not bump revision (off the canonical path). - lc, _, _ := s.Load(ctx, "s1") - if lc.Revision != 1 { - t.Fatalf("revision = %d after metadata patch, want 1 (no bump)", lc.Revision) + // a pass on a newer commit breaks the streak. + _ = s.RecordCheck(ctx, PRCheckRow{PRURL: "pr1", Name: "build", CommitHash: "c4", Status: "passed", CreatedAt: now.Add(4 * time.Second)}) + last3, _ = s.RecentCheckStatuses(ctx, "pr1", "build", 3) + if last3[0] != "passed" { + t.Fatalf("most recent should be passed, got %v", last3) } } -func TestDetectingRoundTrip(t *testing.T) { +func TestPRCommentsReplace(t *testing.T) { s := newTestStore(t) ctx := context.Background() - rec := sampleRecord("s1") - rec.Lifecycle.Session.State = domain.SessionDetecting - rec.Lifecycle.Detecting = &domain.DetectingState{ - Attempts: 2, - StartedAt: time.Now().UTC().Truncate(time.Second), - EvidenceHash: "abc123", - } - if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { - t.Fatal(err) - } - lc, _, _ := s.Load(ctx, "s1") - if lc.Detecting == nil { - t.Fatal("Detecting lost on round-trip") - } - if lc.Detecting.Attempts != 2 || lc.Detecting.EvidenceHash != "abc123" { - t.Fatalf("detecting = %+v", lc.Detecting) - } + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + now := time.Now().UTC().Truncate(time.Second) + _ = s.UpsertPR(ctx, PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: now}) - // Clearing Detecting must null the columns back out. - rec.Lifecycle.Revision = 1 - rec.Lifecycle.Detecting = nil - if err := s.Upsert(ctx, rec, ports.EventSessionStateChanged); err != nil { - t.Fatal(err) - } - lc, _, _ = s.Load(ctx, "s1") - if lc.Detecting != nil { - t.Fatalf("Detecting not cleared: %+v", lc.Detecting) + _ = s.ReplacePRComments(ctx, "pr1", []PRCommentRow{ + {PRURL: "pr1", CommentID: "c1", Author: "a", File: "a.go", Line: 1, Body: "nit", CreatedAt: now}, + {PRURL: "pr1", CommentID: "c2", Author: "b", File: "b.go", Line: 2, Body: "bug", Resolved: true, CreatedAt: now.Add(time.Second)}, + }) + if list, _ := s.ListPRComments(ctx, "pr1"); len(list) != 2 { + t.Fatalf("comments = %d, want 2", len(list)) + } + // replace with a smaller set drops the rest. + _ = s.ReplacePRComments(ctx, "pr1", []PRCommentRow{{PRURL: "pr1", CommentID: "c1", Body: "x", CreatedAt: now}}) + if list, _ := s.ListPRComments(ctx, "pr1"); len(list) != 1 { + t.Fatalf("after replace, comments = %d, want 1", len(list)) } } -func TestLoadGetMissing(t *testing.T) { +func TestCDCTriggersPopulateChangeLog(t *testing.T) { s := newTestStore(t) ctx := context.Background() - if _, ok, err := s.Load(ctx, "nope"); ok || err != nil { - t.Fatalf("Load missing: ok=%v err=%v", ok, err) - } - if _, ok, err := s.Get(ctx, "nope"); ok || err != nil { - t.Fatalf("Get missing: ok=%v err=%v", ok, err) - } - if m, err := s.GetMetadata(ctx, "nope"); err != nil || !m.IsZero() { - t.Fatalf("GetMetadata missing: m=%v err=%v", m, err) - } -} + seedProject(t, s, "mer") -func assertOutboxCount(t *testing.T, s *Store, ctx context.Context, want int) { - t.Helper() - rows, err := s.q.ListUnsentOutbox(ctx, 1000) + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + // a real state change logs; a metadata-only change does not (WHEN guard). + r.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionIdle} + _ = s.UpdateSession(ctx, r) + r.Metadata.Prompt = "only metadata changed" + _ = s.UpdateSession(ctx, r) + // a PR insert logs too. + _ = s.UpsertPR(ctx, PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: r.UpdatedAt}) + + evs, err := s.ReadChangeLogAfter(ctx, 0, 100) if err != nil { - t.Fatalf("list outbox: %v", err) + t.Fatal(err) } - if len(rows) != want { - t.Fatalf("outbox count = %d, want %d", len(rows), want) + var types []string + for _, e := range evs { + if e.ProjectID != "mer" { + t.Fatalf("event project = %s, want mer", e.ProjectID) + } + types = append(types, e.EventType) + } + want := []string{"session_created", "session_updated", "pr_created"} + if len(types) != 3 || types[0] != want[0] || types[1] != want[1] || types[2] != want[2] { + t.Fatalf("change_log event types = %v, want %v (metadata-only update suppressed)", types, want) + } + max, _ := s.MaxChangeLogSeq(ctx) + if max != int64(len(evs)) { + t.Fatalf("max seq = %d, want %d", max, len(evs)) } } -// TestConcurrentReadsAndWrites exercises the read-pool + write-mutex model: -// many writers (each its own session) run alongside many readers hammering -// ListAll. Reads must not be serialized behind writes, writes must not corrupt -// or error under the revision-CAS, and the final state must be exact. Run under -// -race this also guards the writeMu discipline. -func TestConcurrentReadsAndWrites(t *testing.T) { +func TestConcurrentSessionCreateAssignsUniqueNums(t *testing.T) { s := newTestStore(t) ctx := context.Background() - const n = 16 + seedProject(t, s, "mer") + const n = 20 var wg sync.WaitGroup - errc := make(chan error, n*2) - + ids := make([]string, n) for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() - if err := s.Upsert(ctx, sampleRecord(fmt.Sprintf("s%02d", i)), ports.EventSessionCreated); err != nil { - errc <- err + r, err := s.CreateSession(ctx, sampleRecord("mer")) + if err != nil { + t.Errorf("create: %v", err) + return } + ids[i] = string(r.ID) }(i) } - for i := 0; i < n; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < 25; j++ { - if _, err := s.ListAll(ctx); err != nil { - errc <- err - return - } - } - }() - } wg.Wait() - close(errc) - for err := range errc { - t.Fatalf("concurrent op error: %v", err) + + seen := map[string]bool{} + for _, id := range ids { + if id == "" || seen[id] { + t.Fatalf("duplicate or empty id: %q in %v", id, ids) + } + seen[id] = true } + if all, _ := s.ListAllSessions(ctx); len(all) != n { + t.Fatalf("created %d sessions, want %d", len(all), n) + } +} - got, err := s.ListAll(ctx) - if err != nil { +func TestTerminationReasonRoundTripAndCheck(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + + // terminate with a valid reason -> round-trips. + r.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionTerminated} + r.Lifecycle.TerminationReason = domain.TermManuallyKilled + if err := s.UpdateSession(ctx, r); err != nil { t.Fatal(err) } - if len(got) != n { - t.Fatalf("after %d concurrent inserts, ListAll returned %d", n, len(got)) + got, _, _ := s.GetSession(ctx, r.ID) + if got.Lifecycle.TerminationReason != domain.TermManuallyKilled { + t.Fatalf("termination_reason = %q, want manually_killed", got.Lifecycle.TerminationReason) + } + if domain.DeriveStatus(got.Lifecycle, domain.PRFacts{}) != domain.StatusKilled { + t.Fatal("terminated+manually_killed should derive to killed") + } + + // an off-enum reason is rejected by the CHECK constraint. + r.Lifecycle.TerminationReason = domain.TerminationReason("definitely_not_a_reason") + if err := s.UpdateSession(ctx, r); err == nil { + t.Fatal("expected CHECK constraint to reject an invalid termination_reason") } } diff --git a/backend/internal/storage/sqlite/upsert.go b/backend/internal/storage/sqlite/upsert.go deleted file mode 100644 index f8ae409322..0000000000 --- a/backend/internal/storage/sqlite/upsert.go +++ /dev/null @@ -1,115 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// Upsert performs the one atomic canonical write: it CAS-checks and persists the -// session row (bumping revision), appends a change_log entry, and enqueues an -// outbox row linked to that entry's seq — all in a single transaction. Only the -// LCM calls this. -// -// Revision CAS (mirrors the in-memory store contract exactly): -// - existing row: rec.Lifecycle.Revision must equal the stored revision, else -// a revision-mismatch error and nothing is written; on match it persists at -// stored+1. -// - insert: rec.Lifecycle.Revision must be 0, persisted as 1. -func (s *Store) Upsert(ctx context.Context, rec domain.SessionRecord, eventType ports.EventType) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin upsert: %w", err) - } - defer tx.Rollback() - qtx := s.q.WithTx(tx) - - newRevision, err := casPersist(ctx, qtx, rec) - if err != nil { - return err - } - - if err := appendOutbox(ctx, qtx, rec, newRevision, eventType); err != nil { - return err - } - - return tx.Commit() -} - -// casPersist applies the revision-CAS insert-or-update and returns the new -// stored revision. -func casPersist(ctx context.Context, q *gen.Queries, rec domain.SessionRecord) (int, error) { - stored, err := q.GetSessionRevision(ctx, string(rec.ID)) - switch { - case errors.Is(err, sql.ErrNoRows): - // Insert path: incoming revision must be 0; row persists at revision 1. - if rec.Lifecycle.Revision != 0 { - return 0, fmt.Errorf("revision mismatch for insert %s: have %d, want 0", rec.ID, rec.Lifecycle.Revision) - } - rows, err := q.InsertSession(ctx, recordToInsert(rec)) - if err != nil { - return 0, fmt.Errorf("insert session %s: %w", rec.ID, err) - } - if rows != 1 { - // Another writer raced us between the revision check and the insert. - // With single-writer this should not happen; treat as a CAS failure. - return 0, fmt.Errorf("revision mismatch for insert %s: row already exists", rec.ID) - } - return 1, nil - case err != nil: - return 0, fmt.Errorf("read revision %s: %w", rec.ID, err) - default: - // Update path: incoming revision must equal the stored revision. - if int64(rec.Lifecycle.Revision) != stored { - return 0, fmt.Errorf("revision mismatch for %s: have %d, want %d", rec.ID, rec.Lifecycle.Revision, stored) - } - rows, err := q.UpdateSessionCAS(ctx, recordToUpdate(rec, stored)) - if err != nil { - return 0, fmt.Errorf("update session %s: %w", rec.ID, err) - } - if rows != 1 { - return 0, fmt.Errorf("revision mismatch for %s: stale revision %d", rec.ID, rec.Lifecycle.Revision) - } - return int(stored) + 1, nil - } -} - -// appendOutbox writes the change_log entry and threads its seq into a fresh -// outbox row. The change_log payload is the persisted record at its new revision -// (metadata is excluded by SessionRecord's json:"-" tag — it is not on the -// canonical path). -func appendOutbox(ctx context.Context, q *gen.Queries, rec domain.SessionRecord, newRevision int, eventType ports.EventType) error { - now := time.Now().UTC() - payload := rec - payload.Lifecycle.Revision = newRevision - payload.Lifecycle.Version = domain.LifecycleVersion - blob, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal change_log payload %s: %w", rec.ID, err) - } - - seq, err := q.InsertChangeLog(ctx, gen.InsertChangeLogParams{ - SessionID: string(rec.ID), - EventType: string(eventType), - Revision: int64(newRevision), - Payload: string(blob), - CreatedAt: now, - }) - if err != nil { - return fmt.Errorf("insert change_log %s: %w", rec.ID, err) - } - - if err := q.InsertOutbox(ctx, gen.InsertOutboxParams{ChangeLogSeq: seq, CreatedAt: now}); err != nil { - return fmt.Errorf("insert outbox %s: %w", rec.ID, err) - } - return nil -} From cdf55ebef506b4f16c8919c180d09c5ffd4f28a0 Mon Sep 17 00:00:00 2001 From: prateek Date: Sun, 31 May 2026 07:16:06 +0530 Subject: [PATCH 054/250] feat(backend): port lifecycle lane onto the new storage+CDC model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the LCM, reactions, session manager, reaper, and boot wiring onto the redesigned domain model — collapsing the runtime axis to is_alive, moving PR facts to the pr table (read back as PRFacts), replacing the free-form SessionReason with a typed terminal-only TerminationReason, and dropping Revision/EventType/durable reaction-trackers (CDC is trigger-driven, escalation budgets are in-memory). - ports: SessionStore + PRWriter interfaces; PRObservation/RuntimeFacts/ ActivitySignal DTOs; drop LifecycleStore/EventType/ReactionStore. - lifecycle: single-writer reducer over is_alive; ApplyPRObservation writes the pr tables and reacts; CI-fix-loop brake derived from pr_checks history; review comments injected into the agent regardless of author (no bot detection); merge auto-terminates with pr_merged. - session: store-assigned "{project}-{n}" ids; folded metadata; status derived from PRFacts on read. - reaper: reports the four-valued probe vocabulary unchanged. - boot: trigger -> poller -> broadcaster; storeAdapter bridges *sqlite.Store. Lane shrinks 6218 -> 2803 LOC. go build/vet/test -race green. Co-Authored-By: Claude Opus 4.8 --- backend/cdc_e2e_test.go | 194 ----- backend/cdc_wiring.go | 142 +--- backend/internal/domain/lifecycle.go | 21 +- backend/internal/domain/session.go | 8 +- backend/internal/domain/status.go | 2 +- backend/internal/domain/status_test.go | 2 +- backend/internal/lifecycle/decide_bridge.go | 252 ++---- backend/internal/lifecycle/fakes_test.go | 164 ---- backend/internal/lifecycle/manager.go | 632 +++++--------- .../internal/lifecycle/manager_parity_test.go | 144 ---- backend/internal/lifecycle/manager_test.go | 802 ++++++------------ .../lifecycle/reaction_durability_test.go | 140 --- backend/internal/lifecycle/reaction_store.go | 94 -- backend/internal/lifecycle/reactions.go | 663 +++++++-------- backend/internal/lifecycle/reactions_test.go | 616 -------------- backend/internal/observe/reaper/reaper.go | 12 +- .../internal/observe/reaper/reaper_test.go | 392 ++------- backend/internal/ports/facts.go | 194 ++--- backend/internal/ports/inbound.go | 62 +- backend/internal/ports/outbound.go | 102 +-- backend/internal/session/fakes_test.go | 400 --------- backend/internal/session/manager.go | 466 ++++------ backend/internal/session/manager_test.go | 744 +++++----------- backend/lifecycle_wiring.go | 184 ++-- backend/main.go | 7 +- backend/main_test.go | 134 --- backend/wiring_test.go | 71 ++ 27 files changed, 1614 insertions(+), 5030 deletions(-) delete mode 100644 backend/cdc_e2e_test.go delete mode 100644 backend/internal/lifecycle/fakes_test.go delete mode 100644 backend/internal/lifecycle/manager_parity_test.go delete mode 100644 backend/internal/lifecycle/reaction_durability_test.go delete mode 100644 backend/internal/lifecycle/reaction_store.go delete mode 100644 backend/internal/lifecycle/reactions_test.go delete mode 100644 backend/internal/session/fakes_test.go delete mode 100644 backend/main_test.go create mode 100644 backend/wiring_test.go diff --git a/backend/cdc_e2e_test.go b/backend/cdc_e2e_test.go deleted file mode 100644 index 29b04534c2..0000000000 --- a/backend/cdc_e2e_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "path/filepath" - "sync" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/cdc" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -// These are full-stack end-to-end tests of the write+delivery path wired exactly -// as main.go wires it: real sqlite.Store -> real outboxAdapter -> real -// cdc.Publisher -> real JSONL log -> real cdc.Consumer -> real cdc.Broadcaster, -// using the REAL snapshotSource (store.ListAll) rather than a fake. The cdc -// package's own integration test covers the synchronous Drain/Poll happy path -// with a fake snapshot; these cover the two gaps it leaves: a rotation that -// resyncs from the actual sessions table, and the concurrent goroutine model -// the daemon actually runs. - -// TestE2E_RealSnapshotResyncThroughRotation forces a log rotation and asserts the -// consumer rebuilds state from the REAL sessions-table snapshot (not the -// rotated-away bytes), delivering the persisted record's payload. -func TestE2E_RealSnapshotResyncThroughRotation(t *testing.T) { - ctx := context.Background() - store := newWiringStore(t) - dir := t.TempDir() - log, err := cdc.OpenLog(dir, 80) // tiny cap: the second write forces a rotation - if err != nil { - t.Fatal(err) - } - defer log.Close() - - var mu sync.Mutex - var got []cdc.Event - bc := cdc.NewBroadcaster() - bc.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) - - con := cdc.NewConsumer("fe", filepath.Join(dir, cdc.LogFileName), store, bc, - cdc.ConsumerConfig{Snapshot: snapshotSource{store: store}}) - if _, err := con.Start(ctx); err != nil { - t.Fatal(err) - } - pub := cdc.NewPublisher(outboxAdapter{store: store}, log, cdc.PublisherConfig{}) - - // First canonical write: drained and consumed live from the original file. - if err := store.Upsert(ctx, wiringRec("s1"), ports.EventSessionCreated); err != nil { - t.Fatal(err) - } - if err := pub.Drain(ctx); err != nil { - t.Fatal(err) - } - if err := con.Poll(ctx); err != nil { - t.Fatal(err) - } - mu.Lock() - before := len(got) - mu.Unlock() - - // Second write pushes the log past its cap -> rotation. The consumer sees a - // fresh file and must resync from the sessions table. - r := wiringRec("s1") - r.Lifecycle.Revision = 1 - if err := store.Upsert(ctx, r, ports.EventSessionStateChanged); err != nil { - t.Fatal(err) - } - if err := pub.Drain(ctx); err != nil { - t.Fatal(err) - } - if err := con.Poll(ctx); err != nil { - t.Fatal(err) - } - - mu.Lock() - defer mu.Unlock() - if len(got) <= before { - t.Fatalf("resync delivered nothing after rotation (got %d, before %d)", len(got), before) - } - // A real session_snapshot for s1 must have been delivered, carrying the full - // record persisted in the sessions table. - var snap *cdc.Event - for i := range got { - if got[i].EventType == "session_snapshot" && got[i].SessionID == "s1" { - snap = &got[i] - } - } - if snap == nil { - t.Fatalf("no real session_snapshot delivered after rotation; got %+v", got) - } - var rec domain.SessionRecord - if err := json.Unmarshal([]byte(snap.Payload), &rec); err != nil { - t.Fatalf("snapshot payload not a SessionRecord: %v", err) - } - if rec.ID != "s1" || rec.Lifecycle.Session.State != domain.SessionWorking { - t.Fatalf("snapshot payload mismatch: %+v", rec) - } - // The consumer's durable offset advanced to the change_log head. - off, err := store.GetOffset(ctx, "fe") - if err != nil { - t.Fatal(err) - } - maxSeq, err := store.MaxChangeLogSeq(ctx) - if err != nil { - t.Fatal(err) - } - if off != maxSeq { - t.Fatalf("offset = %d, want change_log head %d", off, maxSeq) - } -} - -// TestE2E_ConcurrentPublisherConsumer runs the publisher and consumer as the -// daemon runs them — independent goroutines on their own tickers — and asserts -// every canonical write is delivered exactly once, in order, with the offset -// landing at the head. Run under -race this also guards the broadcaster/consumer -// hand-off. -func TestE2E_ConcurrentPublisherConsumer(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - store := newWiringStore(t) - dir := t.TempDir() - log, err := cdc.OpenLog(dir, 0) - if err != nil { - t.Fatal(err) - } - defer log.Close() - - var mu sync.Mutex - var got []cdc.Event - bc := cdc.NewBroadcaster() - bc.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) - - pub := cdc.NewPublisher(outboxAdapter{store: store}, log, cdc.PublisherConfig{}) - con := cdc.NewConsumer("fe", filepath.Join(dir, cdc.LogFileName), store, bc, cdc.ConsumerConfig{}) - - pubDone := pub.Start(ctx) - conDone, err := con.Start(ctx) - if err != nil { - t.Fatal(err) - } - - const n = 5 - for i := 0; i < n; i++ { - r := wiringRec("s1") - r.Lifecycle.Revision = i - evt := ports.EventSessionStateChanged - if i == 0 { - evt = ports.EventSessionCreated - } - if err := store.Upsert(ctx, r, evt); err != nil { - t.Fatalf("upsert %d: %v", i, err) - } - } - - // Bounded wait for the goroutine pipeline to deliver everything. - deadline := time.Now().Add(5 * time.Second) - for { - mu.Lock() - count := len(got) - mu.Unlock() - if count >= n { - break - } - if time.Now().After(deadline) { - t.Fatalf("timed out: delivered %d/%d events", count, n) - } - time.Sleep(20 * time.Millisecond) - } - - cancel() - <-pubDone - <-conDone - - mu.Lock() - defer mu.Unlock() - if len(got) != n { - t.Fatalf("delivered %d events, want %d", len(got), n) - } - for i, e := range got { - if e.Seq != int64(i+1) { - t.Fatalf("event %d has seq %d, want %d (out-of-order or duplicate)", i, e.Seq, i+1) - } - } - off, err := store.GetOffset(context.Background(), "fe") - if err != nil { - t.Fatal(err) - } - if off != n { - t.Fatalf("offset = %d, want %d", off, n) - } -} diff --git a/backend/cdc_wiring.go b/backend/cdc_wiring.go index cfae4fdb9b..d824cbab12 100644 --- a/backend/cdc_wiring.go +++ b/backend/cdc_wiring.go @@ -3,140 +3,62 @@ package main import ( "context" "encoding/json" - "fmt" "log/slog" - "path/filepath" - "time" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) -// cdcConsumerName is the durable consumer_offsets key for the in-process FE -// broadcast consumer. A second transport (e.g. a cloud relay) would use its own -// key so each tracks an independent cursor. -const cdcConsumerName = "fe-broadcast" - -// cdcPipeline owns the running CDC goroutines and the broadcaster the FE -// transport subscribes to. It is the durable change-delivery substrate: the -// publisher drains the outbox to JSONL, the consumer tails the log and fans out -// through the broadcaster, and the janitor reclaims acknowledged outbox rows. +// cdcPipeline owns the running CDC poller and the broadcaster the SSE transport +// subscribes to. The DB triggers write change_log; the poller tails it and fans +// each new event out through the broadcaster. Durable catch-up is the client's +// job (it reads change_log from its own Last-Event-ID), so the poller only +// pushes live events and re-seeks to head on restart. type cdcPipeline struct { Broadcaster *cdc.Broadcaster - log *cdc.Log - dones []<-chan struct{} + done <-chan struct{} } -// startCDC opens the JSONL log and starts the publisher, consumer, and janitor -// against store, returning a handle whose Stop waits for the goroutines to -// drain after ctx is cancelled. The goroutines stop when ctx is cancelled. -func startCDC(ctx context.Context, store *sqlite.Store, dataDir string, logger *slog.Logger) (*cdcPipeline, error) { - log, err := cdc.OpenLog(dataDir, 0) - if err != nil { - return nil, fmt.Errorf("open cdc log: %w", err) - } - +// startCDC seeks the poller to the current head and starts its loop. It stops +// when ctx is cancelled; Stop waits for it to drain. +func startCDC(ctx context.Context, store *sqlite.Store, logger *slog.Logger) (*cdcPipeline, error) { bcast := cdc.NewBroadcaster() - logPath := filepath.Join(dataDir, cdc.LogFileName) - - pub := cdc.NewPublisher(outboxAdapter{store}, log, cdc.PublisherConfig{Logger: logger}) - con := cdc.NewConsumer(cdcConsumerName, logPath, store, bcast, cdc.ConsumerConfig{ - Snapshot: snapshotSource{store}, - Logger: logger, - }) - jan := cdc.NewJanitor(store, cdc.JanitorConfig{Logger: logger}) - - conDone, err := con.Start(ctx) - if err != nil { - log.Close() - return nil, fmt.Errorf("start cdc consumer: %w", err) + poller := cdc.NewPoller(cdcSource{store}, bcast, cdc.PollerConfig{Logger: logger}) + if err := poller.SeekToHead(ctx); err != nil { + return nil, err } - - return &cdcPipeline{ - Broadcaster: bcast, - log: log, - dones: []<-chan struct{}{pub.Start(ctx), conDone, jan.Start(ctx)}, - }, nil + return &cdcPipeline{Broadcaster: bcast, done: poller.Start(ctx)}, nil } -// Stop waits for every CDC goroutine to exit (the caller must have cancelled the -// ctx passed to startCDC) and closes the log file. +// Stop waits for the poller goroutine to exit (the caller must have cancelled the +// ctx passed to startCDC). func (p *cdcPipeline) Stop() error { - for _, d := range p.dones { - <-d - } - return p.log.Close() + <-p.done + return nil } -// outboxAdapter bridges *sqlite.Store's outbox methods to cdc.OutboxStore, -// mapping the storage-native OutboxEvent to the transport's PendingEvent. (The -// offset and vacuum contracts need no adapter — *sqlite.Store satisfies -// cdc.OffsetStore and cdc.Vacuum directly.) -type outboxAdapter struct{ store *sqlite.Store } +// cdcSource adapts *sqlite.Store's change_log reads to cdc.Source. +type cdcSource struct{ store *sqlite.Store } -func (a outboxAdapter) ListUnsent(ctx context.Context, limit int) ([]cdc.PendingEvent, error) { - evs, err := a.store.ListUnsent(ctx, limit) +func (s cdcSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) { + rows, err := s.store.ReadChangeLogAfter(ctx, after, limit) if err != nil { return nil, err } - out := make([]cdc.PendingEvent, len(evs)) - for i, e := range evs { - out[i] = cdc.PendingEvent{ - OutboxID: e.OutboxID, - Event: cdc.Event{ - Seq: e.Seq, - SessionID: e.SessionID, - EventType: e.EventType, - Revision: e.Revision, - Payload: e.Payload, - CreatedAt: e.CreatedAt, - }, + out := make([]cdc.Event, len(rows)) + for i, r := range rows { + out[i] = cdc.Event{ + Seq: r.Seq, + ProjectID: r.ProjectID, + SessionID: r.SessionID, + Type: cdc.EventType(r.EventType), + Payload: json.RawMessage(r.Payload), + CreatedAt: r.CreatedAt, } } return out, nil } -func (a outboxAdapter) MarkSent(ctx context.Context, id int64, at time.Time) error { - return a.store.MarkSent(ctx, id, at) -} - -func (a outboxAdapter) MarkFailed(ctx context.Context, id int64, msg string) error { - return a.store.MarkFailed(ctx, id, msg) -} - -// snapshotSource rebuilds current state from the sessions table after a -// log-rotation gap, emitting one full-state event per session. Each event -// carries the change_log high-water seq so the consumer resumes its cursor -// there; the payload mirrors the canonical change_log payload (metadata -// excluded, version stamped) so subscribers parse snapshot and live events the -// same way. -type snapshotSource struct{ store *sqlite.Store } - -func (s snapshotSource) Snapshot(ctx context.Context) ([]cdc.Event, int64, error) { - recs, err := s.store.ListAll(ctx) - if err != nil { - return nil, 0, err - } - maxSeq, err := s.store.MaxChangeLogSeq(ctx) - if err != nil { - return nil, 0, err - } - events := make([]cdc.Event, 0, len(recs)) - for _, r := range recs { - r.Lifecycle.Version = domain.LifecycleVersion - blob, err := json.Marshal(r) - if err != nil { - return nil, 0, fmt.Errorf("marshal snapshot %s: %w", r.ID, err) - } - events = append(events, cdc.Event{ - Seq: maxSeq, - SessionID: string(r.ID), - EventType: "session_snapshot", - Revision: int64(r.Lifecycle.Revision), - Payload: string(blob), - CreatedAt: r.UpdatedAt, - }) - } - return events, maxSeq, nil +func (s cdcSource) LatestSeq(ctx context.Context) (int64, error) { + return s.store.MaxChangeLogSeq(ctx) } diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go index b56367616a..a82ea85aed 100644 --- a/backend/internal/domain/lifecycle.go +++ b/backend/internal/domain/lifecycle.go @@ -103,17 +103,16 @@ type SessionSubstate struct { // axis. The zero value (Exists=false) means "no PR", which derivation treats as // "session has no PR". type PRFacts struct { - URL string - Number int - Exists bool - Draft bool - Merged bool - Closed bool - CI CIState - Review ReviewDecision - Mergeability Mergeability - BotComments bool - IdleBeyond bool // idle past the stuck threshold + URL string + Number int + Exists bool + Draft bool + Merged bool + Closed bool + CI CIState + Review ReviewDecision + Mergeability Mergeability + ReviewComments bool // has unresolved review comments (any author) to address } type CIState string diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index c9cd8d9632..2b81088a40 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -21,11 +21,11 @@ const ( // operational handles and seed inputs the Session Manager and reaper need but // that are NOT part of the canonical lifecycle. The set of fields is fixed here // (no free-form keys), so what a session can carry is a compile-time fact, and -// it is persisted 1:1 in the session_metadata table off the CDC path. +// it is folded into the sessions row off the CDC path. // -// Empty fields mean "unset": PatchMetadata never overwrites a stored value with -// an empty one, so a partial write (spawn setting only the runtime handle) does -// not clobber a value set earlier (the branch set at creation). +// Empty fields mean "unset": the LCM merges metadata without overwriting a +// stored value with an empty one, so a partial write (spawn setting only the +// runtime handle) does not clobber a value set earlier (the branch at creation). type SessionMetadata struct { Branch string `json:"branch,omitempty"` WorkspacePath string `json:"workspacePath,omitempty"` diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index 0ff5c0fd83..3ae1e00c53 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -95,7 +95,7 @@ func prPipelineStatus(pr PRFacts) SessionStatus { return StatusCIFailed case pr.Draft: return StatusDraft - case pr.Review == ReviewChangesRequest || pr.BotComments: + case pr.Review == ReviewChangesRequest || pr.ReviewComments: return StatusChangesRequested case pr.Mergeability == MergeMergeable: return StatusMergeable diff --git a/backend/internal/domain/status_test.go b/backend/internal/domain/status_test.go index ae63271cf5..57512577c1 100644 --- a/backend/internal/domain/status_test.go +++ b/backend/internal/domain/status_test.go @@ -42,7 +42,7 @@ func TestDeriveStatus(t *testing.T) { {"draft PR failing CI -> ci_failed (CI dominates)", sess(SessionWorking), openPR(func(f *PRFacts) { f.Draft = true; f.CI = CIFailing }), StatusCIFailed}, {"draft PR ignores review state -> draft", sess(SessionWorking), openPR(func(f *PRFacts) { f.Draft = true; f.Review = ReviewApproved }), StatusDraft}, {"open PR changes_requested", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewChangesRequest }), StatusChangesRequested}, - {"open PR bot comments -> changes_requested", sess(SessionWorking), openPR(func(f *PRFacts) { f.BotComments = true }), StatusChangesRequested}, + {"open PR review comments -> changes_requested", sess(SessionWorking), openPR(func(f *PRFacts) { f.ReviewComments = true }), StatusChangesRequested}, {"open PR mergeable", sess(SessionWorking), openPR(func(f *PRFacts) { f.Mergeability = MergeMergeable }), StatusMergeable}, {"open PR approved", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewApproved }), StatusApproved}, {"open PR review required -> review_pending", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewRequired }), StatusReviewPending}, diff --git a/backend/internal/lifecycle/decide_bridge.go b/backend/internal/lifecycle/decide_bridge.go index 501d12ac75..4f88cbe52f 100644 --- a/backend/internal/lifecycle/decide_bridge.go +++ b/backend/internal/lifecycle/decide_bridge.go @@ -8,236 +8,102 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// defaultRecentActivityWindow is how fresh the last activity signal must be for -// the probe decider to treat the agent as "recently active" (which keeps an -// ambiguous dead-runtime probe in detecting instead of concluding death). +// defaultRecentActivityWindow is how fresh the last activity must be for the +// probe decider to treat the agent as "recently active" — which keeps an +// ambiguous dead-runtime probe in detecting instead of concluding death. const defaultRecentActivityWindow = 60 * time.Second -// ---- fact translation: ports DTOs -> pure decide inputs ---- - -// runtimeFactsToProbeInput maps a raw RuntimeFacts (plus the prior detecting -// memory and last-known activity read back from canonical) into the probe -// decider's input. KillRequested is always false here: the inferred-death path -// never carries an explicit kill — that arrives via OnKillRequested. -func runtimeFactsToProbeInput(f ports.RuntimeFacts, cur domain.CanonicalSessionLifecycle, window time.Duration) decide.ProbeInput { - rt, rtFailed := runtimeProbeToState(f.RuntimeState) - proc, procFailed := processProbeToLiveness(f.ProcessState) +// probeInput maps a raw RuntimeFacts (plus the prior detecting memory and last +// activity) into the pure decider's input. A failed/unknown probe is reported as +// such, never as a death — that routes to the detecting quarantine. +func probeInput(f ports.RuntimeFacts, cur domain.CanonicalSessionLifecycle, window time.Duration) decide.ProbeInput { now := nowOr(f.ObservedAt) - return decide.ProbeInput{ - Runtime: rt, - RuntimeFailed: rtFailed, - Process: proc, - ProcessFailed: procFailed, - RecentActivity: hasRecentActivity(cur.Activity, now, window), - Prior: cur.Detecting, - Now: now, - } -} -func runtimeProbeToState(p ports.RuntimeProbe) (domain.RuntimeState, bool) { - switch p { - case ports.RuntimeProbeAlive: - return domain.RuntimeAlive, false - case ports.RuntimeProbeDead: - return domain.RuntimeExited, false - case ports.RuntimeProbeFailed: - return domain.RuntimeProbeFailed, true - default: // indeterminate / unset: ambiguous, never a death conclusion - return domain.RuntimeUnknown, false + var runtimeAlive, runtimeFailed bool + switch f.Runtime { + case ports.ProbeAlive: + runtimeAlive = true + case ports.ProbeFailed, ports.ProbeUnknown: + runtimeFailed = true // ambiguous: quarantine, never conclude death } -} -func processProbeToLiveness(p ports.ProcessProbe) (decide.ProcessLiveness, bool) { - switch p { - case ports.ProcessProbeAlive: - return decide.ProcessAlive, false - case ports.ProcessProbeDead: - return decide.ProcessDead, false - case ports.ProcessProbeFailed: - return decide.ProcessIndeterminate, true - default: // indeterminate / unset - return decide.ProcessIndeterminate, false + var process decide.ProcessLiveness + var processFailed bool + switch f.Process { + case ports.ProbeAlive: + process = decide.ProcessAlive + case ports.ProbeDead: + process = decide.ProcessDead + case ports.ProbeFailed: + process, processFailed = decide.ProcessIndeterminate, true + default: + process = decide.ProcessIndeterminate } -} -// runtimeSubstateFromFacts derives the runtime sub-state to persist. Liveness -// always owns this axis, so it is written on every runtime observation -// regardless of what the session axis does. -func runtimeSubstateFromFacts(f ports.RuntimeFacts) domain.RuntimeSubstate { - switch f.RuntimeState { - case ports.RuntimeProbeAlive: - return domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning} - case ports.RuntimeProbeDead: - return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonTmuxMissing} - case ports.RuntimeProbeFailed: - return domain.RuntimeSubstate{State: domain.RuntimeProbeFailed, Reason: domain.RuntimeReasonProbeError} - case ports.RuntimeProbeIndeterminate: - // Probe ran but couldn't tell — distinct from a probe error, so no - // probe_error reason; the ambiguity is carried by RuntimeUnknown alone. - return domain.RuntimeSubstate{State: domain.RuntimeUnknown} - default: // unset - return domain.RuntimeSubstate{State: domain.RuntimeUnknown} + return decide.ProbeInput{ + RuntimeAlive: runtimeAlive, + RuntimeFailed: runtimeFailed, + Process: process, + ProcessFailed: processFailed, + RecentActivity: hasRecentActivity(cur.Activity, now, window), + Prior: cur.Detecting, + Now: now, } } -// hasRecentActivity answers the probe decider's "was the agent heard from -// recently?" question. Sticky states (waiting_input/blocked) count as recent -// because they mean a live-but-paused agent; an explicit exited signal never -// counts; otherwise we age the last-activity timestamp against the window. +// hasRecentActivity answers the decider's "heard from the agent recently?" +// question. Sticky states (waiting_input/blocked) count as recent (a live-but- +// paused agent); an explicit exited never counts; else age the timestamp. func hasRecentActivity(a domain.ActivitySubstate, now time.Time, window time.Duration) bool { - if a.State == domain.ActivityExited { + switch { + case a.State == domain.ActivityExited: return false - } - if a.State.IsSticky() { + case a.State.IsSticky(): return true - } - if a.LastActivityAt.IsZero() { + case a.LastActivityAt.IsZero(): return false + default: + return now.Sub(a.LastActivityAt) <= window } - return now.Sub(a.LastActivityAt) <= window -} - -// openPRInput maps SCM facts onto the open-PR ladder. IdleBeyond is always false -// in split A — the idle-duration signal is owned by the escalation engine -// (split B); the synchronous LCM has no clock of its own here. -func openPRInput(f ports.SCMFacts) decide.OpenPRInput { - hasBotComments, hasHumanComments := classifyPendingComments(f.PendingComments) - return decide.OpenPRInput{ - Draft: f.PRState == domain.PRDraft || f.Draft, - CIFailing: f.CISummary == ports.CIFailing, - ChangesRequested: f.ReviewDecision == ports.ReviewChangesRequested || hasHumanComments, - BotComments: hasBotComments, - MergeConflicts: hasMergeConflicts(f.Mergeability), - Approved: f.ReviewDecision == ports.ReviewApproved, - Mergeable: f.Mergeability.Mergeable, - ReviewPending: f.ReviewDecision == ports.ReviewPending, - Number: f.PRNumber, - URL: f.PRURL, - } -} - -func classifyPendingComments(comments []ports.ReviewComment) (hasBot, hasHuman bool) { - for _, c := range comments { - if c.IsBot { - hasBot = true - } else { - hasHuman = true - } - } - return hasBot, hasHuman -} - -func hasMergeConflicts(m ports.Mergeability) bool { - return !m.Mergeable && !m.NoConflicts && (m.CIPassing || m.Approved || len(m.Blockers) > 0) } -// ---- activity -> session axis mapping (activity owns working/idle/waiting) ---- - -// activityToSession maps an activity classification onto the session sub-state. -// exited returns ok=false: an exit signal must NOT write a terminal session -// state — only the probe pipeline (via detecting) may conclude inferred death. -func activityToSession(a domain.ActivityState) (domain.SessionState, domain.SessionReason, bool) { +// activityToSession maps an activity classification onto the session state. +// exited returns ok=false: only the probe pipeline may conclude death. +func activityToSession(a domain.ActivityState) (domain.SessionState, bool) { switch a { case domain.ActivityActive: - return domain.SessionWorking, domain.ReasonTaskInProgress, true - case domain.ActivityReady: - // ready = the agent finished a unit and is waiting for more work. - return domain.SessionIdle, domain.ReasonResearchComplete, true - case domain.ActivityIdle: - // plain inactivity carries no completion claim, so no specific reason - // (research_complete here would read misleadingly in diagnostics). - return domain.SessionIdle, "", true + return domain.SessionWorking, true + case domain.ActivityReady, domain.ActivityIdle: + return domain.SessionIdle, true case domain.ActivityWaitingInput: - return domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, true + return domain.SessionNeedsInput, true case domain.ActivityBlocked: - return domain.SessionStuck, domain.ReasonAwaitingUserInput, true - default: // exited / unset - return "", "", false + return domain.SessionStuck, true + default: + return "", false } } -// ---- composition predicates: who may write the session axis ---- - -// isTerminal reports a final session state that must not be resurrected by an -// observation (only an explicit Restore reopens a terminal session). +// isTerminal reports a final session state — reopened only by an explicit +// Restore, never by an observation. func isTerminal(s domain.SessionState) bool { return s == domain.SessionDone || s == domain.SessionTerminated } -// isLivenessOwned reports whether the current session sub-state was set by the -// liveness/death axis (the probe pipeline) and may therefore be recovered by a -// later healthy probe. detecting is always liveness-owned; a stuck/terminated -// state is liveness-owned only when its reason came from a death inference. -func isLivenessOwned(s domain.SessionSubstate) bool { - if s.State == domain.SessionDetecting { - return true - } - switch s.Reason { - case domain.ReasonRuntimeLost, domain.ReasonAgentProcessExited, domain.ReasonProbeFailure: - return true - } - return false -} - -// shouldWriteSessionRuntime is the #1 composition rule for ApplyRuntimeObservation. -// A death-axis verdict (detecting/stuck/terminal) always writes — it overrides -// activity because a (maybe) dead agent can't be working/waiting. A healthy -// "working" verdict only writes when it is recovering a liveness-owned state -// (e.g. detecting -> working); it must NOT clobber an activity-owned -// needs_input/blocked/idle the activity axis is responsible for. -func shouldWriteSessionRuntime(d decide.LifecycleDecision, cur domain.CanonicalSessionLifecycle) bool { +// writeRuntimeSession reports whether a probe verdict may write the session +// state. A death-axis verdict (detecting/stuck/terminated) always writes; a +// healthy "working" verdict only recovers a detecting session — it must not +// clobber an activity-owned idle/needs_input. +func writeRuntimeSession(d decide.LifecycleDecision, cur domain.CanonicalSessionLifecycle) bool { if isTerminal(cur.Session.State) { - // A terminal session is only reopened by an explicit Restore — never by - // an observation. Even a death-axis verdict (e.g. detecting) must not - // resurrect it; the runtime axis is still patched separately. return false } if d.SessionState == domain.SessionWorking { - return isLivenessOwned(cur.Session) + return cur.Session.State == domain.SessionDetecting } return true } -// shouldWriteSessionActivity is the mirror rule for ApplyActivitySignal: the -// activity axis owns working/idle/waiting. A valid activity signal is direct -// proof of life, so it is allowed to RESOLVE a detecting session (pull it out of -// the liveness quarantine) — but it must not resurrect a terminal session, and -// it leaves a liveness-escalated stuck state to the probe pipeline (stuck is a -// deliberate human-facing escalation, not a transient quarantine). -func shouldWriteSessionActivity(cur domain.CanonicalSessionLifecycle) bool { - if isTerminal(cur.Session.State) { - return false - } - if cur.Session.State == domain.SessionDetecting { - return true - } - return !isLivenessOwned(cur.Session) -} - -// ---- explicit-kill mapping (SM's terminal-write authority) ---- - -func killSession(k ports.LifecycleKillReason) domain.SessionSubstate { - switch k { - case ports.KillManual: - return domain.SessionSubstate{State: domain.SessionTerminated, Reason: domain.ReasonManuallyKilled} - case ports.KillCleanup: - return domain.SessionSubstate{State: domain.SessionTerminated, Reason: domain.ReasonAutoCleanup} - default: // error - return domain.SessionSubstate{State: domain.SessionTerminated, Reason: domain.ReasonErrorInProcess} - } -} - -func killRuntime(k ports.LifecycleKillReason) domain.RuntimeSubstate { - switch k { - case ports.KillManual: - return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonManualKillRequested} - case ports.KillCleanup: - return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonAutoCleanup} - default: // error - return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonProbeError} - } -} - func nowOr(t time.Time) time.Time { if t.IsZero() { return time.Now() diff --git a/backend/internal/lifecycle/fakes_test.go b/backend/internal/lifecycle/fakes_test.go deleted file mode 100644 index 45aec91b98..0000000000 --- a/backend/internal/lifecycle/fakes_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package lifecycle - -import ( - "context" - "fmt" - "sync" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -// fakeStore is an in-memory LifecycleStore that faithfully applies full-row -// Upsert semantics so tests assert against the real persisted canonical. -type fakeStore struct { - mu sync.Mutex - records map[domain.SessionID]*domain.SessionRecord - metadata map[domain.SessionID]domain.SessionMetadata -} - -var _ ports.LifecycleStore = (*fakeStore)(nil) - -func newFakeStore() *fakeStore { - return &fakeStore{ - records: map[domain.SessionID]*domain.SessionRecord{}, - metadata: map[domain.SessionID]domain.SessionMetadata{}, - } -} - -// seed installs a starting lifecycle for a session id (bypassing the patch path). -func (s *fakeStore) seed(id domain.SessionID, l domain.CanonicalSessionLifecycle) { - s.mu.Lock() - defer s.mu.Unlock() - if l.Version == 0 { - l.Version = domain.LifecycleVersion - } - s.records[id] = &domain.SessionRecord{ID: id, Lifecycle: l} -} - -func (s *fakeStore) Load(_ context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - rec, ok := s.records[id] - if !ok { - return domain.CanonicalSessionLifecycle{}, false, nil - } - return rec.Lifecycle, true, nil -} - -func (s *fakeStore) Upsert(_ context.Context, rec domain.SessionRecord, _ ports.EventType) error { - s.mu.Lock() - defer s.mu.Unlock() - if existing, ok := s.records[rec.ID]; ok { - if rec.Lifecycle.Revision != existing.Lifecycle.Revision { - return fmt.Errorf("revision mismatch for %s: have %d, want %d", rec.ID, rec.Lifecycle.Revision, existing.Lifecycle.Revision) - } - rec.Lifecycle.Revision = existing.Lifecycle.Revision + 1 - } else { - if rec.Lifecycle.Revision != 0 { - return fmt.Errorf("revision mismatch for insert %s: have %d, want 0", rec.ID, rec.Lifecycle.Revision) - } - rec.Lifecycle.Revision = 1 - } - if rec.Lifecycle.Version == 0 { - rec.Lifecycle.Version = domain.LifecycleVersion - } - r := rec - s.records[rec.ID] = &r - return nil -} - -func (s *fakeStore) Get(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - rec, ok := s.records[id] - if !ok { - return domain.SessionRecord{}, false, nil - } - return *rec, true, nil -} - -func (s *fakeStore) List(_ context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { - s.mu.Lock() - defer s.mu.Unlock() - var out []domain.SessionRecord - for _, rec := range s.records { - if rec.ProjectID == project { - out = append(out, *rec) - } - } - return out, nil -} - -func (s *fakeStore) GetMetadata(_ context.Context, id domain.SessionID) (domain.SessionMetadata, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.metadata[id], nil -} - -func (s *fakeStore) PatchMetadata(_ context.Context, id domain.SessionID, meta domain.SessionMetadata) error { - s.mu.Lock() - defer s.mu.Unlock() - s.metadata[id] = mergeSessionMetadata(s.metadata[id], meta) - return nil -} - -// mergeSessionMetadata applies meta onto dst with the store's "empty = leave -// unchanged" semantics, so partial patches do not clobber earlier values. -func mergeSessionMetadata(dst, meta domain.SessionMetadata) domain.SessionMetadata { - if meta.Branch != "" { - dst.Branch = meta.Branch - } - if meta.WorkspacePath != "" { - dst.WorkspacePath = meta.WorkspacePath - } - if meta.RuntimeHandleID != "" { - dst.RuntimeHandleID = meta.RuntimeHandleID - } - if meta.RuntimeName != "" { - dst.RuntimeName = meta.RuntimeName - } - if meta.AgentSessionID != "" { - dst.AgentSessionID = meta.AgentSessionID - } - if meta.Prompt != "" { - dst.Prompt = meta.Prompt - } - return dst -} - -// recordingNotifier captures emitted events for assertions. -type recordingNotifier struct { - mu sync.Mutex - events []ports.OrchestratorEvent -} - -var _ ports.Notifier = (*recordingNotifier)(nil) - -func (n *recordingNotifier) Notify(_ context.Context, e ports.OrchestratorEvent) error { - n.mu.Lock() - defer n.mu.Unlock() - n.events = append(n.events, e) - return nil -} - -// recordingMessenger captures messages injected into agents. -type recordingMessenger struct { - mu sync.Mutex - sent []struct { - ID domain.SessionID - Message string - } -} - -var _ ports.AgentMessenger = (*recordingMessenger)(nil) - -func (a *recordingMessenger) Send(_ context.Context, id domain.SessionID, message string) error { - a.mu.Lock() - defer a.mu.Unlock() - a.sent = append(a.sent, struct { - ID domain.SessionID - Message string - }{id, message}) - return nil -} diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 63d7164a1b..5c58f0a27e 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -1,13 +1,8 @@ // Package lifecycle implements ports.LifecycleManager: the synchronous -// observe->decide->persist reducer. Every Apply*/On* entrypoint runs the same -// pipeline under a per-session lock — load the full canonical record, run the -// matching pure decider, classify the resulting change, and persist the full -// row through the store. The store owns Revision++; the LCM never polls and -// never writes the display status (that is derived on read). -// -// After a transition is persisted, the Apply* paths fire the mapped reaction -// (the ACT layer: reaction table + escalation engine) via the react() chokepoint -// in reactions.go. +// observe -> decide -> persist reducer. Every Apply*/On* entrypoint loads the +// session, runs the pure decider, and persists the full row under a single write +// lock. The DB triggers emit the CDC; the engine never writes the change log. +// After a transition it fires the mapped reaction (see reactions.go). package lifecycle import ( @@ -21,438 +16,241 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// Session metadata is now the typed domain.SessionMetadata struct (was a -// free-form string map keyed by Meta* constants). OnSpawnCompleted records the -// spawned session's handles via spawnMetadata; Prompt is the assembled launch -// prompt, persisted so a Restore that finds no captured agent session id can -// still fall back to a fresh launch with the same prompt rather than failing. - -// Manager is the LCM. The Apply* pipeline persists a transition and then fires -// the mapped reaction via Notifier/AgentMessenger (see reactions.go). +// Manager is the lifecycle engine. mu serialises the load->decide->persist +// read-modify-write across sessions; reactions dispatch after the lock releases +// so a slow agent send never blocks the write path. type Manager struct { - store ports.LifecycleStore + store ports.SessionStore + pr ports.PRWriter notifier ports.Notifier messenger ports.AgentMessenger - recentActivityWindow time.Duration - locks keyedMutex - - // trackers hold per-(session,reaction) escalation budgets (ACT policy, not - // canonical state). trackerMu guards them: react() touches them from the - // caller's goroutine, TickEscalations from the reaper's. clock is the time - // source for escalation stamping (overridable in tests). - trackers map[trackerKey]*reactionTracker - trackerMu sync.Mutex - clock func() time.Time + mu sync.Mutex + window time.Duration + clock func() time.Time - // reactionStore, when wired via WithReactionStore, makes the trackers map a - // write-through cache over durable rows so a restart does not re-fire an - // already-escalated human page. nil keeps the in-memory-only default. - reactionStore ReactionStore - - // sessionLister returns every session known to persistence so RunningSessions - // can filter by runtime axis without coupling the LCM to a cross-project - // store API the Tom-store does not yet expose. The daemon (lane #10) injects - // the production lister via WithSessionLister; until then, the call returns - // no sessions so a reaper attached to an unwired Manager is a clean no-op - // rather than a panic. - sessionLister func(ctx context.Context) ([]domain.SessionRecord, error) + // in-memory ACT state (policy, not canonical truth — reset on restart). + react reactionState } var _ ports.LifecycleManager = (*Manager)(nil) -func New(store ports.LifecycleStore, notifier ports.Notifier, messenger ports.AgentMessenger) *Manager { +func New(store ports.SessionStore, pr ports.PRWriter, notifier ports.Notifier, messenger ports.AgentMessenger) *Manager { return &Manager{ - store: store, - notifier: notifier, - messenger: messenger, - recentActivityWindow: defaultRecentActivityWindow, - trackers: map[trackerKey]*reactionTracker{}, - clock: time.Now, + store: store, + pr: pr, + notifier: notifier, + messenger: messenger, + window: defaultRecentActivityWindow, + clock: time.Now, + react: newReactionState(), } } -// WithSessionLister injects the function the LCM uses to enumerate all -// persisted sessions for RunningSessions. The daemon wires this against the -// store at startup; it must be called BEFORE any reaper attached to this -// Manager starts running, since concurrent calls would race the bare-field -// read in RunningSessions. Calling it more than once replaces the previous -// lister. -func (m *Manager) WithSessionLister(fn func(ctx context.Context) ([]domain.SessionRecord, error)) { - m.sessionLister = fn -} - -// ---- per-session serialisation ---- - -// keyedMutex hands out one lock per session id so the load->decide->persist -// read-modify-write is serial within a session but parallel across sessions. -// -// Entries are reference-counted and evicted when the last holder releases, so -// the map stays bounded to sessions with in-flight operations rather than -// growing unbounded over the lifetime of a long-running daemon. -type keyedMutex struct { - mu sync.Mutex - locks map[domain.SessionID]*lockEntry -} - -type lockEntry struct { - mu sync.Mutex - refs int -} - -func (k *keyedMutex) lock(id domain.SessionID) func() { - k.mu.Lock() - if k.locks == nil { - k.locks = make(map[domain.SessionID]*lockEntry) - } - e, ok := k.locks[id] - if !ok { - e = &lockEntry{} - k.locks[id] = e - } - e.refs++ - k.mu.Unlock() - - e.mu.Lock() - return func() { - e.mu.Unlock() - k.mu.Lock() - e.refs-- - if e.refs == 0 { - delete(k.locks, id) - } - k.mu.Unlock() - } -} - -func (m *Manager) withLock(id domain.SessionID, fn func() error) error { - unlock := m.locks.lock(id) - defer unlock() - return fn() -} - -// transition is what a persisted write produced: the canonical before and after -// the full-row upsert. The ACT layer (react) derives the reaction from these. It -// is nil when the pipeline made no write. -// -// projectID is captured so reaction events fired downstream (Notifier.Notify in -// executeReaction and escalate) can populate OrchestratorEvent.ProjectID — the -// human-facing event router groups events by project. Empty when the record has -// no ProjectID (e.g. test-only seeded records that omit identity). -type transition struct { - beforeLC domain.CanonicalSessionLifecycle - afterLC domain.CanonicalSessionLifecycle - projectID domain.ProjectID -} - -// mutate runs the shared pipeline: load full row -> build next canonical -> -// Upsert full row (only if changed). decideFn returns the full next lifecycle -// and whether it changed anything; false is a clean no-op (no write), which is -// how failed-probe / unknown-fact inputs are dropped. -// -// On a write it returns the transition (before/after canonical) so the caller — -// which still holds the originating facts — can fire the mapped reaction. +// mutate runs the shared pipeline: load -> decideFn -> persist (only if changed). +// It returns whether a write happened. A stray observation for an unknown session +// is a clean no-op. func (m *Manager) mutate( ctx context.Context, id domain.SessionID, - decideFn func(cur domain.CanonicalSessionLifecycle, exists bool) (domain.CanonicalSessionLifecycle, bool, error), -) (*transition, error) { - var tr *transition - err := m.withLock(id, func() error { - rec, exists, err := m.store.Get(ctx, id) - if err != nil { - return err - } - cur := rec.Lifecycle - next, changed, err := decideFn(cur, exists) - if err != nil { - return err - } - if !changed { - return nil - } - rec.Lifecycle = m.prepareLifecycleWrite(next) - rec.UpdatedAt = m.clock() - if err := m.store.Upsert(ctx, rec, classifyEventType(cur, rec.Lifecycle, false)); err != nil { - return err - } - // ProjectID is captured straight from the record we already loaded at the - // top of this closure — identity is set once at OnSpawnInitiated and never - // mutated, so no second store roundtrip is needed for reaction events. - tr = &transition{beforeLC: cur, afterLC: rec.Lifecycle, projectID: rec.ProjectID} - return nil - }) - return tr, err -} - -func (m *Manager) prepareLifecycleWrite(next domain.CanonicalSessionLifecycle) domain.CanonicalSessionLifecycle { + fn func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool), +) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rec, ok, err := m.store.GetSession(ctx, id) + if err != nil || !ok { + return false, err + } + next, changed := fn(rec.Lifecycle) + if !changed { + return false, nil + } next.Version = domain.LifecycleVersion - return next + rec.Lifecycle = next + rec.UpdatedAt = m.clock() + if err := m.store.UpdateSession(ctx, rec); err != nil { + return false, err + } + return true, nil } // ---- OBSERVE entrypoints ---- -// ApplyRuntimeObservation feeds the probe decider. Liveness always writes the -// runtime axis; the session axis follows the #1 composition rule; and a -// non-detecting verdict clears any stale detecting memory (#3) so the next -// probe doesn't read a phantom prior. +// ApplyRuntimeObservation feeds the probe decider. is_alive always tracks the +// verdict; the session state follows the runtime-write rule; a non-detecting +// verdict clears stale detecting memory. func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error { - tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (domain.CanonicalSessionLifecycle, bool, error) { - if !exists { - return cur, false, nil // nothing seeded; ignore stray probe - } - - d := decide.ResolveProbeDecision(runtimeFactsToProbeInput(f, cur, m.recentActivityWindow)) - + changed, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) { + d := decide.ResolveProbeDecision(probeInput(f, cur, m.window)) next := cur - changed := false - - if rt := runtimeSubstateFromFacts(f); cur.Runtime != rt { - next.Runtime = rt - changed = true + ch := false + if next.IsAlive != d.IsAlive { + next.IsAlive, ch = d.IsAlive, true } - // A terminal session is reopened only by an explicit Restore: an - // observation may refresh the runtime axis above but must touch neither - // the session axis nor the detecting memory. if !isTerminal(cur.Session.State) { - if shouldWriteSessionRuntime(d, cur) { - changed = setSessionIfChanged(&next, d.SessionState, d.SessionReason) || changed + if writeRuntimeSession(d, cur) { + ch = setSessionState(&next, d.SessionState, d.TerminationReason) || ch } - changed = setDetecting(&next, d.Detecting) || changed + ch = setDetecting(&next, d.Detecting) || ch } - - return next, changed, nil + return next, ch }) - if err != nil { + if err != nil || !changed { return err } - return m.react(ctx, id, tr, reactionContext{}) + return m.runReactions(ctx, id, reactionContent{}) } -// ApplySCMObservation maps PR facts onto the PR axis. A failed fetch is dropped -// (failed probe != "no PR"). An open or draft PR writes only the PR sub-state — -// the session axis stays owned by activity, and DeriveLegacyStatus surfaces the -// PR reason for display. A terminal PR (merged/closed) also parks the session. -func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, f ports.SCMFacts) error { - tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (domain.CanonicalSessionLifecycle, bool, error) { - if !exists || !f.Fetched { - return cur, false, nil - } - - switch f.PRState { - case domain.PRDraft, domain.PROpen: - d := decide.ResolveOpenPRDecision(openPRInput(f)) - next := cur - changed := setPRIfChanged(&next, d, f) - return next, changed, nil - - case domain.PRMerged, domain.PRClosed: - d := decide.ResolveTerminalPRStateDecision(f.PRState) - next := cur - changed := setPRIfChanged(&next, d, f) - // A merge/close is a milestone that ends the work, so it parks the - // session axis (idle / merged_waiting_decision) even over an - // activity-owned needs_input/blocked — unlike the open-PR path, - // which leaves the session axis to activity. A terminal session is - // still never reopened. - if !isTerminal(cur.Session.State) { - changed = setSessionIfChanged(&next, d.SessionState, d.SessionReason) || changed - } - return next, changed, nil - - default: // none / unset: no PR-driven transition in split A - return cur, false, nil - } - }) - if err != nil { - return err - } - return m.react(ctx, id, tr, reactionContext{ciFailureLogTail: f.CIFailureLogTail}) -} - -// ApplyActivitySignal updates the activity axis. Only a valid-confidence signal -// is authoritative (stale/unavailable/probe_failure != idleness). It refreshes -// the persisted activity sub-state (the probe decider's RecentActivity input) -// and maps the classification onto the session axis. A valid signal is proof of -// life, so it may resolve a detecting session — clearing the quarantine memory -// so a later probe doesn't resume counting from a stale prior. +// ApplyActivitySignal updates the activity axis. Only a valid signal is +// authoritative, and it is proof of life: it may resolve a detecting session and +// move the session out of any non-terminal state. func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error { - tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (domain.CanonicalSessionLifecycle, bool, error) { - if !exists || s.State != ports.SignalValid { - return cur, false, nil + if !s.Valid { + return nil + } + changed, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) { + if isTerminal(cur.Session.State) { + return cur, false } - next := cur - changed := false - - act := domain.ActivitySubstate{State: s.Activity, LastActivityAt: nowOr(s.Timestamp), Source: s.Source} + ch := false + act := domain.ActivitySubstate{State: s.State, LastActivityAt: nowOr(s.Timestamp), Source: s.Source} if !sameActivity(cur.Activity, act) { - next.Activity = act - changed = true + next.Activity, ch = act, true } - if st, rs, ok := activityToSession(s.Activity); ok && shouldWriteSessionActivity(cur) { - changed = setSessionIfChanged(&next, st, rs) || changed - // Proof of life that pulls the session out of detecting must also - // drop the quarantine memory (detecting memory only exists while - // detecting, so this is a no-op otherwise). - if cur.Detecting != nil { - next.Detecting = nil - changed = true + if st, ok := activityToSession(s.State); ok { + ch = setSessionState(&next, st, domain.TermNone) || ch + if next.Detecting != nil { + next.Detecting, ch = nil, true } } - - return next, changed, nil + if s.State != domain.ActivityExited && !next.IsAlive { + next.IsAlive, ch = true, true + } + return next, ch }) - if err != nil { + if err != nil || !changed { return err } - return m.react(ctx, id, tr, reactionContext{}) + return m.runReactions(ctx, id, reactionContent{}) } -// ---- mutation commands/outcomes reported by the Session Manager ---- +// ApplyPRObservation records the observed PR facts in the pr tables, terminates +// the session on a merge, and fires the PR-driven reactions. A failed fetch is +// dropped (failed probe != "PR closed"). +func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o ports.PRObservation) error { + if !o.Fetched { + return nil + } + rec, ok, err := m.store.GetSession(ctx, id) + if err != nil || !ok { + return err + } + if err := m.writePR(ctx, id, o); err != nil { + return err + } -// OnSpawnInitiated seeds or reopens the full session record for a spawn-like -// mutation. It is the Session Manager's create/reopen command under the Writer -// contract: the SM builds the identity + initial canonical row, but only the LCM -// writes it. Fresh rows emit session_created; reopening a terminal row reuses -// the current row as the before-image and lets the classifier emit the schema -// event for the reopen. -func (m *Manager) OnSpawnInitiated(ctx context.Context, rec domain.SessionRecord) error { - return m.withLock(rec.ID, func() error { - cur := rec.Lifecycle - isInsert := true - if current, ok, err := m.store.Get(ctx, rec.ID); err != nil { - return err - } else if ok { - currentLC := current.Lifecycle - if !isTerminal(currentLC.Session.State) && !isTerminal(rec.Lifecycle.Session.State) { - return fmt.Errorf("lifecycle: OnSpawnInitiated for active session %q", rec.ID) + if o.Merged { + changed, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) { + if isTerminal(cur.Session.State) { + return cur, false } - cur = currentLC - isInsert = false - } - rec.Lifecycle = m.prepareLifecycleWrite(rec.Lifecycle) - if isInsert { - rec.Lifecycle.Revision = 0 - } else { - rec.Lifecycle.Revision = cur.Revision - } - now := m.clock() - if rec.CreatedAt.IsZero() { - rec.CreatedAt = now - } - rec.UpdatedAt = now - return m.store.Upsert(ctx, rec, classifyEventType(cur, rec.Lifecycle, isInsert)) - }) -} - -// OnSpawnCompleted records that a spawn finished: the runtime is up and the -// handles are known. Per the agreed rule it flips the runtime axis to alive and -// stores the handles in metadata, but leaves the session at not_started -// (display: spawning) — the agent "acknowledges" via the first activity signal. -func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error { - return m.withLock(id, func() error { - rec, exists, err := m.store.Get(ctx, id) + next := cur + next.Session.State = domain.SessionTerminated + next.TerminationReason = domain.TermPRMerged + next.IsAlive = false + next.Detecting = nil + return next, true + }) if err != nil { return err } - if !exists { - // The SM seeds the initial lifecycle before spawning; a completion - // for an unseeded session is a contract violation, not a stray - // observation, so surface it rather than fabricating a record. - return fmt.Errorf("lifecycle: OnSpawnCompleted for unseeded session %q", id) - } - rt := domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning} - if rec.Lifecycle.Runtime != rt { - cur := rec.Lifecycle - next := cur - next.Runtime = rt - rec.Lifecycle = m.prepareLifecycleWrite(next) - rec.UpdatedAt = m.clock() - if err := m.store.Upsert(ctx, rec, classifyEventType(cur, rec.Lifecycle, false)); err != nil { - return err - } - } - if meta := spawnMetadata(o); !meta.IsZero() { - if err := m.store.PatchMetadata(ctx, id, meta); err != nil { - return err - } + if changed { + m.clearReactions(id) + return m.fireNotify(ctx, id, rec.ProjectID, reactions[rxMerged]) } return nil - }) + } + + return m.runReactions(ctx, id, prContent(o)) } -// OnKillRequested is the SM's explicit terminal-write authority (the one -// terminal path that does not go through the inferred-death decider). It writes -// the terminal session/runtime sub-states for the kill kind and clears any -// in-flight detecting memory. -func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, r ports.KillReason) error { - // An explicit user kill is a human action, not an inferred event, so it - // fires no reaction — the transition is discarded. - _, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (domain.CanonicalSessionLifecycle, bool, error) { - if !exists { - // Killing an unknown/already-gone session is a benign race; no-op - // rather than fabricating a terminal record for a session we never - // knew about. - return cur, false, nil +// writePR upserts the scalar facts, records each check run, and replaces the +// comment set. PR-table CDC is emitted by the DB triggers. +func (m *Manager) writePR(ctx context.Context, id domain.SessionID, o ports.PRObservation) error { + now := m.clock() + if err := m.pr.UpsertPR(ctx, ports.PRRow{ + URL: o.URL, SessionID: string(id), Number: o.Number, + Draft: o.Draft, Merged: o.Merged, Closed: o.Closed, + CI: o.CI, Review: o.Review, Mergeability: o.Mergeability, UpdatedAt: now, + }); err != nil { + return err + } + for _, c := range o.Checks { + c.PRURL = o.URL + if c.CreatedAt.IsZero() { + c.CreatedAt = now + } + if err := m.pr.RecordCheck(ctx, c); err != nil { + return err } + } + return m.pr.ReplacePRComments(ctx, o.URL, o.Comments) +} - next := cur - changed := false +// ---- mutation commands from the Session Manager ---- - if sess := killSession(r.Kind); cur.Session != sess { - next.Session = sess - changed = true - } - if rt := killRuntime(r.Kind); cur.Runtime != rt { - next.Runtime = rt - changed = true - } - if cur.Detecting != nil { - next.Detecting = nil - changed = true - } - return next, changed, nil - }) +// OnSpawnCompleted marks a session live and folds in its handles. It serves a +// fresh spawn (not_started -> live) and a restore (terminal -> reopened): both +// land at not_started + is_alive, with the agent acknowledging via first activity. +func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error { + m.mu.Lock() + defer m.mu.Unlock() + rec, ok, err := m.store.GetSession(ctx, id) if err != nil { return err } - // A kill is terminal but bypasses react()'s incident-over cleanup (it fires - // no reaction). Drop any escalation trackers here so a later duration-based - // TickEscalations can't emit reaction.escalated for a dead session. - m.clearSessionTrackers(ctx, id) - return nil + if !ok { + return fmt.Errorf("lifecycle: OnSpawnCompleted for unknown session %q", id) + } + rec.Lifecycle.Version = domain.LifecycleVersion + rec.Lifecycle.Session.State = domain.SessionNotStarted + rec.Lifecycle.TerminationReason = domain.TermNone + rec.Lifecycle.IsAlive = true + rec.Lifecycle.Detecting = nil + rec.Metadata = mergeMetadata(rec.Metadata, spawnMetadata(o)) + rec.UpdatedAt = m.clock() + return m.store.UpdateSession(ctx, rec) } -// ---- read-snapshot helpers ---- +// OnKillRequested is the explicit terminal-write path (the one terminal that does +// not go through the inferred-death decider). It fires no reaction — an explicit +// kill is a human action — but drops the session's ACT state. +func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) error { + _, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) { + if isTerminal(cur.Session.State) { + return cur, false + } + if reason == domain.TermNone { + reason = domain.TermManuallyKilled + } + next := cur + next.Session.State = domain.SessionTerminated + next.TerminationReason = reason + next.IsAlive = false + next.Detecting = nil + return next, true + }) + m.clearReactions(id) + return err +} -// RunningSessions returns a snapshot of every persisted session worth probing -// in the next reaper tick. "Worth probing" is wider than "runtime axis alive": -// it includes sessions in the Detecting quarantine, because a fresh probe is -// the only fact that can recover them (back to working) or escalate them -// (terminal killed). Filtering to runtime-axis-alive would silently park every -// Detecting session — a single failed probe would never get a second chance -// and recovery via runtime probe would be unreachable. -// -// The predicate is "not a final session state". Terminal session states (done, -// terminated) are excluded because Restore is the only path back; observations -// must not reopen them (#1 invariant). Sessions in earlier states — not_started, -// working, idle, needs_input, stuck, detecting — are all included. Those that -// lack runtime handle metadata (e.g. not_started before OnSpawnCompleted) are -// returned and harmlessly skipped by the reaper's per-session handle guard. -// -// The call only reads and copies, so it does not break the single-writer -// invariant; concurrent Apply* calls may move sessions in or out of the probe -// set between snapshots, which is correct — the next tick re-reads. -// -// When no lister has been wired (e.g. tests construct a bare Manager), the -// method returns nil so a goroutine attached to such a Manager degrades to a -// no-op rather than panicking. +// RunningSessions snapshots every non-terminal session for the reaper to probe. +// Detecting sessions are included — a fresh probe is the only fact that recovers +// or escalates them. func (m *Manager) RunningSessions(ctx context.Context) ([]domain.SessionRecord, error) { - if m.sessionLister == nil { - return nil, nil - } - all, err := m.sessionLister(ctx) + all, err := m.store.ListAllSessions(ctx) if err != nil { return nil, err } @@ -465,37 +263,28 @@ func (m *Manager) RunningSessions(ctx context.Context) ([]domain.SessionRecord, return out, nil } -// ---- diff helpers ---- +// ---- diff + metadata helpers ---- -// setSessionIfChanged sets next.Session only when the decided sub-state differs -// from the current next value; an empty decided state means "decider does not -// address the session axis" and is left untouched. -func setSessionIfChanged(next *domain.CanonicalSessionLifecycle, st domain.SessionState, rs domain.SessionReason) bool { +// setSessionState sets the state (and, for a terminal state, the reason) when it +// differs. An empty state means "decider doesn't address the session axis". +func setSessionState(next *domain.CanonicalSessionLifecycle, st domain.SessionState, reason domain.TerminationReason) bool { if st == "" { return false } - want := domain.SessionSubstate{State: st, Reason: rs} - if next.Session == want { - return false + changed := false + if next.Session.State != st { + next.Session.State, changed = st, true } - next.Session = want - return true -} - -// setPRIfChanged folds the decided PR sub-state plus the fact-borne PR identity -// (number/url) into next when it differs from the current next value. -func setPRIfChanged(next *domain.CanonicalSessionLifecycle, d decide.LifecycleDecision, f ports.SCMFacts) bool { - want := domain.PRSubstate{State: d.PRState, Reason: d.PRReason, Number: f.PRNumber, URL: f.PRURL} - if next.PR == want { - return false + want := domain.TermNone + if st == domain.SessionTerminated { + want = reason + } + if next.TerminationReason != want { + next.TerminationReason, changed = want, true } - next.PR = want - return true + return changed } -// setDetecting implements the detecting semantics on the full canonical row: -// set/replace when the decision carries memory, clear (#3) when it doesn't but -// canonical still holds stale memory, else leave untouched. func setDetecting(next *domain.CanonicalSessionLifecycle, d *domain.DetectingState) bool { if d != nil { if next.Detecting != nil && *next.Detecting == *d { @@ -512,27 +301,8 @@ func setDetecting(next *domain.CanonicalSessionLifecycle, d *domain.DetectingSta return false } -func classifyEventType(before, after domain.CanonicalSessionLifecycle, isInsert bool) ports.EventType { - switch { - case isInsert: - return ports.EventSessionCreated - case before.Session.State != after.Session.State && after.Session.State == domain.SessionTerminated: - return ports.EventSessionTerminated - case before.Session != after.Session: - return ports.EventSessionStateChanged - case before.PR != after.PR: - return ports.EventSessionPRUpdated - case before.Runtime != after.Runtime: - return ports.EventSessionRuntimeUpdated - case before.Activity != after.Activity: - return ports.EventSessionActivityUpdated - default: - return ports.EventSessionUpdated - } -} - -// sameActivity compares activity sub-states with time-aware equality (== on -// time.Time is monotonic-clock sensitive and would spuriously report changes). +// sameActivity compares with time-aware equality (== on time.Time is +// monotonic-clock sensitive and would spuriously report changes). func sameActivity(a, b domain.ActivitySubstate) bool { return a.State == b.State && a.Source == b.Source && a.LastActivityAt.Equal(b.LastActivityAt) } @@ -547,3 +317,21 @@ func spawnMetadata(o ports.SpawnOutcome) domain.SessionMetadata { Prompt: o.Prompt, } } + +// mergeMetadata overlays set fields of in onto base without clobbering an +// existing value with an empty one (a partial spawn write keeps the branch set +// at creation). +func mergeMetadata(base, in domain.SessionMetadata) domain.SessionMetadata { + set := func(dst *string, v string) { + if v != "" { + *dst = v + } + } + set(&base.Branch, in.Branch) + set(&base.WorkspacePath, in.WorkspacePath) + set(&base.RuntimeHandleID, in.RuntimeHandleID) + set(&base.RuntimeName, in.RuntimeName) + set(&base.AgentSessionID, in.AgentSessionID) + set(&base.Prompt, in.Prompt) + return base +} diff --git a/backend/internal/lifecycle/manager_parity_test.go b/backend/internal/lifecycle/manager_parity_test.go deleted file mode 100644 index 146dcc16cd..0000000000 --- a/backend/internal/lifecycle/manager_parity_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package lifecycle - -import ( - "context" - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" -) - -// TestStoreParity is the key contract test from the plan: it drives the REAL -// Lifecycle Manager through identical operation sequences against the in-memory -// fakeStore (the authoritative store semantics) and the SQLite-backed Store, -// then asserts the resulting canonical lifecycle is byte-identical. If the -// SQLite adapter honored the port exactly, the two managers cannot diverge. -// -// Both stores are seeded the same way (via the public Upsert insert path, so -// both start at revision 1) — this makes revision numbers, not just states, -// directly comparable. -func TestStoreParity(t *testing.T) { - seed := lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive) - seed.Activity = domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: t0, Source: domain.SourceNative} - - cases := []struct { - name string - ops []func(*Manager) error - }{ - { - name: "runtime dead then activity signal", - ops: []func(*Manager) error{ - func(m *Manager) error { - return m.ApplyRuntimeObservation(context.Background(), sid, ports.RuntimeFacts{ - RuntimeState: ports.RuntimeProbeDead, ProcessState: ports.ProcessProbeDead, ObservedAt: t0, - }) - }, - func(m *Manager) error { - return m.ApplyActivitySignal(context.Background(), sid, ports.ActivitySignal{ - State: ports.SignalValid, Activity: domain.ActivityActive, Timestamp: t0, Source: domain.SourceHook, - }) - }, - }, - }, - { - name: "scm pr open then changes requested", - ops: []func(*Manager) error{ - func(m *Manager) error { - return m.ApplySCMObservation(context.Background(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, PRNumber: 7, PRURL: "http://x/7", - }) - }, - }, - }, - { - name: "kill request terminates", - ops: []func(*Manager) error{ - func(m *Manager) error { - return m.OnKillRequested(context.Background(), sid, ports.KillReason{Kind: ports.KillManual, Detail: "x"}) - }, - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - fakeMgr, fakeS := newManager() - sqlMgr, sqlS := newSQLiteManager(t) - - seedViaUpsert(t, fakeS, seed) - seedViaUpsert(t, sqlS, seed) - - for i, op := range tc.ops { - errF := op(fakeMgr) - errS := op(sqlMgr) - if (errF == nil) != (errS == nil) { - t.Fatalf("op %d error divergence: fake=%v sqlite=%v", i, errF, errS) - } - } - - fl, okF, _ := fakeS.Load(context.Background(), sid) - sl, okS, _ := sqlS.Load(context.Background(), sid) - if okF != okS { - t.Fatalf("presence divergence: fake=%v sqlite=%v", okF, okS) - } - assertLifecycleEqual(t, fl, sl) - }) - } -} - -func newSQLiteManager(t *testing.T) (*Manager, *sqlite.Store) { - t.Helper() - db, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - t.Cleanup(func() { db.Close() }) - store := sqlite.NewStore(db) - return New(store, &recordingNotifier{}, &recordingMessenger{}), store -} - -func seedViaUpsert(t *testing.T, store ports.LifecycleStore, l domain.CanonicalSessionLifecycle) { - t.Helper() - rec := domain.SessionRecord{ - ID: sid, - ProjectID: "proj", - Kind: domain.KindWorker, - CreatedAt: t0, - UpdatedAt: t0, - Lifecycle: l, - } - if err := store.Upsert(context.Background(), rec, ports.EventSessionCreated); err != nil { - t.Fatalf("seed upsert: %v", err) - } -} - -func assertLifecycleEqual(t *testing.T, a, b domain.CanonicalSessionLifecycle) { - t.Helper() - if a.Revision != b.Revision { - t.Errorf("revision: fake=%d sqlite=%d", a.Revision, b.Revision) - } - if a.Session != b.Session { - t.Errorf("session: fake=%+v sqlite=%+v", a.Session, b.Session) - } - if a.PR != b.PR { - t.Errorf("pr: fake=%+v sqlite=%+v", a.PR, b.PR) - } - if a.Runtime != b.Runtime { - t.Errorf("runtime: fake=%+v sqlite=%+v", a.Runtime, b.Runtime) - } - if a.Activity.State != b.Activity.State || a.Activity.Source != b.Activity.Source || - !a.Activity.LastActivityAt.Equal(b.Activity.LastActivityAt) { - t.Errorf("activity: fake=%+v sqlite=%+v", a.Activity, b.Activity) - } - switch { - case a.Detecting == nil && b.Detecting == nil: - case a.Detecting == nil || b.Detecting == nil: - t.Errorf("detecting presence: fake=%v sqlite=%v", a.Detecting, b.Detecting) - default: - if a.Detecting.Attempts != b.Detecting.Attempts || a.Detecting.EvidenceHash != b.Detecting.EvidenceHash || - !a.Detecting.StartedAt.Equal(b.Detecting.StartedAt) { - t.Errorf("detecting: fake=%+v sqlite=%+v", a.Detecting, b.Detecting) - } - } -} diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index 96557e8f01..7843f8af20 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -2,8 +2,8 @@ package lifecycle import ( "context" - "errors" - "sync" + "fmt" + "strings" "testing" "time" @@ -11,605 +11,361 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -var t0 = time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) - -const sid domain.SessionID = "s1" - -func newManager() (*Manager, *fakeStore) { - store := newFakeStore() - return New(store, &recordingNotifier{}, &recordingMessenger{}), store -} - -func mustLoad(t *testing.T, store *fakeStore) domain.CanonicalSessionLifecycle { - t.Helper() - l, ok, err := store.Load(context.Background(), sid) - if err != nil || !ok { - t.Fatalf("load: ok=%v err=%v", ok, err) - } - return l -} - -// ---- ApplyRuntimeObservation + #1 composition + #3 detecting clear ---- - -func TestApplyRuntimeObservation(t *testing.T) { - aliveProbe := ports.RuntimeFacts{RuntimeState: ports.RuntimeProbeAlive, ProcessState: ports.ProcessProbeAlive, ObservedAt: t0} - failedProbe := ports.RuntimeFacts{RuntimeState: ports.RuntimeProbeFailed, ProcessState: ports.ProcessProbeAlive, ObservedAt: t0} - deadProbe := ports.RuntimeFacts{RuntimeState: ports.RuntimeProbeDead, ProcessState: ports.ProcessProbeDead, ObservedAt: t0} - - tests := []struct { - name string - seed domain.CanonicalSessionLifecycle - facts ports.RuntimeFacts - wantSession domain.SessionState - wantReason domain.SessionReason - wantRuntime domain.RuntimeState - wantDisplay domain.SessionStatus - wantDetecting bool // expect non-nil detecting memory persisted - }{ - { - name: "healthy probe must not clobber an activity-owned needs_input (#1)", - seed: lc(domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, domain.RuntimeAlive), - facts: aliveProbe, - wantSession: domain.SessionNeedsInput, - wantReason: domain.ReasonAwaitingUserInput, - wantRuntime: domain.RuntimeAlive, - wantDisplay: domain.StatusNeedsInput, - wantDetecting: false, - }, - { - name: "healthy probe recovers a liveness-owned detecting -> working and clears memory (#1 + #3)", - seed: detectingLC(), - facts: aliveProbe, - wantSession: domain.SessionWorking, - wantReason: domain.ReasonTaskInProgress, - wantRuntime: domain.RuntimeAlive, - wantDisplay: domain.StatusWorking, - wantDetecting: false, - }, - { - name: "failed probe routes to detecting and records memory", - seed: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive), - facts: failedProbe, - wantSession: domain.SessionDetecting, - wantReason: domain.ReasonProbeFailure, - wantRuntime: domain.RuntimeProbeFailed, - wantDisplay: domain.StatusDetecting, - wantDetecting: true, - }, - { - name: "dead+dead with no recent activity concludes killed and clears detecting (#3)", - seed: detectingLC(), - facts: deadProbe, - wantSession: domain.SessionTerminated, - wantReason: domain.ReasonRuntimeLost, - wantRuntime: domain.RuntimeExited, - wantDisplay: domain.StatusKilled, - wantDetecting: false, - }, - } +var ctx = context.Background() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mgr, store := newManager() - store.seed(sid, tt.seed) +// ---- fakes ---- - if err := mgr.ApplyRuntimeObservation(context.Background(), sid, tt.facts); err != nil { - t.Fatalf("apply: %v", err) - } +// fakeStore is a mini SessionStore + PRWriter: it derives PRFacts and recent +// check statuses from what the engine writes, so PR-reaction tests exercise the +// write path and the read-back together. +type fakeStore struct { + sessions map[domain.SessionID]domain.SessionRecord + pr map[domain.SessionID]ports.PRRow + comments map[string][]ports.PRComment + checks []ports.PRCheckRow + num int +} - l := mustLoad(t, store) - if l.Session.State != tt.wantSession || l.Session.Reason != tt.wantReason { - t.Errorf("session = %v/%v, want %v/%v", l.Session.State, l.Session.Reason, tt.wantSession, tt.wantReason) - } - if l.Runtime.State != tt.wantRuntime { - t.Errorf("runtime = %v, want %v", l.Runtime.State, tt.wantRuntime) - } - if got := domain.DeriveLegacyStatus(l); got != tt.wantDisplay { - t.Errorf("display = %v, want %v", got, tt.wantDisplay) - } - if (l.Detecting != nil) != tt.wantDetecting { - t.Errorf("detecting present = %v, want %v (%+v)", l.Detecting != nil, tt.wantDetecting, l.Detecting) - } - }) +func newFakeStore() *fakeStore { + return &fakeStore{ + sessions: map[domain.SessionID]domain.SessionRecord{}, + pr: map[domain.SessionID]ports.PRRow{}, + comments: map[string][]ports.PRComment{}, } } -func TestApplyRuntimeObservation_NoRecordIsNoOp(t *testing.T) { - mgr, store := newManager() - if err := mgr.ApplyRuntimeObservation(context.Background(), sid, ports.RuntimeFacts{RuntimeState: ports.RuntimeProbeAlive, ProcessState: ports.ProcessProbeAlive, ObservedAt: t0}); err != nil { - t.Fatalf("apply: %v", err) - } - if _, ok, _ := store.Load(context.Background(), sid); ok { - t.Error("a probe for an unseeded session must not fabricate a record") +func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { + f.num++ + rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num)) + f.sessions[rec.ID] = rec + return rec, nil +} +func (f *fakeStore) UpdateSession(_ context.Context, rec domain.SessionRecord) error { + f.sessions[rec.ID] = rec + return nil +} +func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { + r, ok := f.sessions[id] + return r, ok, nil +} +func (f *fakeStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) { + var out []domain.SessionRecord + for _, r := range f.sessions { + if r.ProjectID == p { + out = append(out, r) + } } + return out, nil } - -func TestApplyRuntimeObservation_DoesNotResurrectTerminal(t *testing.T) { - mgr, store := newManager() - store.seed(sid, lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.RuntimeExited)) - - // A failed probe would normally route to detecting, but a terminal session - // must not be reopened by an observation (only an explicit Restore does). - if err := mgr.ApplyRuntimeObservation(context.Background(), sid, ports.RuntimeFacts{RuntimeState: ports.RuntimeProbeFailed, ProcessState: ports.ProcessProbeAlive, ObservedAt: t0}); err != nil { - t.Fatalf("apply: %v", err) +func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) { + out := make([]domain.SessionRecord, 0, len(f.sessions)) + for _, r := range f.sessions { + out = append(out, r) } - - l := mustLoad(t, store) - if l.Session.State != domain.SessionTerminated || l.Session.Reason != domain.ReasonManuallyKilled { - t.Errorf("session = %v/%v, want terminated/manually_killed (no resurrection)", l.Session.State, l.Session.Reason) + return out, nil +} +func (f *fakeStore) PRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, error) { + r, ok := f.pr[id] + if !ok { + return domain.PRFacts{}, nil + } + facts := domain.PRFacts{ + URL: r.URL, Number: r.Number, Exists: true, + Draft: r.Draft, Merged: r.Merged, Closed: r.Closed, + CI: r.CI, Review: r.Review, Mergeability: r.Mergeability, + } + for _, c := range f.comments[r.URL] { + if !c.Resolved { + facts.ReviewComments = true + break + } } - if l.Detecting != nil { - t.Errorf("terminal session must not gain detecting memory, got %+v", l.Detecting) + return facts, nil +} +func (f *fakeStore) UpsertPR(_ context.Context, r ports.PRRow) error { + f.pr[domain.SessionID(r.SessionID)] = r + return nil +} +func (f *fakeStore) RecordCheck(_ context.Context, r ports.PRCheckRow) error { + f.checks = append(f.checks, r) + return nil +} +func (f *fakeStore) RecentCheckStatuses(_ context.Context, url, name string, limit int) ([]string, error) { + var out []string + for i := len(f.checks) - 1; i >= 0 && len(out) < limit; i-- { + if f.checks[i].PRURL == url && f.checks[i].Name == name { + out = append(out, f.checks[i].Status) + } } + return out, nil +} +func (f *fakeStore) ReplacePRComments(_ context.Context, url string, cs []ports.PRComment) error { + f.comments[url] = cs + return nil } -// ---- ApplyActivitySignal ---- +type fakeNotifier struct{ events []ports.Event } -func TestApplyActivitySignal(t *testing.T) { - tests := []struct { - name string - seed domain.CanonicalSessionLifecycle - signal ports.ActivitySignal - wantSession domain.SessionState - wantReason domain.SessionReason - checkReason bool - wantActivity domain.ActivityState - wantChanged bool - }{ - { - name: "valid waiting_input maps to needs_input", - seed: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive), - signal: ports.ActivitySignal{State: ports.SignalValid, Activity: domain.ActivityWaitingInput, Timestamp: t0, Source: domain.SourceHook}, - wantSession: domain.SessionNeedsInput, - wantActivity: domain.ActivityWaitingInput, - wantChanged: true, - }, - { - name: "valid active recovers needs_input -> working", - seed: lc(domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, domain.RuntimeAlive), - signal: ports.ActivitySignal{State: ports.SignalValid, Activity: domain.ActivityActive, Timestamp: t0, Source: domain.SourceHook}, - wantSession: domain.SessionWorking, - wantActivity: domain.ActivityActive, - wantChanged: true, - }, - { - name: "valid idle maps to idle with a neutral reason", - seed: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive), - signal: ports.ActivitySignal{State: ports.SignalValid, Activity: domain.ActivityIdle, Timestamp: t0, Source: domain.SourceHook}, - wantSession: domain.SessionIdle, - wantReason: "", - checkReason: true, - wantActivity: domain.ActivityIdle, - wantChanged: true, - }, - { - name: "low-confidence signal is dropped (no idleness inferred)", - seed: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive), - signal: ports.ActivitySignal{State: ports.SignalProbeFailure, Activity: domain.ActivityIdle, Timestamp: t0, Source: domain.SourceHook}, - wantSession: domain.SessionWorking, - wantChanged: false, - }, - { - name: "valid activity resolves a detecting session (proof of life)", - seed: detectingLC(), - signal: ports.ActivitySignal{State: ports.SignalValid, Activity: domain.ActivityActive, Timestamp: t0, Source: domain.SourceHook}, - wantSession: domain.SessionWorking, - wantActivity: domain.ActivityActive, - wantChanged: true, - }, +func (f *fakeNotifier) Notify(_ context.Context, e ports.Event) error { + f.events = append(f.events, e) + return nil +} +func (f *fakeNotifier) last() string { + if len(f.events) == 0 { + return "" } + return f.events[len(f.events)-1].Type +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mgr, store := newManager() - store.seed(sid, tt.seed) - - if err := mgr.ApplyActivitySignal(context.Background(), sid, tt.signal); err != nil { - t.Fatalf("apply: %v", err) - } - - l := mustLoad(t, store) - if l.Session.State != tt.wantSession { - t.Errorf("session = %v, want %v", l.Session.State, tt.wantSession) - } - if tt.checkReason && l.Session.Reason != tt.wantReason { - t.Errorf("session reason = %q, want %q", l.Session.Reason, tt.wantReason) - } - if tt.wantChanged && l.Revision != 1 { - t.Errorf("revision = %d, want 1 (expected a write)", l.Revision) - } - if !tt.wantChanged && l.Revision != 0 { - t.Errorf("revision = %d, want 0 (expected a no-op)", l.Revision) - } - if tt.wantChanged && tt.wantActivity != "" && l.Activity.State != tt.wantActivity { - t.Errorf("activity = %v, want %v", l.Activity.State, tt.wantActivity) - } - if tt.name == "valid activity resolves a detecting session (proof of life)" && l.Detecting != nil { - t.Errorf("resolving detecting must clear the quarantine memory, got %+v", l.Detecting) - } - }) - } -} - -// ---- ApplySCMObservation ---- - -func TestApplySCMObservation(t *testing.T) { - t.Run("failed fetch is a no-op (failed probe != no PR)", func(t *testing.T) { - mgr, store := newManager() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) - if err := mgr.ApplySCMObservation(context.Background(), sid, ports.SCMFacts{Fetched: false, PRState: domain.PROpen}); err != nil { - t.Fatalf("apply: %v", err) - } - if l := mustLoad(t, store); l.Revision != 0 || l.PR.State != "" { - t.Errorf("expected no-op, got revision=%d pr=%v", l.Revision, l.PR.State) - } - }) +type fakeMessenger struct{ msgs []string } - t.Run("open PR writes only the PR axis; session stays activity-owned", func(t *testing.T) { - mgr, store := newManager() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) - f := ports.SCMFacts{Fetched: true, PRState: domain.PROpen, CISummary: ports.CIFailing, PRNumber: 12, PRURL: "https://x/12"} - if err := mgr.ApplySCMObservation(context.Background(), sid, f); err != nil { - t.Fatalf("apply: %v", err) - } - l := mustLoad(t, store) - if l.PR.State != domain.PROpen || l.PR.Reason != domain.PRReasonCIFailing || l.PR.Number != 12 { - t.Errorf("pr = %+v, want open/ci_failing/#12", l.PR) - } - if l.Session.State != domain.SessionWorking { - t.Errorf("session = %v, want working (untouched)", l.Session.State) - } - if got := domain.DeriveLegacyStatus(l); got != domain.StatusCIFailed { - t.Errorf("display = %v, want ci_failed", got) - } - }) - - t.Run("draft PR writes draft or ci_failed without review states", func(t *testing.T) { - cases := []struct { - name string - facts ports.SCMFacts - wantReason domain.PRReason - wantStatus domain.SessionStatus - }{ - {"draft with failing CI", ports.SCMFacts{Fetched: true, PRState: domain.PRDraft, CISummary: ports.CIFailing}, domain.PRReasonCIFailing, domain.StatusCIFailed}, - {"draft via bool with open state", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, Draft: true}, domain.PRReasonInProgress, domain.StatusDraft}, - {"draft via bool with failing CI", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, Draft: true, CISummary: ports.CIFailing}, domain.PRReasonCIFailing, domain.StatusCIFailed}, - {"draft ignores review and merge facts", ports.SCMFacts{Fetched: true, PRState: domain.PRDraft, ReviewDecision: ports.ReviewApproved, Mergeability: ports.Mergeability{Mergeable: true}}, domain.PRReasonInProgress, domain.StatusDraft}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - mgr, store := newManager() - wantSession := domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress} - store.seed(sid, lc(wantSession.State, wantSession.Reason, domain.RuntimeAlive)) - if err := mgr.ApplySCMObservation(context.Background(), sid, c.facts); err != nil { - t.Fatalf("apply: %v", err) - } - l := mustLoad(t, store) - if l.PR.State != domain.PRDraft || l.PR.Reason != c.wantReason { - t.Errorf("pr = %v/%v, want draft/%v", l.PR.State, l.PR.Reason, c.wantReason) - } - if l.Session != wantSession { - t.Errorf("session = %+v, want untouched %+v", l.Session, wantSession) - } - if got := domain.DeriveLegacyStatus(l); got != c.wantStatus { - t.Errorf("display = %v, want %v", got, c.wantStatus) - } - }) - } - }) +func (f *fakeMessenger) Send(_ context.Context, _ domain.SessionID, m string) error { + f.msgs = append(f.msgs, m) + return nil +} - t.Run("merged PR parks the session and displays merged", func(t *testing.T) { - mgr, store := newManager() - seed := lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive) - seed.PR = domain.PRSubstate{State: domain.PROpen, Reason: domain.PRReasonInProgress, Number: 12} - store.seed(sid, seed) - f := ports.SCMFacts{Fetched: true, PRState: domain.PRMerged, PRNumber: 12} - if err := mgr.ApplySCMObservation(context.Background(), sid, f); err != nil { - t.Fatalf("apply: %v", err) - } - l := mustLoad(t, store) - if l.PR.State != domain.PRMerged || l.Session.Reason != domain.ReasonMergedWaitingDecision { - t.Errorf("got pr=%v session=%v, want merged + merged_waiting_decision", l.PR.State, l.Session.Reason) - } - if got := domain.DeriveLegacyStatus(l); got != domain.StatusMerged { - t.Errorf("display = %v, want merged", got) - } - }) +func newManager() (*Manager, *fakeStore, *fakeNotifier, *fakeMessenger) { + st, n, msg := newFakeStore(), &fakeNotifier{}, &fakeMessenger{} + return New(st, st, n, msg), st, n, msg +} - t.Run("open-PR review branches map to the PR axis", func(t *testing.T) { - cases := []struct { - name string - facts ports.SCMFacts - wantReason domain.PRReason - wantStatus domain.SessionStatus - }{ - {"changes requested", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewChangesRequested}, domain.PRReasonChangesRequested, domain.StatusChangesRequested}, - {"pending human comments", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, PendingComments: []ports.ReviewComment{{Author: "human", Body: "fix"}}}, domain.PRReasonChangesRequested, domain.StatusChangesRequested}, - {"pending bot comments", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, PendingComments: []ports.ReviewComment{{Author: "bot", Body: "fix", IsBot: true}}}, domain.PRReasonBotComments, domain.StatusChangesRequested}, - {"merge conflicts", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, Mergeability: ports.Mergeability{CIPassing: true, Approved: true, NoConflicts: false, Blockers: []string{"merge conflicts"}}}, domain.PRReasonMergeConflicts, domain.StatusPROpen}, - {"approved + mergeable", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewApproved, Mergeability: ports.Mergeability{Mergeable: true}}, domain.PRReasonMergeReady, domain.StatusMergeable}, - {"review pending", ports.SCMFacts{Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewPending}, domain.PRReasonReviewPending, domain.StatusReviewPending}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - mgr, store := newManager() - wantSession := domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress} - store.seed(sid, lc(wantSession.State, wantSession.Reason, domain.RuntimeAlive)) - if err := mgr.ApplySCMObservation(context.Background(), sid, c.facts); err != nil { - t.Fatalf("apply: %v", err) - } - l := mustLoad(t, store) - if l.PR.State != domain.PROpen || l.PR.Reason != c.wantReason { - t.Errorf("pr = %v/%v, want open/%v", l.PR.State, l.PR.Reason, c.wantReason) - } - if got := domain.DeriveLegacyStatus(l); got != c.wantStatus { - t.Errorf("display = %v, want %v", got, c.wantStatus) - } - }) - } - }) +func working(id domain.SessionID) domain.SessionRecord { + return domain.SessionRecord{ + ID: id, ProjectID: "mer", + Lifecycle: domain.CanonicalSessionLifecycle{ + Version: domain.LifecycleVersion, + Session: domain.SessionSubstate{State: domain.SessionWorking}, + IsAlive: true, + }, + } +} - t.Run("no PR is a no-op in split A", func(t *testing.T) { - mgr, store := newManager() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) - if err := mgr.ApplySCMObservation(context.Background(), sid, ports.SCMFacts{Fetched: true, PRState: domain.PRNone}); err != nil { - t.Fatalf("apply: %v", err) - } - if l := mustLoad(t, store); l.Revision != 0 { - t.Errorf("expected no-op, got revision=%d", l.Revision) - } - }) +func openPR(o ports.PRObservation) ports.PRObservation { + o.Fetched, o.URL, o.Number = true, "https://example/pr/1", 1 + return o } -// ---- mutation outcomes ---- +// ---- runtime observations ---- -func TestOnSpawnCompleted(t *testing.T) { - mgr, store := newManager() - store.seed(sid, lc(domain.SessionNotStarted, domain.ReasonSpawnRequested, domain.RuntimeUnknown)) +func TestRuntimeObservation_InferredDeath(t *testing.T) { + m, st, n, _ := newManager() + st.sessions["mer-1"] = working("mer-1") - out := ports.SpawnOutcome{ - Branch: "feat/x", - WorkspacePath: "/w/x", - RuntimeHandle: ports.RuntimeHandle{ID: "tmux:1", RuntimeName: "tmux"}, - AgentSessionID: "agent-1", + if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Runtime: ports.ProbeDead, Process: ports.ProbeDead}); err != nil { + t.Fatal(err) + } + got := st.sessions["mer-1"].Lifecycle + if got.Session.State != domain.SessionTerminated || got.TerminationReason != domain.TermRuntimeLost || got.IsAlive { + t.Fatalf("want terminated/runtime_lost/dead, got %+v", got) } - if err := mgr.OnSpawnCompleted(context.Background(), sid, out); err != nil { - t.Fatalf("apply: %v", err) + if n.last() != "reaction.agent-exited" { + t.Fatalf("want agent-exited notify, got %q", n.last()) } +} + +func TestRuntimeObservation_FailedProbeQuarantines(t *testing.T) { + m, st, _, _ := newManager() + st.sessions["mer-1"] = working("mer-1") - l := mustLoad(t, store) - if l.Runtime.State != domain.RuntimeAlive { - t.Errorf("runtime = %v, want alive", l.Runtime.State) + if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Runtime: ports.ProbeFailed, Process: ports.ProbeFailed}); err != nil { + t.Fatal(err) } - if l.Session.State != domain.SessionNotStarted { - t.Errorf("session = %v, want not_started (spawn does not assert acknowledgement)", l.Session.State) + got := st.sessions["mer-1"].Lifecycle + if got.Session.State != domain.SessionDetecting || !got.IsAlive || got.Detecting == nil { + t.Fatalf("failed probe should quarantine alive, got %+v", got) } - if got := domain.DeriveLegacyStatus(l); got != domain.StatusSpawning { - t.Errorf("display = %v, want spawning", got) +} + +func TestRuntimeObservation_RecoversDetecting(t *testing.T) { + m, st, _, _ := newManager() + rec := working("mer-1") + rec.Lifecycle.Session.State = domain.SessionDetecting + rec.Lifecycle.Detecting = &domain.DetectingState{Attempts: 1} + st.sessions["mer-1"] = rec + + if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Runtime: ports.ProbeAlive, Process: ports.ProbeAlive}); err != nil { + t.Fatal(err) } - meta, _ := store.GetMetadata(context.Background(), sid) - if meta.Branch != "feat/x" || meta.AgentSessionID != "agent-1" || meta.RuntimeName != "tmux" { - t.Errorf("metadata not recorded: %+v", meta) + got := st.sessions["mer-1"].Lifecycle + if got.Session.State != domain.SessionWorking || got.Detecting != nil { + t.Fatalf("healthy probe should recover to working, got %+v", got) } } -func TestOnSpawnInitiated_ActiveSessionRejected(t *testing.T) { - mgr, store := newManager() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) +// ---- activity signals ---- - err := mgr.OnSpawnInitiated(context.Background(), domain.SessionRecord{ - ID: sid, - ProjectID: domain.ProjectID("proj"), - Lifecycle: lc(domain.SessionNotStarted, domain.ReasonSpawnRequested, domain.RuntimeUnknown), - }) - if err == nil { - t.Fatal("OnSpawnInitiated should reject a non-terminal row on top of an active session") - } +func TestActivity_WaitingInputPagesHuman(t *testing.T) { + m, st, n, _ := newManager() + st.sessions["mer-1"] = working("mer-1") - got := mustLoad(t, store) - if got.Session.State != domain.SessionWorking || got.Revision != 0 { - t.Fatalf("active row should be unchanged, got %+v", got) + if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityWaitingInput, Timestamp: time.Now()}); err != nil { + t.Fatal(err) + } + if st.sessions["mer-1"].Lifecycle.Session.State != domain.SessionNeedsInput { + t.Fatalf("want needs_input, got %v", st.sessions["mer-1"].Lifecycle.Session.State) + } + if n.last() != "reaction.agent-needs-input" { + t.Fatalf("want needs-input notify, got %q", n.last()) } } -func TestOnKillRequested(t *testing.T) { - tests := []struct { - name string - kind ports.LifecycleKillReason - wantReason domain.SessionReason - wantRuntime domain.RuntimeReason - wantDisplay domain.SessionStatus - }{ - {"manual", ports.KillManual, domain.ReasonManuallyKilled, domain.RuntimeReasonManualKillRequested, domain.StatusKilled}, - {"cleanup", ports.KillCleanup, domain.ReasonAutoCleanup, domain.RuntimeReasonAutoCleanup, domain.StatusCleanup}, - {"error", ports.KillError, domain.ReasonErrorInProcess, domain.RuntimeReasonProbeError, domain.StatusErrored}, +func TestActivity_InvalidIsIgnored(t *testing.T) { + m, st, _, _ := newManager() + st.sessions["mer-1"] = working("mer-1") + before := st.sessions["mer-1"] + + if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: false, State: domain.ActivityIdle}); err != nil { + t.Fatal(err) + } + if st.sessions["mer-1"] != before { + t.Fatal("invalid signal must not mutate the session") } +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mgr, store := newManager() - store.seed(sid, detectingLC()) +// ---- PR observations ---- - if err := mgr.OnKillRequested(context.Background(), sid, ports.KillReason{Kind: tt.kind, Detail: "x"}); err != nil { - t.Fatalf("apply: %v", err) - } +func TestPR_CIFailingNudgesAgentWithLogs(t *testing.T) { + m, st, _, msg := newManager() + st.sessions["mer-1"] = working("mer-1") - l := mustLoad(t, store) - if l.Session.State != domain.SessionTerminated || l.Session.Reason != tt.wantReason { - t.Errorf("session = %v/%v, want terminated/%v", l.Session.State, l.Session.Reason, tt.wantReason) - } - if l.Runtime.Reason != tt.wantRuntime { - t.Errorf("runtime reason = %v, want %v", l.Runtime.Reason, tt.wantRuntime) - } - if l.Detecting != nil { - t.Errorf("kill must clear detecting memory, got %+v", l.Detecting) - } - if got := domain.DeriveLegacyStatus(l); got != tt.wantDisplay { - t.Errorf("display = %v, want %v", got, tt.wantDisplay) - } - }) + o := openPR(ports.PRObservation{CI: domain.CIFailing, Checks: []ports.PRCheckRow{{Name: "build", CommitHash: "c1", Status: "failed", LogTail: "boom"}}}) + if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { + t.Fatal(err) + } + if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "boom") { + t.Fatalf("want one CI nudge with log tail, got %v", msg.msgs) } } -func TestOnSpawnCompleted_UnseededErrors(t *testing.T) { - mgr, store := newManager() - err := mgr.OnSpawnCompleted(context.Background(), sid, ports.SpawnOutcome{Branch: "x"}) - if err == nil { - t.Error("OnSpawnCompleted for an unseeded session must error, not fabricate a record") +func TestPR_CIBrakeEscalatesAfterThreeFails(t *testing.T) { + m, st, n, msg := newManager() + st.sessions["mer-1"] = working("mer-1") + + for _, commit := range []string{"c1", "c2", "c3"} { + o := openPR(ports.PRObservation{CI: domain.CIFailing, Checks: []ports.PRCheckRow{{Name: "build", CommitHash: commit, Status: "failed", LogTail: "boom"}}}) + if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { + t.Fatal(err) + } } - if _, ok, _ := store.Load(context.Background(), sid); ok { - t.Error("no record should have been created") + if len(msg.msgs) != 2 { + t.Fatalf("want 2 nudges then escalate, got %d nudges", len(msg.msgs)) + } + if n.last() != "reaction.escalated" { + t.Fatalf("3rd failure should escalate, got %q", n.last()) } } -func TestOnKillRequested_UnseededIsNoOp(t *testing.T) { - mgr, store := newManager() - if err := mgr.OnKillRequested(context.Background(), sid, ports.KillReason{Kind: ports.KillManual}); err != nil { - t.Fatalf("kill of unknown session should be a benign no-op, got %v", err) +func TestPR_ReviewCommentsInjectedRegardlessOfAuthor(t *testing.T) { + m, st, _, msg := newManager() + st.sessions["mer-1"] = working("mer-1") + + o := openPR(ports.PRObservation{ + Review: domain.ReviewChangesRequest, + Comments: []ports.PRComment{{ID: "1", Author: "greptileai", Body: "use a constant here"}}, + }) + if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { + t.Fatal(err) } - if _, ok, _ := store.Load(context.Background(), sid); ok { - t.Error("killing an unknown session must not fabricate a terminal record") + if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "use a constant here") { + t.Fatalf("review feedback should be injected verbatim, got %v", msg.msgs) } } -// ---- fake store contract ---- +func TestPR_ApprovedAndGreenNotifies(t *testing.T) { + m, st, n, _ := newManager() + st.sessions["mer-1"] = working("mer-1") -func TestFakeStoreUpsertFullRow(t *testing.T) { - store := newFakeStore() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) - - rec, ok, err := store.Get(context.Background(), sid) - if err != nil || !ok { - t.Fatalf("seeded record missing: ok=%v err=%v", ok, err) + o := openPR(ports.PRObservation{Review: domain.ReviewApproved, Mergeability: domain.MergeMergeable}) + if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { + t.Fatal(err) } - rec.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionIdle, Reason: domain.ReasonResearchComplete} - rec.Lifecycle.Runtime = domain.RuntimeSubstate{State: domain.RuntimeExited} - if err := store.Upsert(context.Background(), rec, ports.EventSessionStateChanged); err != nil { - t.Fatalf("upsert: %v", err) + if n.last() != "reaction.approved-and-green" { + t.Fatalf("want approved-and-green, got %q", n.last()) } +} + +func TestPR_MergeTerminatesSession(t *testing.T) { + m, st, n, _ := newManager() + st.sessions["mer-1"] = working("mer-1") - got, _, _ := store.Get(context.Background(), sid) - if got.Lifecycle.Session.State != domain.SessionIdle || got.Lifecycle.Runtime.State != domain.RuntimeExited { - t.Fatalf("upsert should replace the full canonical row, got %+v", got.Lifecycle) + o := openPR(ports.PRObservation{Merged: true}) + if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { + t.Fatal(err) } - if got.Lifecycle.Revision != 1 { - t.Fatalf("upsert should bump revision inside the store, got %d want 1", got.Lifecycle.Revision) + got := st.sessions["mer-1"].Lifecycle + if got.Session.State != domain.SessionTerminated || got.TerminationReason != domain.TermPRMerged { + t.Fatalf("merge should terminate with pr_merged, got %+v", got) + } + if n.last() != "reaction.pr-merged" { + t.Fatalf("want pr-merged notify, got %q", n.last()) } } -// ---- per-session serialisation under the race detector ---- - -func TestPerSessionSerialization(t *testing.T) { - mgr, store := newManager() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) +func TestPR_FailedFetchIsDropped(t *testing.T) { + m, st, _, msg := newManager() + st.sessions["mer-1"] = working("mer-1") - const n = 50 - var wg sync.WaitGroup - wg.Add(n) - for i := 0; i < n; i++ { - go func(i int) { - defer wg.Done() - _ = mgr.ApplyActivitySignal(context.Background(), sid, ports.ActivitySignal{ - State: ports.SignalValid, - Activity: domain.ActivityActive, - Timestamp: t0.Add(time.Duration(i) * time.Second), - Source: domain.SourceHook, - }) - }(i) + if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: false, CI: domain.CIFailing}); err != nil { + t.Fatal(err) } - wg.Wait() - - // Each goroutine writes a distinct LastActivityAt, so every call is a real - // change; with correct serialisation all n land without a lost update. - if l := mustLoad(t, store); l.Revision != n { - t.Errorf("revision = %d, want %d (lost update under concurrency)", l.Revision, n) + if len(msg.msgs) != 0 || len(st.pr) != 0 { + t.Fatal("a failed fetch must write nothing and fire nothing") } } -// ---- RunningSessions (reaper poll-set) ---- +// ---- explicit kill ---- -func TestRunningSessions_NoListerWired_ReturnsEmpty(t *testing.T) { - m, _ := newManager() - got, err := m.RunningSessions(context.Background()) - if err != nil { - t.Fatalf("RunningSessions: %v", err) +func TestKill_TerminatesWithoutReacting(t *testing.T) { + m, st, n, _ := newManager() + st.sessions["mer-1"] = working("mer-1") + + if err := m.OnKillRequested(ctx, "mer-1", domain.TermManuallyKilled); err != nil { + t.Fatal(err) + } + got := st.sessions["mer-1"].Lifecycle + if got.Session.State != domain.SessionTerminated || got.TerminationReason != domain.TermManuallyKilled || got.IsAlive { + t.Fatalf("want terminated/manually_killed/dead, got %+v", got) } - if len(got) != 0 { - t.Fatalf("expected empty slice when no lister wired, got %d records", len(got)) + if len(n.events) != 0 { + t.Fatal("an explicit kill must not fire a reaction") } } -func TestRunningSessions_ListerErrorPropagates(t *testing.T) { - m, _ := newManager() - wantErr := errors.New("boom") - m.WithSessionLister(func(_ context.Context) ([]domain.SessionRecord, error) { - return nil, wantErr - }) - _, err := m.RunningSessions(context.Background()) - if !errors.Is(err, wantErr) { - t.Fatalf("expected lister error to propagate, got %v", err) - } -} - -// TestRunningSessions_FilterIncludesProbableExcludesTerminal locks in the -// reaper poll-set predicate. The bug we are guarding against is filtering to -// "runtime.State == RuntimeAlive": detecting sessions (RuntimeMissing / -// RuntimeProbeFailed) would be silently parked, breaking the probe-driven -// recovery path proved by manager_test.go:59 and the dead+dead -> killed path -// proved by manager_test.go:79. -func TestRunningSessions_FilterIncludesProbableExcludesTerminal(t *testing.T) { - m, _ := newManager() - records := []domain.SessionRecord{ - {ID: "working-alive", Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)}, - {ID: "detecting-probefailed", Lifecycle: lc(domain.SessionDetecting, domain.ReasonProbeFailure, domain.RuntimeProbeFailed)}, - {ID: "detecting-missing", Lifecycle: lc(domain.SessionDetecting, domain.ReasonRuntimeLost, domain.RuntimeMissing)}, - {ID: "idle-alive", Lifecycle: lc(domain.SessionIdle, domain.ReasonResearchComplete, domain.RuntimeAlive)}, - {ID: "needs-input-alive", Lifecycle: lc(domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, domain.RuntimeAlive)}, - {ID: "not-started", Lifecycle: lc(domain.SessionNotStarted, domain.ReasonSpawnRequested, domain.RuntimeUnknown)}, - {ID: "terminated", Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.RuntimeExited)}, - {ID: "done", Lifecycle: lc(domain.SessionDone, domain.ReasonPRMerged, domain.RuntimeExited)}, - } - m.WithSessionLister(func(_ context.Context) ([]domain.SessionRecord, error) { - return records, nil - }) +// ---- duration escalation ---- - got, err := m.RunningSessions(context.Background()) - if err != nil { - t.Fatalf("RunningSessions: %v", err) +func TestTickEscalations_DurationPagesHuman(t *testing.T) { + m, st, n, msg := newManager() + now := time.Now() + m.clock = func() time.Time { return now } + st.sessions["mer-1"] = working("mer-1") + + o := openPR(ports.PRObservation{Mergeability: domain.MergeConflicting}) + if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { + t.Fatal(err) } - gotIDs := map[domain.SessionID]bool{} - for _, r := range got { - gotIDs[r.ID] = true + if len(msg.msgs) != 1 { + t.Fatalf("merge-conflict should nudge once, got %d", len(msg.msgs)) } - wantIncluded := []domain.SessionID{ - "working-alive", "detecting-probefailed", "detecting-missing", - "idle-alive", "needs-input-alive", "not-started", + if err := m.TickEscalations(ctx, now.Add(16*time.Minute)); err != nil { + t.Fatal(err) } - for _, id := range wantIncluded { - if !gotIDs[id] { - t.Errorf("expected %q in poll set, missing", id) - } - } - wantExcluded := []domain.SessionID{"terminated", "done"} - for _, id := range wantExcluded { - if gotIDs[id] { - t.Errorf("expected %q NOT in poll set, found", id) - } + if n.last() != "reaction.escalated" { + t.Fatalf("unaddressed conflict should escalate after 15m, got %q", n.last()) } } -// ---- helpers ---- +func TestRunningSessions_ExcludesTerminal(t *testing.T) { + m, st, _, _ := newManager() + st.sessions["mer-1"] = working("mer-1") + dead := working("mer-2") + dead.Lifecycle.Session.State = domain.SessionTerminated + st.sessions["mer-2"] = dead -func lc(state domain.SessionState, reason domain.SessionReason, rt domain.RuntimeState) domain.CanonicalSessionLifecycle { - return domain.CanonicalSessionLifecycle{ - Version: domain.LifecycleVersion, - Session: domain.SessionSubstate{State: state, Reason: reason}, - Runtime: domain.RuntimeSubstate{State: rt}, + got, err := m.RunningSessions(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].ID != "mer-1" { + t.Fatalf("want only the live session, got %+v", got) } -} - -func detectingLC() domain.CanonicalSessionLifecycle { - l := lc(domain.SessionDetecting, domain.ReasonRuntimeLost, domain.RuntimeMissing) - l.Detecting = &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: "abc"} - return l } diff --git a/backend/internal/lifecycle/reaction_durability_test.go b/backend/internal/lifecycle/reaction_durability_test.go deleted file mode 100644 index 1866c8c977..0000000000 --- a/backend/internal/lifecycle/reaction_durability_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package lifecycle - -import ( - "context" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" -) - -// reactionStoreAdapter bridges the concrete *sqlite.Store to the lifecycle -// package's ReactionStore interface (string/row types <-> domain types). This is -// the same glue the composition root installs. -type reactionStoreAdapter struct{ s *sqlite.Store } - -func (a reactionStoreAdapter) LoadReactionTrackers(ctx context.Context) ([]PersistedTracker, error) { - rows, err := a.s.ListReactionTrackers(ctx) - if err != nil { - return nil, err - } - out := make([]PersistedTracker, len(rows)) - for i, r := range rows { - out[i] = PersistedTracker{ - SessionID: domain.SessionID(r.SessionID), - Key: r.ReactionKey, - Attempts: r.Attempts, - Escalated: r.Escalated, - FirstAttemptAt: r.FirstAttemptAt, - ProjectID: domain.ProjectID(r.ProjectID), - } - } - return out, nil -} - -func (a reactionStoreAdapter) SaveReactionTracker(ctx context.Context, t PersistedTracker) error { - return a.s.SaveReactionTracker(ctx, sqlite.ReactionTrackerRow{ - SessionID: string(t.SessionID), - ReactionKey: t.Key, - Attempts: t.Attempts, - Escalated: t.Escalated, - FirstAttemptAt: t.FirstAttemptAt, - ProjectID: string(t.ProjectID), - }) -} - -func (a reactionStoreAdapter) DeleteReactionTracker(ctx context.Context, id domain.SessionID, key string) error { - return a.s.DeleteReactionTracker(ctx, string(id), key) -} - -func (a reactionStoreAdapter) DeleteSessionReactionTrackers(ctx context.Context, id domain.SessionID) error { - return a.s.DeleteSessionReactionTrackers(ctx, string(id)) -} - -// TestReaction_DurabilitySurvivesRestart is the plan's reaction_trackers -// durability check: once a reaction has escalated, a daemon restart (a fresh -// Manager hydrated from the same store) must NOT re-fire the human page — the -// exact failure the in-memory-only version had. -func TestReaction_DurabilitySurvivesRestart(t *testing.T) { - db, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - t.Cleanup(func() { db.Close() }) - store := sqlite.NewStore(db) - adapter := reactionStoreAdapter{store} - - // --- first process lifetime: drive ci-failed to escalation --- - notf1 := &recordingNotifier{} - m1 := New(store, notf1, &recordingMessenger{}) - m1.clock = func() time.Time { return t0 } - if err := m1.WithReactionStore(context.Background(), adapter); err != nil { - t.Fatalf("hydrate m1: %v", err) - } - seedViaUpsert(t, store, lcOpenPR(domain.PRReasonReviewPending)) - - // ci-failed: retries 2, persistent → escalate on the third failure. - for i := 0; i < 4; i++ { - failCI(t, m1) - pendingCI(t, m1) - } - if c := notifyCount(notf1, "reaction.escalated"); c != 1 { - t.Fatalf("precondition: want one escalation in first lifetime, got %d", c) - } - - // --- simulated restart: a fresh Manager hydrated from the same store --- - notf2 := &recordingNotifier{} - msgr2 := &recordingMessenger{} - m2 := New(store, notf2, msgr2) - m2.clock = func() time.Time { return t0 } - if err := m2.WithReactionStore(context.Background(), adapter); err != nil { - t.Fatalf("hydrate m2: %v", err) - } - - // The ci-failed tracker rehydrates with escalated=true, so further failures - // are silenced: no new send-to-agent, no re-escalation. - failCI(t, m2) - if c := notifyCount(notf2, "reaction.escalated"); c != 0 { - t.Errorf("restart re-fired an already-escalated page: got %d escalations", c) - } - if len(msgr2.sent) != 0 { - t.Errorf("restart re-sent to agent despite escalated budget: got %d sends", len(msgr2.sent)) - } -} - -// TestReaction_DurabilityClearsOnIncidentOver proves the durable rows are -// removed when an incident resolves, so a later unrelated incident starts from a -// fresh budget rather than a stale escalated=true. -func TestReaction_DurabilityClearsOnIncidentOver(t *testing.T) { - db, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - t.Cleanup(func() { db.Close() }) - store := sqlite.NewStore(db) - adapter := reactionStoreAdapter{store} - - m := New(store, &recordingNotifier{}, &recordingMessenger{}) - m.clock = func() time.Time { return t0 } - if err := m.WithReactionStore(context.Background(), adapter); err != nil { - t.Fatalf("hydrate: %v", err) - } - seedViaUpsert(t, store, lcOpenPR(domain.PRReasonReviewPending)) - - failCI(t, m) - if rows, _ := store.ListReactionTrackers(context.Background()); len(rows) == 0 { - t.Fatalf("precondition: expected a persisted ci-failed tracker") - } - - // Approved+green ends the incident → recovered() clears every tracker. - if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewApproved, CISummary: ports.CIPassing, PRNumber: 7, - }); err != nil { - t.Fatalf("recover: %v", err) - } - if rows, _ := store.ListReactionTrackers(context.Background()); len(rows) != 0 { - t.Errorf("incident-over must clear durable trackers, got %d rows", len(rows)) - } -} diff --git a/backend/internal/lifecycle/reaction_store.go b/backend/internal/lifecycle/reaction_store.go deleted file mode 100644 index f8da7415b6..0000000000 --- a/backend/internal/lifecycle/reaction_store.go +++ /dev/null @@ -1,94 +0,0 @@ -package lifecycle - -// reaction_store.go is the optional durability seam for the escalation engine. -// By default the Manager keeps escalation budgets in memory only (a restart -// resets them, which costs at most a few extra agent retries — never a missed -// human page). When a ReactionStore is wired via WithReactionStore the in-memory -// map becomes a write-through cache over durable rows, so a restart does NOT -// re-fire an already-escalated human notification. -// -// The interface uses lifecycle-local types so the package stays free of any -// storage dependency; the composition root adapts the concrete store to it -// (mirroring the cdc.OutboxStore adapter). - -import ( - "context" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// PersistedTracker is the durable form of one (session,reaction) escalation -// budget — the storage-facing mirror of the in-memory reactionTracker. -type PersistedTracker struct { - SessionID domain.SessionID - Key string - Attempts int - Escalated bool - FirstAttemptAt time.Time - ProjectID domain.ProjectID -} - -// ReactionStore persists escalation budgets so they survive a daemon restart. -type ReactionStore interface { - LoadReactionTrackers(ctx context.Context) ([]PersistedTracker, error) - SaveReactionTracker(ctx context.Context, t PersistedTracker) error - DeleteReactionTracker(ctx context.Context, id domain.SessionID, key string) error - DeleteSessionReactionTrackers(ctx context.Context, id domain.SessionID) error -} - -// WithReactionStore makes escalation budgets durable: it hydrates the in-memory -// trackers from rs and turns on write-through for subsequent mutations. Like -// WithSessionLister it must be called BEFORE any reaper or Apply* dispatch -// starts, since it populates the tracker map without holding trackerMu against -// concurrent reactors. A hydration error is returned so the caller can decide -// whether to proceed with an empty (in-memory) budget set. -func (m *Manager) WithReactionStore(ctx context.Context, rs ReactionStore) error { - m.reactionStore = rs - rows, err := rs.LoadReactionTrackers(ctx) - if err != nil { - return err - } - for _, r := range rows { - m.trackers[trackerKey{id: r.SessionID, key: reactionKey(r.Key)}] = &reactionTracker{ - attempts: r.Attempts, - escalated: r.Escalated, - firstAttemptAt: r.FirstAttemptAt, - projectID: r.ProjectID, - } - } - return nil -} - -// persistTracker write-throughs one tracker's current state. Best-effort: a -// failed write degrades durability to the in-memory default (a restart may -// re-fire one page), so it must not break the synchronous dispatch path. The -// snapshot is taken by the caller under trackerMu and passed by value here so no -// DB I/O happens while the lock is held. -func (m *Manager) persistTracker(ctx context.Context, id domain.SessionID, key reactionKey, snap reactionTracker) { - if m.reactionStore == nil { - return - } - _ = m.reactionStore.SaveReactionTracker(ctx, PersistedTracker{ - SessionID: id, - Key: string(key), - Attempts: snap.attempts, - Escalated: snap.escalated, - FirstAttemptAt: snap.firstAttemptAt, - ProjectID: snap.projectID, - }) -} - -func (m *Manager) deletePersistedTracker(ctx context.Context, id domain.SessionID, key reactionKey) { - if m.reactionStore == nil { - return - } - _ = m.reactionStore.DeleteReactionTracker(ctx, id, string(key)) -} - -func (m *Manager) deletePersistedSessionTrackers(ctx context.Context, id domain.SessionID) { - if m.reactionStore == nil { - return - } - _ = m.reactionStore.DeleteSessionReactionTrackers(ctx, id) -} diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index ac4de400ad..94f149f4dc 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -1,460 +1,397 @@ package lifecycle -// reactions.go is the ACT layer: the reaction table, the per-(session,reaction) -// escalation engine, and the duration-driven TickEscalations the synchronous -// LCM can't wake itself for. Reactions fire from react() after a transition is -// persisted by the Apply* pipeline (see manager.go). +// reactions.go is the ACT layer: after a persisted transition the engine maps +// the session's (state, PR facts) to at most one reaction and dispatches it — +// nudging the agent or paging the human. Two reactions inject live content (CI +// logs, review comments) and re-fire when that content changes; the rest fire +// once on entry, with duration escalation driven by TickEscalations. // -// Dispatch is synchronous: react() runs Send/Notify inline. It is the single -// dispatch chokepoint, so moving it onto a worker goroutine later (once a daemon -// owns that goroutine's lifecycle) is a change confined to this one function. +// Budgets are in-memory: a restart re-arms them, which costs a few extra nudges, +// never a missed page. import ( "context" "fmt" + "strings" + "sync" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// reactionKey names a row in the reaction table and a tracker bucket. type reactionKey string const ( - reactionCIFailed reactionKey = "ci-failed" - reactionChangesRequested reactionKey = "changes-requested" - reactionBugbotComments reactionKey = "bugbot-comments" - reactionMergeConflicts reactionKey = "merge-conflicts" - reactionAgentIdle reactionKey = "agent-idle" - reactionApprovedAndGreen reactionKey = "approved-and-green" - reactionAgentStuck reactionKey = "agent-stuck" - reactionNeedsInput reactionKey = "agent-needs-input" - reactionAgentExited reactionKey = "agent-exited" - reactionPRClosed reactionKey = "pr-closed" - reactionAllComplete reactionKey = "all-complete" + rxCIFailed reactionKey = "ci-failed" + rxReviewComments reactionKey = "review-comments" + rxMergeConflicts reactionKey = "merge-conflicts" + rxIdle reactionKey = "agent-idle" + rxApprovedGreen reactionKey = "approved-and-green" + rxStuck reactionKey = "agent-stuck" + rxNeedsInput reactionKey = "agent-needs-input" + rxExited reactionKey = "agent-exited" + rxPRClosed reactionKey = "pr-closed" + rxMerged reactionKey = "pr-merged" ) -type actionKind string - +// Brakes: stop auto-handling and page a human after this many failed attempts. const ( - actionSendToAgent actionKind = "send-to-agent" - actionNotify actionKind = "notify" - actionAutoMerge actionKind = "auto-merge" + ciBrakeRuns = 3 // last N runs of a failing check all failed + reviewMaxNudge = 3 // re-nudged the agent N times over new review feedback ) -// reactionConfig is one row of the reaction table (distillation §4.1/§4.2). -// -// - retries numeric escalation cap: escalate once attempts exceed it. -// - escalateAfter duration escalation: escalate once this elapses since the -// first attempt (fired by TickEscalations, since the LCM never polls). -// - persistent the tracker survives the status leaving the triggering -// state; it only resets when the incident is truly over (PR no longer open -// or the session terminal). Only ci-failed is persistent, so a flapping -// CI (fail→pending→fail) keeps draining one shared retry budget. +// reactionConfig is one row of the reaction table. toAgent reactions nudge the +// agent; the rest notify the human. escalateAfter (when set) drives a +// duration-based escalation via TickEscalations. type reactionConfig struct { - action actionKind + toAgent bool message string - priority ports.EventPriority eventType string - retries int + priority ports.Priority escalateAfter time.Duration - persistent bool } -// defaultReactions is the product's default behaviour (distillation §4.2). -// auto-merge is intentionally absent: approved-and-green is a notify, so the -// human decides to merge. The auto-merge action kind exists for opt-in configs, -// but no default row uses it. -var defaultReactions = map[reactionKey]reactionConfig{ - reactionCIFailed: { - action: actionSendToAgent, persistent: true, retries: 2, - message: "CI is failing on your PR. Review the failing output below and push a fix.", - eventType: "reaction.ci-failed", priority: ports.PriorityAction, - }, - reactionChangesRequested: { - action: actionSendToAgent, escalateAfter: 30 * time.Minute, - message: "A reviewer requested changes on your PR. Address the comments and push.", - eventType: "reaction.changes-requested", priority: ports.PriorityAction, - }, - reactionBugbotComments: { - action: actionSendToAgent, escalateAfter: 30 * time.Minute, - message: "An automated reviewer left comments on your PR. Address them and push.", - eventType: "reaction.bugbot-comments", priority: ports.PriorityAction, - }, - reactionMergeConflicts: { - action: actionSendToAgent, escalateAfter: 15 * time.Minute, - message: "Your PR has merge conflicts. Rebase onto the base branch and resolve them.", - eventType: "reaction.merge-conflicts", priority: ports.PriorityAction, - }, - reactionAgentIdle: { - action: actionSendToAgent, retries: 2, escalateAfter: 15 * time.Minute, - message: "You appear idle. Continue the task or explain what is blocking you.", - eventType: "reaction.agent-idle", priority: ports.PriorityWarning, - }, - reactionApprovedAndGreen: { - // notify-only: a green, approved PR is the human-decision path — the human - // decides to merge (no auto-merge by default). - action: actionNotify, priority: ports.PriorityAction, - message: "PR is approved and green — ready to merge.", - eventType: "reaction.approved-and-green", - }, - reactionAgentStuck: { - // §4.2 lists a threshold: 10m here; it is intentionally not gated — entry - // into stuck is already debounced upstream by the detecting->stuck - // quarantine (DETECTING_MAX_ATTEMPTS/DURATION), so a second timer would be - // redundant. - action: actionNotify, priority: ports.PriorityUrgent, - message: "Agent is stuck and needs attention.", - eventType: "reaction.agent-stuck", - }, - reactionNeedsInput: { - action: actionNotify, priority: ports.PriorityUrgent, - message: "Agent needs input to continue.", - eventType: "reaction.agent-needs-input", - }, - reactionAgentExited: { - action: actionNotify, priority: ports.PriorityUrgent, - message: "Agent process exited unexpectedly.", - eventType: "reaction.agent-exited", - }, - reactionPRClosed: { - action: actionNotify, priority: ports.PriorityAction, - message: "PR was closed without merging — decide: resume, learn, or terminate.", - eventType: "reaction.pr-closed", - }, - reactionAllComplete: { - action: actionNotify, priority: ports.PriorityInfo, - message: "PR merged — work complete.", - eventType: "reaction.all-complete", - }, +var reactions = map[reactionKey]reactionConfig{ + rxCIFailed: {toAgent: true, eventType: "reaction.ci-failed", priority: ports.PriorityAction, message: "CI is failing on your PR. Review the output below and push a fix."}, + rxReviewComments: {toAgent: true, eventType: "reaction.review-comments", priority: ports.PriorityAction, message: "A reviewer left feedback on your PR. Address it and push."}, + rxMergeConflicts: {toAgent: true, eventType: "reaction.merge-conflicts", priority: ports.PriorityAction, escalateAfter: 15 * time.Minute, message: "Your PR has merge conflicts. Rebase onto the base branch and resolve them."}, + rxIdle: {toAgent: true, eventType: "reaction.agent-idle", priority: ports.PriorityInfo, escalateAfter: 15 * time.Minute, message: "You appear idle. Continue the task or say what is blocking you."}, + rxApprovedGreen: {eventType: "reaction.approved-and-green", priority: ports.PriorityAction, message: "PR is approved and green — ready to merge."}, + rxStuck: {eventType: "reaction.agent-stuck", priority: ports.PriorityUrgent, message: "Agent is stuck and needs attention."}, + rxNeedsInput: {eventType: "reaction.agent-needs-input", priority: ports.PriorityUrgent, message: "Agent needs input to continue."}, + rxExited: {eventType: "reaction.agent-exited", priority: ports.PriorityUrgent, message: "Agent process exited unexpectedly."}, + rxPRClosed: {eventType: "reaction.pr-closed", priority: ports.PriorityAction, message: "PR was closed without merging."}, + rxMerged: {eventType: "reaction.pr-merged", priority: ports.PriorityInfo, message: "PR merged — work complete."}, } -// reactionEventFor maps a canonical record to the reaction it should drive, -// mirroring DeriveLegacyStatus but for the ACT layer. ok is false when the -// current state has no reaction. -// -// A closed PR derives to the idle display status, so it is detected from the PR -// axis directly before falling through to the status mapping. Bot review -// comments and merge conflicts are represented as PR reasons so the ACT layer -// can distinguish them from human-requested changes and plain open PRs. -func reactionEventFor(l domain.CanonicalSessionLifecycle) (reactionKey, bool) { - if l.PR.State == domain.PRClosed { - return reactionPRClosed, true - } - if isActivePRState(l.PR.State) { - switch l.PR.Reason { - case domain.PRReasonBotComments: - return reactionBugbotComments, true - case domain.PRReasonMergeConflicts: - return reactionMergeConflicts, true +// reactionContent carries the live material the feedback reactions inject. Empty +// for runtime/activity transitions; populated from a PR observation. +type reactionContent struct { + ciCheck string + ciCommit string + ciURL string + ciLogTail string + comments []string + reviewSig string +} + +// prContent extracts the CI failure + review feedback from a PR observation. +func prContent(o ports.PRObservation) reactionContent { + c := reactionContent{} + for _, ch := range o.Checks { + if ch.Status == "failed" { + c.ciCheck, c.ciCommit, c.ciLogTail, c.ciURL = ch.Name, ch.CommitHash, ch.LogTail, o.URL + break } } - switch domain.DeriveLegacyStatus(l) { - case domain.StatusCIFailed: - return reactionCIFailed, true - case domain.StatusChangesRequested: - return reactionChangesRequested, true - case domain.StatusApproved, domain.StatusMergeable: - return reactionApprovedAndGreen, true - case domain.StatusIdle: - return reactionAgentIdle, true - case domain.StatusStuck: - return reactionAgentStuck, true - case domain.StatusNeedsInput: - return reactionNeedsInput, true - case domain.StatusKilled: - // Inferred death only — an explicit user kill goes through - // OnKillRequested, which does not react. - return reactionAgentExited, true - case domain.StatusMerged: - return reactionAllComplete, true + var ids []string + for _, cm := range o.Comments { + if cm.Resolved { + continue + } + c.comments = append(c.comments, cm.Body) + ids = append(ids, cm.ID) } - return "", false + c.reviewSig = strings.Join(ids, ",") + return c } -// reactionContext carries fact-derived material the message templates need. The -// SCM path populates it (CI failure log tail); other paths pass the zero value. -type reactionContext struct { - ciFailureLogTail *string -} +// ---- in-memory escalation state ---- -// trackerKey buckets an escalation tracker by session and reaction. type trackerKey struct { id domain.SessionID key reactionKey } -// reactionTracker is the per-(session,reaction) escalation budget. It lives in -// memory on the Manager: a daemon restart resets budgets, which only ever costs -// a few extra agent retries before re-escalating — never a missed human -// notification. Keeping it out of the canonical store preserves the -// truth-vs-policy split (the store holds session truth; this is ACT policy). -// -// projectID is captured at first attempt so TickEscalations — which fires from -// the reaper and has no transition on hand — can still populate ProjectID on -// the escalation event. It is set once and never overwritten; reaction-bearing -// transitions for a given session id always carry the same projectID. -type reactionTracker struct { - attempts int - escalated bool - firstAttemptAt time.Time - projectID domain.ProjectID +type tracker struct { + attempts int + firstAt time.Time + escalated bool + seenSig bool + lastSig string + projectID domain.ProjectID } -// react fires the ACT layer after a persisted transition: clear the tracker for -// the reaction we left, then dispatch the reaction for the one we entered. It -// fires only on a genuine reaction change, so re-persisting the same state does -// not re-dispatch. Synchronous by design (see file header). -// -// Integration-time caveat: react runs AFTER withLock releases (deliberately, so -// a busy-waiting send-to-agent never holds the per-session mutex). Under a live -// daemon with concurrent observers (SCM poller + reaper + activity ingest) the -// afterLC snapshot can be stale by dispatch time — e.g. a ci-failed send firing -// after the session already moved to approved. Tests are single-threaded so it -// is not observable yet; when the daemon lands, give react a per-session -// ordering (a small react queue) or re-check the triggering state before -// dispatching. -func (m *Manager) react(ctx context.Context, id domain.SessionID, tr *transition, rc reactionContext) error { - if tr == nil { - return nil +type reactionState struct { + mu sync.Mutex + trackers map[trackerKey]*tracker + lastKey map[domain.SessionID]reactionKey +} + +func newReactionState() reactionState { + return reactionState{trackers: map[trackerKey]*tracker{}, lastKey: map[domain.SessionID]reactionKey{}} +} + +// trackerFor returns the (id,key) tracker, creating it on first use. Caller holds mu. +func (rs *reactionState) trackerFor(id domain.SessionID, key reactionKey) *tracker { + k := trackerKey{id, key} + t := rs.trackers[k] + if t == nil { + t = &tracker{} + rs.trackers[k] = t } - beforeKey, hadBefore := reactionEventFor(tr.beforeLC) - afterKey, hasAfter := reactionEventFor(tr.afterLC) - - changed := beforeKey != afterKey - - switch { - case incidentOver(tr.afterLC) || recovered(tr.afterLC): - // The PR-pipeline incident has ended — the PR resolved (merged/closed), - // the session went terminal, or it reached an approved/green state. Every - // tracker for this session is now stale, including a persistent ci-failed - // one. This is keyed on the state REACHED, not the one left: the recovery - // transition is typically review_pending->approved (beforeKey empty), so - // clearing only beforeKey would leak the ci-failed tracker and leave its - // escalated=true to silence a future regression. Clear them all. - m.clearSessionTrackers(ctx, id) - case hadBefore && (!hasAfter || changed): - // Within an unresolved open PR: a normal tracker resets when its state is - // left. A persistent one (ci-failed) is NOT cleared here — it must survive - // the ambiguous review_pending limbo (the fail->pending->fail flap, §4.2); - // it only resets via the recovery/incident-over branch above. - if !defaultReactions[beforeKey].persistent { - m.clearTracker(ctx, id, beforeKey) + return t +} + +func (m *Manager) clearReactions(id domain.SessionID) { + m.react.mu.Lock() + defer m.react.mu.Unlock() + for k := range m.react.trackers { + if k.id == id { + delete(m.react.trackers, k) } } + delete(m.react.lastKey, id) +} + +// ---- dispatch ---- + +// runReactions is the chokepoint called after every persisted transition. It +// runs unlocked (the write lock is already released) so a busy agent send never +// blocks the write path. +func (m *Manager) runReactions(ctx context.Context, id domain.SessionID, content reactionContent) error { + rec, ok, err := m.store.GetSession(ctx, id) + if err != nil || !ok { + return err + } + lc := rec.Lifecycle + project := rec.ProjectID - if hasAfter && (!hadBefore || changed) { - return m.executeReaction(ctx, id, tr.projectID, afterKey, rc) + if isTerminal(lc.Session.State) { + err := m.dispatch(ctx, id, project, terminalReaction(lc.TerminationReason)) + m.clearReactions(id) // incident over: drop budgets after the final notify + return err } - return nil -} -// incidentOver reports that a PR-pipeline incident has truly ended (PR no longer -// active, or the session terminal), so all trackers for the session may reset. -func incidentOver(l domain.CanonicalSessionLifecycle) bool { - return !isActivePRState(l.PR.State) || isTerminal(l.Session.State) -} + pr, err := m.store.PRFactsForSession(ctx, id) + if err != nil { + return err + } -func isActivePRState(s domain.PRState) bool { - return s == domain.PROpen || s == domain.PRDraft + // Feedback reactions inject live content and re-fire as it changes — only + // while the agent can actually act on it. + if pr.Exists && !pr.Closed && !needsHuman(lc.Session.State) { + if pr.CI == domain.CIFailing && content.ciCheck != "" { + if err := m.handleCIFailure(ctx, id, project, content); err != nil { + return err + } + } + if hasReviewFeedback(pr) { + if err := m.handleReviewFeedback(ctx, id, project, content); err != nil { + return err + } + } + } + + return m.dispatch(ctx, id, project, reactionFor(lc, pr)) } -// recovered reports a genuinely-green open PR: an approved/mergeable state, which -// unambiguously means CI is no longer failing (the open-PR ladder ranks ci_failing -// above approved, so an approved display cannot coexist with failing CI). Unlike -// the ambiguous review_pending state — which may just be CI re-running — reaching -// this ends a ci-failed incident and re-arms its budget. Draft PRs are active, -// but not recoverable via review/merge state. -func recovered(l domain.CanonicalSessionLifecycle) bool { - if !isActivePRState(l.PR.State) || l.PR.State == domain.PRDraft { - return false +// dispatch fires the entry reaction for key, deduped so a steady state does not +// re-fire. Leaving a reaction drops its budget. +func (m *Manager) dispatch(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey) error { + m.react.mu.Lock() + if m.react.lastKey[id] == key { + m.react.mu.Unlock() + return nil } - switch l.PR.Reason { - case domain.PRReasonApproved, domain.PRReasonMergeReady: - return true - default: - return false + if prev := m.react.lastKey[id]; prev != "" { + delete(m.react.trackers, trackerKey{id, prev}) } -} + m.react.lastKey[id] = key + m.react.mu.Unlock() -func (m *Manager) executeReaction(ctx context.Context, id domain.SessionID, projectID domain.ProjectID, key reactionKey, rc reactionContext) error { - cfg := defaultReactions[key] - switch cfg.action { - case actionNotify: - // notify reactions are human-attention terminals: fire once on the - // triggering transition, no retry/escalation budget. - return m.notifier.Notify(ctx, ports.OrchestratorEvent{ - Type: cfg.eventType, - Priority: cfg.priority, - SessionID: id, - ProjectID: projectID, - Message: cfg.message, - }) - case actionAutoMerge: - // Off by default: no default row maps here, and wiring a merge port is a - // later PR. An opt-in config could route a reaction here. + if key == "" { return nil - case actionSendToAgent: - return m.sendToAgent(ctx, id, projectID, key, cfg, rc) } - return nil + cfg := reactions[key] + if cfg.toAgent { + return m.fireAgentEntry(ctx, id, project, key, cfg) + } + return m.fireNotify(ctx, id, project, cfg) } -// sendToAgent runs the escalation engine for an auto send-to-agent reaction: -// count the attempt, escalate when the numeric cap or duration is exceeded -// (silencing further auto-dispatch), else inject the message via the messenger. -func (m *Manager) sendToAgent(ctx context.Context, id domain.SessionID, projectID domain.ProjectID, key reactionKey, cfg reactionConfig, rc reactionContext) error { - m.trackerMu.Lock() - tk := m.trackerFor(id, key) - // Capture projectID once so the duration-based TickEscalations path — which - // has no transition on hand — can still populate ProjectID on the escalation - // event. A non-empty incoming projectID always wins, in case the tracker was - // first created from an observation that lacked one. - if projectID != "" { - tk.projectID = projectID - } - if tk.escalated { - m.trackerMu.Unlock() - return nil // silenced until the condition clears the tracker +// reactionFor maps (session state, PR facts) to the reaction to enter. CI failure +// and review feedback return "" here — they are handled by the feedback path. +func reactionFor(lc domain.CanonicalSessionLifecycle, pr domain.PRFacts) reactionKey { + switch lc.Session.State { + case domain.SessionStuck: + return rxStuck + case domain.SessionNeedsInput: + return rxNeedsInput } - now := m.clock() - freshFirst := tk.firstAttemptAt.IsZero() - if freshFirst { - tk.firstAttemptAt = now + if pr.Exists { + if pr.Closed { + if !pr.Merged { + return rxPRClosed + } + return "" + } + switch { + case pr.CI == domain.CIFailing, hasReviewFeedback(pr): + return "" // feedback path + case pr.Mergeability == domain.MergeConflicting: + return rxMergeConflicts + case pr.Mergeability == domain.MergeMergeable, pr.Review == domain.ReviewApproved: + return rxApprovedGreen + } } - tk.attempts++ - escalateNow := shouldEscalate(tk, cfg, now) - if escalateNow { - tk.escalated = true + if lc.Session.State == domain.SessionIdle { + return rxIdle } - snap := *tk - m.trackerMu.Unlock() + return "" +} - // Write through the new budget (incl. escalated) before dispatching, so a - // crash between persist and notify re-fires at most the same page on restart. - m.persistTracker(ctx, id, key, snap) +func hasReviewFeedback(pr domain.PRFacts) bool { + return pr.Review == domain.ReviewChangesRequest || pr.ReviewComments +} - if escalateNow { - return m.escalate(ctx, id, snap.projectID, key) - } +func needsHuman(s domain.SessionState) bool { + return s == domain.SessionStuck || s == domain.SessionNeedsInput +} - if err := m.messenger.Send(ctx, id, composeMessage(cfg, rc)); err != nil { - // A delivery failure must not consume escalation budget: roll this - // attempt back so the next relevant transition retries from the same - // point rather than marching toward escalation on undelivered messages - // (distillation §4.3). - m.trackerMu.Lock() - tk.attempts-- - if freshFirst { - tk.firstAttemptAt = time.Time{} - } - rolled := *tk - m.trackerMu.Unlock() - m.persistTracker(ctx, id, key, rolled) - return err +// terminalReaction is the notify fired when a session reaches a terminal state by +// inferred death. An explicit kill goes through OnKillRequested (no reaction); +// auto_cleanup / pr_merged are notified elsewhere. +func terminalReaction(r domain.TerminationReason) reactionKey { + switch r { + case domain.TermRuntimeLost, domain.TermAgentProcessExited, domain.TermProbeFailure, domain.TermErrorInProcess: + return rxExited + default: + return "" } - return nil } -// shouldEscalate uses inclusive boundaries: escalate once the numeric cap is -// exceeded or once exactly escalateAfter has elapsed (don't wait for the next -// tick to cross a strict threshold). -func shouldEscalate(tk *reactionTracker, cfg reactionConfig, now time.Time) bool { - if cfg.retries > 0 && tk.attempts > cfg.retries { - return true - } - if cfg.escalateAfter > 0 && !tk.firstAttemptAt.IsZero() && now.Sub(tk.firstAttemptAt) >= cfg.escalateAfter { - return true - } - return false +// ---- feedback reactions (content-driven re-fire + brake) ---- + +func (m *Manager) handleCIFailure(ctx context.Context, id domain.SessionID, project domain.ProjectID, c reactionContent) error { + msg := reactions[rxCIFailed].message + "\n\nFailing output:\n" + c.ciLogTail + return m.fireFeedback(ctx, id, project, rxCIFailed, c.ciCommit, msg, func(int) (bool, error) { + st, err := m.pr.RecentCheckStatuses(ctx, c.ciURL, c.ciCheck, ciBrakeRuns) + if err != nil { + return false, err + } + return allFailed(st, ciBrakeRuns), nil + }) } -// escalate emits reaction.escalated and notifies the human. The caller has -// already set tracker.escalated under the lock, which silences further -// auto-dispatch for this reaction until the tracker clears. -func (m *Manager) escalate(ctx context.Context, id domain.SessionID, projectID domain.ProjectID, key reactionKey) error { - return m.notifier.Notify(ctx, ports.OrchestratorEvent{ - Type: "reaction.escalated", - Priority: ports.PriorityUrgent, - SessionID: id, - ProjectID: projectID, - Message: fmt.Sprintf("auto-handling of %q is exhausted and needs a human.", key), - Data: map[string]any{"reaction": string(key)}, +func (m *Manager) handleReviewFeedback(ctx context.Context, id domain.SessionID, project domain.ProjectID, c reactionContent) error { + msg := reactions[rxReviewComments].message + if len(c.comments) > 0 { + msg += "\n\n" + strings.Join(c.comments, "\n\n") + } + return m.fireFeedback(ctx, id, project, rxReviewComments, c.reviewSig, msg, func(attempts int) (bool, error) { + return attempts > reviewMaxNudge, nil }) } -func composeMessage(cfg reactionConfig, rc reactionContext) string { - if rc.ciFailureLogTail != nil && *rc.ciFailureLogTail != "" { - return cfg.message + "\n\nFailing output:\n" + *rc.ciFailureLogTail +// fireFeedback nudges the agent with fresh content, deduped by signature so the +// same content is not re-sent each poll. braked decides whether to escalate to a +// human instead (CI: history; review: attempt count). +func (m *Manager) fireFeedback(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, sig, message string, braked func(attempts int) (bool, error)) error { + m.react.mu.Lock() + t := m.react.trackerFor(id, key) + if project != "" { + t.projectID = project + } + if t.escalated || (t.seenSig && t.lastSig == sig) { + m.react.mu.Unlock() + return nil } - return cfg.message + t.seenSig, t.lastSig = true, sig + t.attempts++ + attempts, pid := t.attempts, t.projectID + m.react.lastKey[id] = key // feedback owns the slot so a later dispatch("") clears it + m.react.mu.Unlock() + + brake, err := braked(attempts) + if err != nil { + return err + } + if brake { + m.react.mu.Lock() + t.escalated = true + m.react.mu.Unlock() + return m.escalate(ctx, id, pid, key) + } + return m.messenger.Send(ctx, id, message) } -// trackerFor returns the tracker for (id,key), creating it on first use. The -// caller must hold trackerMu. -func (m *Manager) trackerFor(id domain.SessionID, key reactionKey) *reactionTracker { - k := trackerKey{id: id, key: key} - tk := m.trackers[k] - if tk == nil { - tk = &reactionTracker{} - m.trackers[k] = tk +// ---- entry reactions ---- + +// fireAgentEntry nudges the agent once on entry into a static reaction +// (idle/merge-conflicts); escalation is duration-based via TickEscalations. +func (m *Manager) fireAgentEntry(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, cfg reactionConfig) error { + m.react.mu.Lock() + t := m.react.trackerFor(id, key) + if project != "" { + t.projectID = project + } + if t.escalated { + m.react.mu.Unlock() + return nil + } + if t.firstAt.IsZero() { + t.firstAt = m.clock() } - return tk + t.attempts++ + m.react.mu.Unlock() + return m.messenger.Send(ctx, id, cfg.message) } -func (m *Manager) clearTracker(ctx context.Context, id domain.SessionID, key reactionKey) { - m.trackerMu.Lock() - delete(m.trackers, trackerKey{id: id, key: key}) - m.trackerMu.Unlock() - m.deletePersistedTracker(ctx, id, key) +func (m *Manager) fireNotify(ctx context.Context, id domain.SessionID, project domain.ProjectID, cfg reactionConfig) error { + return m.notifier.Notify(ctx, ports.Event{ + Type: cfg.eventType, Priority: cfg.priority, + SessionID: id, ProjectID: project, Message: cfg.message, + }) } -// clearSessionTrackers drops every tracker for a session — used when its -// incident is over, so no budget (and no stale escalated=true) survives into a -// later unrelated incident. -func (m *Manager) clearSessionTrackers(ctx context.Context, id domain.SessionID) { - m.trackerMu.Lock() - for k := range m.trackers { - if k.id == id { - delete(m.trackers, k) - } - } - m.trackerMu.Unlock() - m.deletePersistedSessionTrackers(ctx, id) +func (m *Manager) escalate(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey) error { + return m.notifier.Notify(ctx, ports.Event{ + Type: "reaction.escalated", Priority: ports.PriorityUrgent, + SessionID: id, ProjectID: project, + Message: fmt.Sprintf("Automatic handling of %q is exhausted — needs a human.", key), + }) } -// TickEscalations fires the duration-based escalations the synchronous LCM -// cannot wake itself for. The reaper calls it on a timer; it escalates any -// not-yet-escalated tracker whose escalateAfter has elapsed. Notifications are -// sent outside the lock so agent/notifier latency never blocks tracker access. +// TickEscalations fires the duration-based escalations the synchronous engine +// cannot wake itself for. The reaper calls it on a timer. func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { type due struct { - id domain.SessionID - projectID domain.ProjectID - key reactionKey - snap reactionTracker + id domain.SessionID + project domain.ProjectID + key reactionKey } var fire []due - - m.trackerMu.Lock() - for k, tk := range m.trackers { - if tk.escalated { + m.react.mu.Lock() + for k, t := range m.react.trackers { + if t.escalated { continue } - cfg := defaultReactions[k.key] - if cfg.escalateAfter > 0 && !tk.firstAttemptAt.IsZero() && now.Sub(tk.firstAttemptAt) >= cfg.escalateAfter { - tk.escalated = true - fire = append(fire, due{id: k.id, projectID: tk.projectID, key: k.key, snap: *tk}) + cfg := reactions[k.key] + if cfg.escalateAfter > 0 && !t.firstAt.IsZero() && now.Sub(t.firstAt) >= cfg.escalateAfter { + t.escalated = true + fire = append(fire, due{k.id, t.projectID, k.key}) } } - m.trackerMu.Unlock() + m.react.mu.Unlock() for _, d := range fire { - m.persistTracker(ctx, d.id, d.key, d.snap) - if err := m.escalate(ctx, d.id, d.projectID, d.key); err != nil { + if err := m.escalate(ctx, d.id, d.project, d.key); err != nil { return err } } return nil } + +func allFailed(statuses []string, n int) bool { + if len(statuses) < n { + return false + } + for i := 0; i < n; i++ { + if statuses[i] != "failed" { + return false + } + } + return true +} diff --git a/backend/internal/lifecycle/reactions_test.go b/backend/internal/lifecycle/reactions_test.go deleted file mode 100644 index 637b1e5bd0..0000000000 --- a/backend/internal/lifecycle/reactions_test.go +++ /dev/null @@ -1,616 +0,0 @@ -package lifecycle - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -// failingMessenger always fails delivery, counting attempts — used to assert a -// send failure does not consume escalation budget. -type failingMessenger struct{ attempts int } - -func (f *failingMessenger) Send(_ context.Context, _ domain.SessionID, _ string) error { - f.attempts++ - return fmt.Errorf("messenger unavailable") -} - -// newReactive wires a Manager with handles on the recording fakes so reaction -// tests can assert what was sent/notified. clock is pinned to t0 for -// deterministic escalation stamping. -func newReactive() (*Manager, *fakeStore, *recordingNotifier, *recordingMessenger) { - store := newFakeStore() - notf := &recordingNotifier{} - msgr := &recordingMessenger{} - m := New(store, notf, msgr) - m.clock = func() time.Time { return t0 } - return m, store, notf, msgr -} - -func lcOpenPR(reason domain.PRReason) domain.CanonicalSessionLifecycle { - l := lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive) - l.PR = domain.PRSubstate{State: domain.PROpen, Reason: reason, Number: 7} - return l -} - -func notifyCount(n *recordingNotifier, eventType string) int { - n.mu.Lock() - defer n.mu.Unlock() - c := 0 - for _, e := range n.events { - if e.Type == eventType { - c++ - } - } - return c -} - -func ctx() context.Context { return context.Background() } - -// ---- right reaction per transition ---- - -func TestReaction_CIFailedSendsToAgentWithLogTail(t *testing.T) { - m, store, notf, msgr := newReactive() - store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) - - tail := "build failed\nundefined: foo" - err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, CISummary: ports.CIFailing, - PRNumber: 7, CIFailureLogTail: &tail, - }) - if err != nil { - t.Fatalf("apply: %v", err) - } - - if len(msgr.sent) != 1 { - t.Fatalf("want 1 send, got %d", len(msgr.sent)) - } - if got := msgr.sent[0].Message; !strings.Contains(got, "CI is failing") || !strings.Contains(got, tail) { - t.Errorf("message missing base text or log tail: %q", got) - } - if notifyCount(notf, "reaction.escalated") != 0 { - t.Error("a first failure must not escalate") - } -} - -func TestReaction_BotAndHumanCommentsRouteSeparately(t *testing.T) { - tests := []struct { - name string - comments []ports.ReviewComment - wantMessage string - }{ - { - name: "bot comments -> bugbot-comments", - comments: []ports.ReviewComment{{Author: "bugbot", Body: "fix", IsBot: true}}, - wantMessage: "automated reviewer", - }, - { - name: "human comments -> changes-requested", - comments: []ports.ReviewComment{{Author: "reviewer", Body: "fix"}}, - wantMessage: "reviewer requested changes", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m, store, _, msgr := newReactive() - store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) - - if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, PendingComments: tt.comments, PRNumber: 7, - }); err != nil { - t.Fatalf("apply: %v", err) - } - - if len(msgr.sent) != 1 { - t.Fatalf("want one send, got %d", len(msgr.sent)) - } - if !strings.Contains(msgr.sent[0].Message, tt.wantMessage) { - t.Errorf("message %q does not contain %q", msgr.sent[0].Message, tt.wantMessage) - } - }) - } -} - -func TestReaction_MergeConflictsSendsToAgent(t *testing.T) { - m, store, _, msgr := newReactive() - store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) - - if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, PRNumber: 7, - Mergeability: ports.Mergeability{CIPassing: true, Approved: true, NoConflicts: false, Blockers: []string{"merge conflicts"}}, - }); err != nil { - t.Fatalf("apply: %v", err) - } - - if len(msgr.sent) != 1 { - t.Fatalf("want one send, got %d", len(msgr.sent)) - } - if !strings.Contains(msgr.sent[0].Message, "merge conflicts") { - t.Errorf("message = %q, want merge conflict nudge", msgr.sent[0].Message) - } -} - -func TestReaction_ApprovedAndGreenNotifiesNeverAutoMerges(t *testing.T) { - m, store, notf, msgr := newReactive() - store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) - - err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewApproved, - Mergeability: ports.Mergeability{Mergeable: true}, PRNumber: 7, - }) - if err != nil { - t.Fatalf("apply: %v", err) - } - - // approved-and-green is notify (human decides to merge); the agent is never - // messaged and no auto-merge fires. - if len(msgr.sent) != 0 { - t.Errorf("approved-and-green must not message the agent, got %d sends", len(msgr.sent)) - } - if notifyCount(notf, "reaction.approved-and-green") != 1 { - t.Errorf("want one approved-and-green notify, got events %+v", notf.events) - } -} - -func TestReaction_NotifyEventsForHardStates(t *testing.T) { - tests := []struct { - name string - apply func(m *Manager) - eventType string - }{ - { - name: "waiting_input -> agent-needs-input", - apply: func(m *Manager) { applyActivity(m, domain.ActivityWaitingInput) }, - eventType: "reaction.agent-needs-input", - }, - { - name: "blocked -> agent-stuck", - apply: func(m *Manager) { applyActivity(m, domain.ActivityBlocked) }, - eventType: "reaction.agent-stuck", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - m, store, notf, msgr := newReactive() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) - tc.apply(m) - if notifyCount(notf, tc.eventType) != 1 { - t.Errorf("want one %s, got events %+v", tc.eventType, notf.events) - } - if len(msgr.sent) != 0 { - t.Errorf("notify reaction must not message the agent, got %d", len(msgr.sent)) - } - }) - } -} - -func TestReaction_InferredDeathNotifiesAgentExited(t *testing.T) { - m, store, notf, _ := newReactive() - store.seed(sid, detectingLC()) - - err := m.ApplyRuntimeObservation(ctx(), sid, ports.RuntimeFacts{ - RuntimeState: ports.RuntimeProbeDead, ProcessState: ports.ProcessProbeDead, ObservedAt: t0, - }) - if err != nil { - t.Fatalf("apply: %v", err) - } - if l := mustLoad(t, store); domain.DeriveLegacyStatus(l) != domain.StatusKilled { - t.Fatalf("precondition: want killed, got %s", domain.DeriveLegacyStatus(l)) - } - if notifyCount(notf, "reaction.agent-exited") != 1 { - t.Errorf("want one agent-exited, got events %+v", notf.events) - } -} - -func TestReaction_PRClosedAndMerged(t *testing.T) { - tests := []struct { - name string - prState domain.PRState - eventType string - }{ - {"closed -> pr-closed", domain.PRClosed, "reaction.pr-closed"}, - {"merged -> all-complete", domain.PRMerged, "reaction.all-complete"}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - m, store, notf, _ := newReactive() - store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) - err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: tc.prState, PRNumber: 7, - }) - if err != nil { - t.Fatalf("apply: %v", err) - } - if notifyCount(notf, tc.eventType) != 1 { - t.Errorf("want one %s, got events %+v", tc.eventType, notf.events) - } - }) - } -} - -func TestReaction_OnKillRequestedDoesNotReact(t *testing.T) { - m, store, notf, msgr := newReactive() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) - - if err := m.OnKillRequested(ctx(), sid, ports.KillReason{Kind: ports.KillManual}); err != nil { - t.Fatalf("kill: %v", err) - } - // An explicit human kill is not an inferred event: no agent-exited, no send. - if len(notf.events) != 0 || len(msgr.sent) != 0 { - t.Errorf("explicit kill must fire no reaction: notifies=%+v sends=%+v", notf.events, msgr.sent) - } -} - -// ---- escalation engine ---- - -func TestReaction_CIFailedNumericEscalation(t *testing.T) { - m, store, notf, msgr := newReactive() - store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) - - // ci-failed has retries 2 and is persistent, so the budget is shared across - // fail->pending->fail oscillations and escalates on the third failure. - failN := 4 - for i := 0; i < failN; i++ { - failCI(t, m) - pendingCI(t, m) // oscillate out (persistent tracker must NOT reset) - } - - if len(msgr.sent) != 2 { - t.Errorf("want 2 auto-sends before escalation, got %d", len(msgr.sent)) - } - if c := notifyCount(notf, "reaction.escalated"); c != 1 { - t.Errorf("want exactly one escalation, got %d", c) - } -} - -func TestReaction_DraftPRDoesNotEndCIFailedIncident(t *testing.T) { - m, store, _, _ := newReactive() - seed := lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive) - seed.PR = domain.PRSubstate{State: domain.PRDraft, Reason: domain.PRReasonInProgress, Number: 7} - store.seed(sid, seed) - - tail := "fail" - if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PRDraft, CISummary: ports.CIFailing, PRNumber: 7, CIFailureLogTail: &tail, - }); err != nil { - t.Fatalf("draft fail: %v", err) - } - if sessionTrackerCount(m, sid) == 0 { - t.Fatalf("precondition: expected a ci-failed tracker") - } - - if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PRDraft, CISummary: ports.CIPending, PRNumber: 7, - }); err != nil { - t.Fatalf("draft pending: %v", err) - } - if n := sessionTrackerCount(m, sid); n == 0 { - t.Errorf("draft PR is still active; ci-failed tracker should survive, got %d", n) - } -} - -func TestReaction_DurationEscalationFiresOnTick(t *testing.T) { - m, store, notf, msgr := newReactive() - store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) - - // changes-requested: send once now, then escalate by duration (30m) — which - // only the reaper's TickEscalations can fire (the LCM never polls). - err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewChangesRequested, PRNumber: 7, - }) - if err != nil { - t.Fatalf("apply: %v", err) - } - if len(msgr.sent) != 1 { - t.Fatalf("want one send on transition, got %d", len(msgr.sent)) - } - - if err := m.TickEscalations(ctx(), t0.Add(10*time.Minute)); err != nil { - t.Fatalf("tick: %v", err) - } - if notifyCount(notf, "reaction.escalated") != 0 { - t.Error("must not escalate before escalateAfter elapses") - } - - // Inclusive boundary: escalate at exactly escalateAfter (30m), not only past it. - if err := m.TickEscalations(ctx(), t0.Add(30*time.Minute)); err != nil { - t.Fatalf("tick: %v", err) - } - if notifyCount(notf, "reaction.escalated") != 1 { - t.Errorf("want one duration escalation at exactly 30m, got events %+v", notf.events) - } -} - -func TestReaction_KillClearsEscalationTrackers(t *testing.T) { - m, store, notf, _ := newReactive() - store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) - - // changes-requested creates a duration-based tracker. - if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewChangesRequested, PRNumber: 7, - }); err != nil { - t.Fatalf("apply: %v", err) - } - if sessionTrackerCount(m, sid) == 0 { - t.Fatalf("precondition: expected a tracker") - } - - if err := m.OnKillRequested(ctx(), sid, ports.KillReason{Kind: ports.KillManual}); err != nil { - t.Fatalf("kill: %v", err) - } - if n := sessionTrackerCount(m, sid); n != 0 { - t.Errorf("kill must clear trackers, %d left", n) - } - // A later duration tick must not escalate a dead session. - if err := m.TickEscalations(ctx(), t0.Add(time.Hour)); err != nil { - t.Fatalf("tick: %v", err) - } - if c := notifyCount(notf, "reaction.escalated"); c != 0 { - t.Errorf("killed session must not escalate, got %d", c) - } -} - -func TestReaction_SendFailureDoesNotBurnBudget(t *testing.T) { - store := newFakeStore() - notf := &recordingNotifier{} - fm := &failingMessenger{} - m := New(store, notf, fm) - m.clock = func() time.Time { return t0 } - store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) - - tail := "fail" - failing := ports.SCMFacts{Fetched: true, PRState: domain.PROpen, CISummary: ports.CIFailing, PRNumber: 7, CIFailureLogTail: &tail} - pending := ports.SCMFacts{Fetched: true, PRState: domain.PROpen, CISummary: ports.CIPending, ReviewDecision: ports.ReviewPending, PRNumber: 7} - - // ci-failed has retries 2; with every delivery failing, the budget is rolled - // back each time, so even 5 failures never escalate. - for i := 0; i < 5; i++ { - _ = m.ApplySCMObservation(ctx(), sid, failing) // returns the delivery error - _ = m.ApplySCMObservation(ctx(), sid, pending) - } - if fm.attempts < 5 { - t.Errorf("expected at least 5 send attempts, got %d", fm.attempts) - } - if c := notifyCount(notf, "reaction.escalated"); c != 0 { - t.Errorf("undelivered messages must not escalate, got %d", c) - } -} - -func TestReaction_NonPersistentTrackerClearsOnLeave(t *testing.T) { - m, store, _, msgr := newReactive() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) - - // agent-idle has retries 2 but is NOT persistent: leaving idle clears the - // tracker, so three idle incidents each send fresh and none escalate. - for i := 0; i < 3; i++ { - applyActivity(m, domain.ActivityIdle) - applyActivity(m, domain.ActivityActive) - } - if len(msgr.sent) != 3 { - t.Errorf("want 3 idle sends (budget reset each incident), got %d", len(msgr.sent)) - } -} - -func TestReaction_CIFailedRearmsOnGenuineRecovery(t *testing.T) { - m, store, notf, msgr := newReactive() - store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) - - // Drain the ci-failed budget to escalation (silenced thereafter). - for i := 0; i < 4; i++ { - failCI(t, m) - pendingCI(t, m) - } - if notifyCount(notf, "reaction.escalated") != 1 { - t.Fatalf("precondition: want one escalation, got %d", notifyCount(notf, "reaction.escalated")) - } - sentBefore := len(msgr.sent) - - // A genuine recovery (approved + green) ends the incident and re-arms the - // budget; a later regression must re-nudge the agent, not stay silenced. - if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewApproved, - Mergeability: ports.Mergeability{Mergeable: true}, PRNumber: 7, - }); err != nil { - t.Fatalf("recover: %v", err) - } - failCI(t, m) - - if len(msgr.sent) != sentBefore+1 { - t.Errorf("regression after recovery must re-nudge the agent: sends %d -> %d", sentBefore, len(msgr.sent)) - } -} - -func TestReaction_IncidentOverClearsAllSessionTrackers(t *testing.T) { - m, store, _, _ := newReactive() - store.seed(sid, lcOpenPR(domain.PRReasonReviewPending)) - - failCI(t, m) // creates a persistent ci-failed tracker - if sessionTrackerCount(m, sid) == 0 { - t.Fatalf("precondition: expected a ci-failed tracker") - } - - // Merging ends the incident; no tracker (and no stale escalated=true) may - // survive for the session. - if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PRMerged, PRNumber: 7, - }); err != nil { - t.Fatalf("merge: %v", err) - } - if n := sessionTrackerCount(m, sid); n != 0 { - t.Errorf("incident over must clear all trackers, %d left", n) - } -} - -// ---- ProjectID propagation (review R11) ---- - -// TestReaction_ProjectIDOnNotifyAndEscalateEvents asserts that both Notify call -// sites in reactions.go (executeReaction's notify and escalate) carry the -// record's ProjectID. The human-facing event router groups by project, so a -// missing id would land events in the wrong bucket. -func TestReaction_ProjectIDOnNotifyAndEscalateEvents(t *testing.T) { - const proj domain.ProjectID = "acme" - - t.Run("notify path -> ProjectID populated", func(t *testing.T) { - m, store, notf, _ := newReactive() - // Seed via Upsert (not the lifecycle-only seed helper) so the record carries - // the ProjectID that mutate's transition then propagates to react. - if err := store.Upsert(ctx(), domain.SessionRecord{ - ID: sid, ProjectID: proj, Lifecycle: lcOpenPR(domain.PRReasonReviewPending), - }, ports.EventSessionCreated); err != nil { - t.Fatalf("upsert: %v", err) - } - - // approved-and-green is a notify reaction; it fires once via executeReaction. - err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewApproved, - Mergeability: ports.Mergeability{Mergeable: true}, PRNumber: 7, - }) - if err != nil { - t.Fatalf("apply: %v", err) - } - - notf.mu.Lock() - defer notf.mu.Unlock() - var got *ports.OrchestratorEvent - for i := range notf.events { - if notf.events[i].Type == "reaction.approved-and-green" { - got = ¬f.events[i] - break - } - } - if got == nil { - t.Fatalf("expected approved-and-green notify, got events: %+v", notf.events) - } - if got.ProjectID != proj { - t.Errorf("notify ProjectID = %q, want %q", got.ProjectID, proj) - } - if got.SessionID != sid { - t.Errorf("notify SessionID = %q, want %q", got.SessionID, sid) - } - }) - - t.Run("escalate path -> ProjectID populated (numeric cap)", func(t *testing.T) { - m, store, notf, _ := newReactive() - if err := store.Upsert(ctx(), domain.SessionRecord{ - ID: sid, ProjectID: proj, Lifecycle: lcOpenPR(domain.PRReasonReviewPending), - }, ports.EventSessionCreated); err != nil { - t.Fatalf("upsert: %v", err) - } - - // Drain the ci-failed budget to numeric escalation (sendToAgent -> escalate). - for i := 0; i < 4; i++ { - failCI(t, m) - pendingCI(t, m) - } - - notf.mu.Lock() - defer notf.mu.Unlock() - var got *ports.OrchestratorEvent - for i := range notf.events { - if notf.events[i].Type == "reaction.escalated" { - got = ¬f.events[i] - break - } - } - if got == nil { - t.Fatalf("expected reaction.escalated event, got events: %+v", notf.events) - } - if got.ProjectID != proj { - t.Errorf("escalate ProjectID = %q, want %q", got.ProjectID, proj) - } - }) - - t.Run("escalate path -> ProjectID populated (TickEscalations duration)", func(t *testing.T) { - m, store, notf, _ := newReactive() - if err := store.Upsert(ctx(), domain.SessionRecord{ - ID: sid, ProjectID: proj, Lifecycle: lcOpenPR(domain.PRReasonReviewPending), - }, ports.EventSessionCreated); err != nil { - t.Fatalf("upsert: %v", err) - } - - // changes-requested creates a duration-based tracker on the first send; - // TickEscalations fires escalate from a path with no transition on hand, - // so the tracker's captured ProjectID is what must surface on the event. - if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, ReviewDecision: ports.ReviewChangesRequested, PRNumber: 7, - }); err != nil { - t.Fatalf("apply: %v", err) - } - if err := m.TickEscalations(ctx(), t0.Add(30*time.Minute)); err != nil { - t.Fatalf("tick: %v", err) - } - - notf.mu.Lock() - defer notf.mu.Unlock() - var got *ports.OrchestratorEvent - for i := range notf.events { - if notf.events[i].Type == "reaction.escalated" { - got = ¬f.events[i] - break - } - } - if got == nil { - t.Fatalf("expected duration-escalated event, got events: %+v", notf.events) - } - if got.ProjectID != proj { - t.Errorf("tick-escalate ProjectID = %q, want %q", got.ProjectID, proj) - } - }) -} - -func sessionTrackerCount(m *Manager, id domain.SessionID) int { - m.trackerMu.Lock() - defer m.trackerMu.Unlock() - c := 0 - for k := range m.trackers { - if k.id == id { - c++ - } - } - return c -} - -// ---- TickEscalations never writes canonical state ---- - -func TestTickEscalations_DoesNotPersist(t *testing.T) { - m, store, _, _ := newReactive() - store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) - if err := m.TickEscalations(ctx(), t0); err != nil { - t.Fatalf("tick: %v", err) - } - if l := mustLoad(t, store); l.Revision != 0 { - t.Errorf("TickEscalations must not write canonical state, got revision=%d", l.Revision) - } -} - -// ---- helpers ---- - -func applyActivity(m *Manager, a domain.ActivityState) { - _ = m.ApplyActivitySignal(ctx(), sid, ports.ActivitySignal{ - State: ports.SignalValid, Activity: a, Timestamp: t0, Source: domain.SourceHook, - }) -} - -func failCI(t *testing.T, m *Manager) { - t.Helper() - tail := "fail" - if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, CISummary: ports.CIFailing, PRNumber: 7, CIFailureLogTail: &tail, - }); err != nil { - t.Fatalf("failCI: %v", err) - } -} - -func pendingCI(t *testing.T, m *Manager) { - t.Helper() - if err := m.ApplySCMObservation(ctx(), sid, ports.SCMFacts{ - Fetched: true, PRState: domain.PROpen, CISummary: ports.CIPending, ReviewDecision: ports.ReviewPending, PRNumber: 7, - }); err != nil { - t.Fatalf("pendingCI: %v", err) - } -} diff --git a/backend/internal/observe/reaper/reaper.go b/backend/internal/observe/reaper/reaper.go index 579f1d6347..7edee2b10a 100644 --- a/backend/internal/observe/reaper/reaper.go +++ b/backend/internal/observe/reaper/reaper.go @@ -183,16 +183,16 @@ func (r *Reaper) probeOne(ctx context.Context, sess domain.SessionRecord, now ti // transient tmux/zellij outage hide a really-dead session, and a // transient adapter bug terminate a really-alive one. Report failed // and let the LCM's detecting quarantine arbitrate. - facts.RuntimeState = ports.RuntimeProbeFailed - facts.ProcessState = ports.ProcessProbeFailed + facts.Runtime = ports.ProbeFailed + facts.Process = ports.ProbeFailed r.logger.Debug("reaper: probe error reported as failed fact", "session", sess.ID, "runtime", handle.RuntimeName, "err", probeErr) case alive: - facts.RuntimeState = ports.RuntimeProbeAlive - facts.ProcessState = ports.ProcessProbeAlive + facts.Runtime = ports.ProbeAlive + facts.Process = ports.ProbeAlive default: - facts.RuntimeState = ports.RuntimeProbeDead - facts.ProcessState = ports.ProcessProbeDead + facts.Runtime = ports.ProbeDead + facts.Process = ports.ProbeDead } if err := r.lcm.ApplyRuntimeObservation(ctx, sess.ID, facts); err != nil { diff --git a/backend/internal/observe/reaper/reaper_test.go b/backend/internal/observe/reaper/reaper_test.go index 0d3b4d4792..ffb3eed453 100644 --- a/backend/internal/observe/reaper/reaper_test.go +++ b/backend/internal/observe/reaper/reaper_test.go @@ -1,385 +1,115 @@ -package reaper_test +package reaper import ( "context" "errors" - "reflect" - "sync" + "io" + "log/slog" "testing" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// ---- fakes ---- +var ctx = context.Background() -type aliveResult struct { - alive bool - err error -} - -// fakeRuntime is a programmable ports.Runtime. The reaper only calls IsAlive, -// but the interface requires the other methods so we stub them. -type fakeRuntime struct { - mu sync.Mutex - results map[string]aliveResult - probed []string -} - -var _ ports.Runtime = (*fakeRuntime)(nil) - -func (f *fakeRuntime) IsAlive(_ context.Context, h ports.RuntimeHandle) (bool, error) { - f.mu.Lock() - f.probed = append(f.probed, h.ID) - f.mu.Unlock() - r, ok := f.results[h.ID] - if !ok { - return false, errors.New("fakeRuntime: no programmed response for " + h.ID) - } - return r.alive, r.err -} - -func (f *fakeRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) { - return ports.RuntimeHandle{}, nil -} -func (f *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil } -func (f *fakeRuntime) SendMessage(context.Context, ports.RuntimeHandle, string) error { - return nil -} -func (f *fakeRuntime) GetOutput(context.Context, ports.RuntimeHandle, int) (string, error) { - return "", nil -} - -// fakeLCM records every reaper-facing call in order so tests can assert the -// exact sequence (TickEscalations -> RunningSessions -> ApplyRuntimeObservation). type fakeLCM struct { - mu sync.Mutex - sessions []domain.SessionRecord - calls []call - - runErr error - tickErr error - obsErr error -} - -type call struct { - Kind string - Now time.Time - Session domain.SessionID - Facts ports.RuntimeFacts + running []domain.SessionRecord + observed map[domain.SessionID]ports.RuntimeFacts + escalated int } -var _ ports.LifecycleManager = (*fakeLCM)(nil) - -func (l *fakeLCM) RunningSessions(_ context.Context) ([]domain.SessionRecord, error) { - l.mu.Lock() - defer l.mu.Unlock() - l.calls = append(l.calls, call{Kind: "RunningSessions"}) - if l.runErr != nil { - return nil, l.runErr - } - out := make([]domain.SessionRecord, len(l.sessions)) - copy(out, l.sessions) - return out, nil +func (l *fakeLCM) RunningSessions(context.Context) ([]domain.SessionRecord, error) { + return l.running, nil } - -func (l *fakeLCM) TickEscalations(_ context.Context, now time.Time) error { - l.mu.Lock() - defer l.mu.Unlock() - l.calls = append(l.calls, call{Kind: "TickEscalations", Now: now}) - return l.tickErr -} - func (l *fakeLCM) ApplyRuntimeObservation(_ context.Context, id domain.SessionID, f ports.RuntimeFacts) error { - l.mu.Lock() - defer l.mu.Unlock() - l.calls = append(l.calls, call{Kind: "ApplyRuntimeObservation", Session: id, Facts: f}) - return l.obsErr -} - -// unused methods on the LCM port — the reaper never invokes them. -func (l *fakeLCM) ApplySCMObservation(context.Context, domain.SessionID, ports.SCMFacts) error { + if l.observed == nil { + l.observed = map[domain.SessionID]ports.RuntimeFacts{} + } + l.observed[id] = f return nil } +func (l *fakeLCM) TickEscalations(context.Context, time.Time) error { l.escalated++; return nil } func (l *fakeLCM) ApplyActivitySignal(context.Context, domain.SessionID, ports.ActivitySignal) error { return nil } -func (l *fakeLCM) OnSpawnInitiated(context.Context, domain.SessionRecord) error { return nil } +func (l *fakeLCM) ApplyPRObservation(context.Context, domain.SessionID, ports.PRObservation) error { + return nil +} func (l *fakeLCM) OnSpawnCompleted(context.Context, domain.SessionID, ports.SpawnOutcome) error { return nil } -func (l *fakeLCM) OnKillRequested(context.Context, domain.SessionID, ports.KillReason) error { +func (l *fakeLCM) OnKillRequested(context.Context, domain.SessionID, domain.TerminationReason) error { return nil } -// ---- helpers ---- +type fakeRuntime struct { + alive bool + err error +} -func aliveSessionWith(id domain.SessionID, runtimeName, handleID string) domain.SessionRecord { - return domain.SessionRecord{ - ID: id, - Lifecycle: domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress}, - Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, - }, - Metadata: domain.SessionMetadata{ - RuntimeHandleID: handleID, - RuntimeName: runtimeName, - }, - } +func (r fakeRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) { + return ports.RuntimeHandle{}, nil +} +func (r fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil } +func (r fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { + return r.alive, r.err } -// detectingSessionWith returns a session in the Detecting quarantine, the -// shape `Manager.RunningSessions` MUST include so a probe-alive can recover it -// (otherwise the reaper traps every session that hiccups once in detecting). -func detectingSessionWith(id domain.SessionID, runtimeName, handleID string) domain.SessionRecord { +func probableSession(id domain.SessionID) domain.SessionRecord { return domain.SessionRecord{ - ID: id, + ID: id, + Metadata: domain.SessionMetadata{RuntimeHandleID: "h1", RuntimeName: "tmux"}, Lifecycle: domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: domain.SessionDetecting, Reason: domain.ReasonProbeFailure}, - Runtime: domain.RuntimeSubstate{State: domain.RuntimeProbeFailed, Reason: domain.RuntimeReasonProbeError}, - }, - Metadata: domain.SessionMetadata{ - RuntimeHandleID: handleID, - RuntimeName: runtimeName, + Session: domain.SessionSubstate{State: domain.SessionWorking}, }, } } -// ---- tests ---- +func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -func TestReaper_Tick(t *testing.T) { - now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) - clock := func() time.Time { return now } - - type runtimeProbes struct { - name string - results map[string]aliveResult - } - - tests := []struct { - name string - sessions []domain.SessionRecord - runtimes []runtimeProbes - wantCalls []call - wantProbe map[string][]string // runtime name -> handle IDs probed, in order - }{ - { - // "No death applied" per the spec: the LCM does not receive a - // death-causing fact. It still receives the alive fact, because - // the reaper reports what it probed and the LCM is the one that - // diffs against canonical (a no-op when runtime is already alive, - // a recovery when the session was in Detecting). - name: "alive session: alive fact reported, no death applied, tick still fires", - sessions: []domain.SessionRecord{aliveSessionWith("s1", "tmux", "h1")}, - runtimes: []runtimeProbes{{name: "tmux", results: map[string]aliveResult{"h1": {alive: true}}}}, - wantCalls: []call{ - {Kind: "TickEscalations", Now: now}, - {Kind: "RunningSessions"}, - { - Kind: "ApplyRuntimeObservation", - Session: "s1", - Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeAlive, ProcessState: ports.ProcessProbeAlive}, - }, - }, - wantProbe: map[string][]string{"tmux": {"h1"}}, - }, - { - // Recovery path: a session in Detecting+probe_failed must be in - // the poll set so an alive probe can flow through and recover it. - // If the reaper filtered to runtime-axis-alive only, this session - // would be trapped in Detecting forever. - name: "detecting session: alive probe reported so LCM can recover from quarantine", - sessions: []domain.SessionRecord{detectingSessionWith("s1", "tmux", "h1")}, - runtimes: []runtimeProbes{{name: "tmux", results: map[string]aliveResult{"h1": {alive: true}}}}, - wantCalls: []call{ - {Kind: "TickEscalations", Now: now}, - {Kind: "RunningSessions"}, - { - Kind: "ApplyRuntimeObservation", - Session: "s1", - Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeAlive, ProcessState: ports.ProcessProbeAlive}, - }, - }, - wantProbe: map[string][]string{"tmux": {"h1"}}, - }, - { - name: "dead session: exactly one ApplyRuntimeObservation with Dead facts", - sessions: []domain.SessionRecord{aliveSessionWith("s1", "tmux", "h1")}, - runtimes: []runtimeProbes{{name: "tmux", results: map[string]aliveResult{"h1": {alive: false}}}}, - wantCalls: []call{ - {Kind: "TickEscalations", Now: now}, - {Kind: "RunningSessions"}, - { - Kind: "ApplyRuntimeObservation", - Session: "s1", - Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeDead, ProcessState: ports.ProcessProbeDead}, - }, - }, - wantProbe: map[string][]string{"tmux": {"h1"}}, - }, - { - name: "probe error: reported as failed fact, NOT collapsed to alive", - sessions: []domain.SessionRecord{aliveSessionWith("s1", "tmux", "h1")}, - runtimes: []runtimeProbes{{name: "tmux", results: map[string]aliveResult{"h1": {err: errors.New("boom")}}}}, - wantCalls: []call{ - {Kind: "TickEscalations", Now: now}, - {Kind: "RunningSessions"}, - { - Kind: "ApplyRuntimeObservation", - Session: "s1", - Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeFailed, ProcessState: ports.ProcessProbeFailed}, - }, - }, - wantProbe: map[string][]string{"tmux": {"h1"}}, - }, - { - name: "multi-runtime dispatch: tmux + zellij in same tick", - sessions: []domain.SessionRecord{ - aliveSessionWith("s1", "tmux", "ht"), - aliveSessionWith("s2", "zellij", "hz"), - }, - runtimes: []runtimeProbes{ - {name: "tmux", results: map[string]aliveResult{"ht": {alive: false}}}, - {name: "zellij", results: map[string]aliveResult{"hz": {alive: true}}}, - }, - wantCalls: []call{ - {Kind: "TickEscalations", Now: now}, - {Kind: "RunningSessions"}, - { - Kind: "ApplyRuntimeObservation", - Session: "s1", - Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeDead, ProcessState: ports.ProcessProbeDead}, - }, - { - Kind: "ApplyRuntimeObservation", - Session: "s2", - Facts: ports.RuntimeFacts{ObservedAt: now, RuntimeState: ports.RuntimeProbeAlive, ProcessState: ports.ProcessProbeAlive}, - }, - }, - wantProbe: map[string][]string{"tmux": {"ht"}, "zellij": {"hz"}}, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - lcm := &fakeLCM{sessions: tc.sessions} - registry := reaper.MapRegistry{} - byName := map[string]*fakeRuntime{} - for _, r := range tc.runtimes { - rt := &fakeRuntime{results: r.results} - registry[r.name] = rt - byName[r.name] = rt - } - rp := reaper.New(lcm, registry, reaper.Config{Clock: clock, Tick: time.Hour}) - - if err := rp.Tick(context.Background()); err != nil { - t.Fatalf("Tick error: %v", err) - } - - if !reflect.DeepEqual(lcm.calls, tc.wantCalls) { - t.Errorf("LCM call log mismatch:\n got %#v\n want %#v", lcm.calls, tc.wantCalls) - } - - for name, want := range tc.wantProbe { - got := byName[name].probed - if !reflect.DeepEqual(got, want) { - t.Errorf("runtime %q probed handles mismatch: got %v want %v", name, got, want) - } - } - }) - } +func newReaper(lcm *fakeLCM, rt fakeRuntime) *Reaper { + return New(lcm, MapRegistry{"tmux": rt}, Config{Logger: quietLogger()}) } -// TestReaper_Loop verifies the background goroutine actually drives ticks and -// exits on context cancel without leaking. -func TestReaper_Loop(t *testing.T) { - now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) - clock := func() time.Time { return now } - lcm := &fakeLCM{} - rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Clock: clock, Tick: 5 * time.Millisecond}) - - ctx, cancel := context.WithCancel(context.Background()) - done := rp.Start(ctx) - - // Wait for at least two ticks so we know the loop is actually firing. - deadline := time.Now().Add(500 * time.Millisecond) - for time.Now().Before(deadline) { - lcm.mu.Lock() - n := countKind(lcm.calls, "TickEscalations") - lcm.mu.Unlock() - if n >= 2 { - break - } - time.Sleep(2 * time.Millisecond) +func TestTick_ReportsAliveProbe(t *testing.T) { + lcm := &fakeLCM{running: []domain.SessionRecord{probableSession("mer-1")}} + if err := newReaper(lcm, fakeRuntime{alive: true}).Tick(ctx); err != nil { + t.Fatal(err) } - cancel() - - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("reaper goroutine did not exit within 1s of ctx cancel") - } - - lcm.mu.Lock() - defer lcm.mu.Unlock() - if got := countKind(lcm.calls, "TickEscalations"); got < 2 { - t.Errorf("expected at least 2 TickEscalations calls during loop, got %d", got) + if lcm.observed["mer-1"].Runtime != ports.ProbeAlive { + t.Fatalf("want alive probe, got %q", lcm.observed["mer-1"].Runtime) } } -func countKind(calls []call, kind string) int { - n := 0 - for _, c := range calls { - if c.Kind == kind { - n++ - } +func TestTick_ReportsProbeErrorAsFailed(t *testing.T) { + lcm := &fakeLCM{running: []domain.SessionRecord{probableSession("mer-1")}} + if err := newReaper(lcm, fakeRuntime{err: errors.New("tmux gone")}).Tick(ctx); err != nil { + t.Fatal(err) + } + if lcm.observed["mer-1"].Runtime != ports.ProbeFailed { + t.Fatalf("probe error must be reported as failed, got %q", lcm.observed["mer-1"].Runtime) } - return n } -// TestReaper_SkipsUnknownRuntime verifies the reaper does not panic and does not -// report a fact when a session references an unregistered runtime — the reaper -// only reports what it actually probed. -func TestReaper_SkipsUnknownRuntime(t *testing.T) { - now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) - clock := func() time.Time { return now } - lcm := &fakeLCM{sessions: []domain.SessionRecord{aliveSessionWith("s1", "ghost", "h1")}} - rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Clock: clock, Tick: time.Hour}) - - if err := rp.Tick(context.Background()); err != nil { - t.Fatalf("Tick error: %v", err) +func TestTick_FiresEscalationHeartbeat(t *testing.T) { + lcm := &fakeLCM{} + if err := newReaper(lcm, fakeRuntime{}).Tick(ctx); err != nil { + t.Fatal(err) } - - for _, c := range lcm.calls { - if c.Kind == "ApplyRuntimeObservation" { - t.Fatalf("unexpected ApplyRuntimeObservation for unknown-runtime session: %+v", c) - } + if lcm.escalated != 1 { + t.Fatalf("tick must drive TickEscalations once, got %d", lcm.escalated) } } -// TestReaper_SkipsMissingHandle verifies the reaper does not probe (and does not -// report) for sessions whose runtime handle metadata is missing — probing -// nothing returns no fact. -func TestReaper_SkipsMissingHandle(t *testing.T) { - now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) - clock := func() time.Time { return now } - sess := aliveSessionWith("s1", "tmux", "h1") - sess.Metadata.RuntimeHandleID = "" - lcm := &fakeLCM{sessions: []domain.SessionRecord{sess}} - rt := &fakeRuntime{results: map[string]aliveResult{}} - rp := reaper.New(lcm, reaper.MapRegistry{"tmux": rt}, reaper.Config{Clock: clock, Tick: time.Hour}) - - if err := rp.Tick(context.Background()); err != nil { - t.Fatalf("Tick error: %v", err) - } - if len(rt.probed) != 0 { - t.Errorf("expected no probes for session without handle id, got %v", rt.probed) +func TestTick_SkipsSessionWithoutHandle(t *testing.T) { + noHandle := domain.SessionRecord{ID: "mer-1"} // no runtime metadata + lcm := &fakeLCM{running: []domain.SessionRecord{noHandle}} + if err := newReaper(lcm, fakeRuntime{alive: true}).Tick(ctx); err != nil { + t.Fatal(err) } - for _, c := range lcm.calls { - if c.Kind == "ApplyRuntimeObservation" { - t.Fatalf("unexpected ApplyRuntimeObservation: %+v", c) - } + if _, probed := lcm.observed["mer-1"]; probed { + t.Fatal("a session without a runtime handle must be skipped") } } diff --git a/backend/internal/ports/facts.go b/backend/internal/ports/facts.go index e1854facc4..a3b3b39795 100644 --- a/backend/internal/ports/facts.go +++ b/backend/internal/ports/facts.go @@ -1,9 +1,6 @@ -// Package ports declares the boundary contracts for the LCM + Session Manager -// lane: the inbound interfaces we implement, the outbound interfaces others -// implement for us, and the fact DTOs that cross those boundaries. -// -// These are the types the SCM poller, persistence adapter, and API layer build -// against, so they are committed and stabilised before the LCM/SM logic. +// Package ports declares the boundary contracts for the lifecycle lane: the +// inbound interfaces the engine implements, the outbound interfaces its adapters +// implement, and the plain DTOs that cross those edges. It holds no logic. package ports import ( @@ -12,122 +9,55 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// SCMFacts is produced by the SCM poller and handed to ApplySCMObservation. -// -// Fetched is the failed-probe guard: when false, the GitHub query timed out or -// errored and the rest of the struct is meaningless — the LCM must NOT read it -// as "no PR / PR closed" (the SCM analogue of "failed probe != dead"). -// -// CIFailureLogTail is a pointer because it is only populated when CI is failing; -// it carries ~120 lines and we don't want it on the hot poll path otherwise. -type SCMFacts struct { - Fetched bool - ObservedAt time.Time - PRState domain.PRState - Draft bool - PRNumber int - PRURL string - CISummary CISummary - ReviewDecision ReviewDecision - Mergeability Mergeability - PendingComments []ReviewComment - CIFailureLogTail *string -} - -type CISummary string +// ProbeResult is a single liveness reading. "failed" (the probe errored/timed +// out) and "unknown" (ran but couldn't tell) are kept distinct from dead — both +// route to the detecting quarantine, never to a death conclusion. +type ProbeResult string const ( - CIPending CISummary = "pending" - CIPassing CISummary = "passing" - CIFailing CISummary = "failing" - CINone CISummary = "none" + ProbeAlive ProbeResult = "alive" + ProbeDead ProbeResult = "dead" + ProbeFailed ProbeResult = "failed" + ProbeUnknown ProbeResult = "unknown" ) -type ReviewDecision string - -const ( - ReviewApproved ReviewDecision = "approved" - ReviewChangesRequested ReviewDecision = "changes_requested" - ReviewPending ReviewDecision = "pending" - ReviewNone ReviewDecision = "none" -) - -// Mergeability is the structured "can this merge?" answer. CIPassing/Approved -// here overlap CISummary/ReviewDecision by design (different granularity); -// Mergeability is authoritative for the merge gate, the others for display. -type Mergeability struct { - Mergeable bool - CIPassing bool - Approved bool - NoConflicts bool - Blockers []string -} - -// ReviewComment carries IsBot so the decider can route bot review comments -// (bugbot-comments reaction) differently from human ones (changes-requested). -type ReviewComment struct { - Author string - Body string - IsBot bool - URL string -} - -// RuntimeFacts is produced by the reaper and handed to ApplyRuntimeObservation. +// RuntimeFacts is what the reaper reports each probe: is the runtime container +// up, and is the agent process inside it up. type RuntimeFacts struct { - ObservedAt time.Time - RuntimeState RuntimeProbe - ProcessState ProcessProbe + ObservedAt time.Time + Runtime ProbeResult + Process ProbeResult } -// RuntimeProbe / ProcessProbe keep "failed" (the probe call itself errored or -// timed out) distinct from "indeterminate" (the probe ran but couldn't tell) — -// they route differently in the decider. -type RuntimeProbe string - -const ( - RuntimeProbeAlive RuntimeProbe = "alive" - RuntimeProbeDead RuntimeProbe = "dead" - RuntimeProbeIndeterminate RuntimeProbe = "indeterminate" - RuntimeProbeFailed RuntimeProbe = "failed" -) - -type ProcessProbe string - -const ( - ProcessProbeAlive ProcessProbe = "alive" - ProcessProbeDead ProcessProbe = "dead" - ProcessProbeIndeterminate ProcessProbe = "indeterminate" - ProcessProbeFailed ProcessProbe = "failed" -) - -// ActivitySignal is pushed by agent hooks / the FS watcher. State is the -// confidence wrapper (so unavailable/probe_failure != idleness); Activity is -// the actual classification. +// ActivitySignal is pushed by the agent hooks. Only a Valid signal is +// authoritative; a stale/absent one is ignored rather than read as idleness. type ActivitySignal struct { - State SignalConfidence - Activity domain.ActivityState + Valid bool + State domain.ActivityState Timestamp time.Time Source domain.ActivitySource } -type SignalConfidence string - -const ( - SignalValid SignalConfidence = "valid" - SignalStale SignalConfidence = "stale" - SignalNull SignalConfidence = "null" - SignalUnavailable SignalConfidence = "unavailable" - SignalProbeFailure SignalConfidence = "probe_failure" -) +// PRObservation is what the SCM poller reports for one PR. Fetched is the +// failed-fetch guard: when false the rest is meaningless and the engine must not +// read it as "PR closed". Checks/Comments are the current full sets (the engine +// records the checks and replaces the comment set). +type PRObservation struct { + Fetched bool + URL string + Number int + Draft bool + Merged bool + Closed bool + CI domain.CIState + Review domain.ReviewDecision + Mergeability domain.Mergeability + Checks []PRCheckRow + Comments []PRComment +} -// SpawnOutcome is what the Session Manager reports to the LCM after a spawn. -// RuntimeHandle is the same structured handle the Runtime port returns, so no -// ad-hoc string encoding is needed for later Destroy/SendMessage calls. -// -// Prompt is the assembled launch prompt persisted as metadata so Restore can -// fall back to a fresh launch (Agent.GetLaunchCommand) when the agent's native -// session id was never captured — without it Restore would have nothing to -// resume and nothing to re-seed a fresh run with. +// SpawnOutcome is what the Session Manager reports once a spawn is live: the +// handles needed for later teardown/restore. type SpawnOutcome struct { Branch string WorkspacePath string @@ -136,17 +66,41 @@ type SpawnOutcome struct { Prompt string } -// KillReason is what the Session Manager reports to the LCM when a kill is -// requested. Kind drives whether the terminal state is killed/cleanup/errored. -type KillReason struct { - Kind LifecycleKillReason - Detail string +// ---- store row DTOs (shared by the PRWriter port and its sqlite adapter) ---- + +// PRRow is the scalar PR facts row. +type PRRow struct { + URL string + SessionID string + Number int + Draft bool + Merged bool + Closed bool + CI domain.CIState + Review domain.ReviewDecision + Mergeability domain.Mergeability + UpdatedAt time.Time } -type LifecycleKillReason string +// PRCheckRow is one CI check run (one row per check name per commit). +type PRCheckRow struct { + PRURL string + Name string + CommitHash string + Status string + URL string + LogTail string + CreatedAt time.Time +} -const ( - KillManual LifecycleKillReason = "manual" - KillCleanup LifecycleKillReason = "cleanup" - KillError LifecycleKillReason = "error" -) +// PRComment is one review comment. Review feedback is injected into the agent +// regardless of author, so there is no bot/human distinction. +type PRComment struct { + ID string + Author string + File string + Line int + Body string + Resolved bool + CreatedAt time.Time +} diff --git a/backend/internal/ports/inbound.go b/backend/internal/ports/inbound.go index 58ec2015f1..00223ae9a4 100644 --- a/backend/internal/ports/inbound.go +++ b/backend/internal/ports/inbound.go @@ -7,73 +7,45 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// LifecycleManager is the inbound contract we implement. Every Apply* method -// runs the same synchronous pipeline: load canonical -> pure decide -> diff -> -// persist (full-row Upsert) -> if the status transitioned, fire reactions. The LCM -// never polls; observers (SCM poller, reaper, activity ingest) call in. -// -// Concurrency: the LCM serialises per session, so concurrent Apply* calls for -// the same session do not race the load/decide/persist read-modify-write. +// LifecycleManager is the inbound contract the engine implements. Observers +// (reaper, SCM poller, activity hooks) and the Session Manager call in; the LCM +// is the sole writer of canonical transitions and the only place reactions fire. type LifecycleManager interface { - // Raw-fact entrypoints (each runs decide internally). - ApplySCMObservation(ctx context.Context, id domain.SessionID, f SCMFacts) error ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f RuntimeFacts) error ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ActivitySignal) error + ApplyPRObservation(ctx context.Context, id domain.SessionID, o PRObservation) error - // Mutation commands/outcomes reported by the Session Manager. - OnSpawnInitiated(ctx context.Context, rec domain.SessionRecord) error + // OnSpawnCompleted marks a session live and records its handles. It works for + // a fresh spawn (not_started -> live) and a restore (terminal -> reopened). OnSpawnCompleted(ctx context.Context, id domain.SessionID, o SpawnOutcome) error - OnKillRequested(ctx context.Context, id domain.SessionID, r KillReason) error + OnKillRequested(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) error - // Reaper heartbeat that drives duration-based escalation (a non-polling - // LCM can't wake itself to fire a "30m elapsed" escalation). + // TickEscalations fires the duration-based escalations the synchronous LCM + // can't wake itself for; the reaper calls it on a timer. TickEscalations(ctx context.Context, now time.Time) error - - // RunningSessions returns a snapshot of every session whose runtime axis is - // alive. The reaper calls it once per tick to decide whom to probe. It is a - // read snapshot — the slice and its elements are safe for the caller to - // iterate without holding any LCM lock — and does not violate the - // single-writer invariant (the reaper never writes; it reports facts back - // through ApplyRuntimeObservation). + // RunningSessions snapshots every non-terminal session for the reaper to probe. RunningSessions(ctx context.Context) ([]domain.SessionRecord, error) } -// SessionManager is the inbound contract called by the API layer and CLI. It -// owns explicit mutations (spawn/kill/restore/cleanup) and never writes -// sessions directly — it routes mutation commands/outcomes to the LCM. +// SessionManager is the inbound contract the API/CLI call for explicit +// mutations. It drives the runtime/agent/workspace plugins and routes canonical +// writes to the LCM. type SessionManager interface { Spawn(ctx context.Context, cfg SpawnConfig) (domain.Session, error) - Kill(ctx context.Context, id domain.SessionID, opts KillOptions) (KillResult, error) + Kill(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) (freed bool, err error) + Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) Send(ctx context.Context, id domain.SessionID, message string) error - Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) - Cleanup(ctx context.Context, project domain.ProjectID) (CleanupResult, error) + Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) } type SpawnConfig struct { ProjectID domain.ProjectID IssueID domain.IssueID Kind domain.SessionKind + Harness domain.AgentHarness Branch string Prompt string AgentRules string - // OpenTerminal is reserved for a later lane (open a terminal tab on spawn). - // Spawn does NOT honor it yet — setting it has no effect. - OpenTerminal bool -} - -type KillOptions struct { - Reason LifecycleKillReason - Detail string -} - -type KillResult struct { - ID domain.SessionID - WorkspaceFreed bool -} - -type CleanupResult struct { - Cleaned []domain.SessionID - Skipped []domain.SessionID // e.g. paths that still held uncommitted work } diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index c64a1e6d44..d180f53858 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -6,86 +6,63 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// LifecycleStore is Tom's persistence adapter for session records. -// -// Writer contract: the Lifecycle Manager (LCM) is the sole logical writer of -// sessions. Controllers, the Session Manager, observers, and other goroutines -// must route mutations to the LCM; no other goroutine writes sessions directly. -// The LCM serializes mutations and calls Upsert with the full SessionRecord and -// the classified event_type. The storage layer owns Revision++ and performs the -// full-row insert-or-update; the older sparse merge-patch model is gone. -// -// List/Get return persistence records (no derived status); the Session Manager -// hydrates them into domain.Session by attaching DeriveLegacyStatus on read. -type LifecycleStore interface { - // Upsert inserts or replaces the full session row and bumps Revision inside - // the storage layer. Only the LCM may call it. - Upsert(ctx context.Context, rec domain.SessionRecord, eventType EventType) error - Load(ctx context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) - List(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) - GetMetadata(ctx context.Context, id domain.SessionID) (domain.SessionMetadata, error) - PatchMetadata(ctx context.Context, id domain.SessionID, meta domain.SessionMetadata) error - - // Get returns a single full record (with identity) by id. Load is - // lifecycle-only, so readers use this to build the read-model and reconstruct - // teardown handles for Kill/Restore on one id. - Get(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) +// SessionStore persists session records and serves the derived read-model's PR +// facts. The Session Manager creates rows; the Lifecycle Manager is the sole +// writer of canonical transitions thereafter. +type SessionStore interface { + CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) + UpdateSession(ctx context.Context, rec domain.SessionRecord) error + GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) + ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) + ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) + // PRFactsForSession returns the PR facts that drive a session's display + // status: the most-recently-updated non-closed PR, else the most recent. + // Zero value (Exists=false) means the session has no PR. + PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) } -// EventType is the schema-level event label attached to each Upsert. -type EventType string - -const ( - EventSessionCreated EventType = "session_created" - EventSessionTerminated EventType = "session_terminated" - EventSessionStateChanged EventType = "session_state_changed" - EventSessionPRUpdated EventType = "session_pr_updated" - EventSessionRuntimeUpdated EventType = "session_runtime_updated" - EventSessionAttentionUpdated EventType = "session_attention_updated" - EventSessionActivityUpdated EventType = "session_activity_updated" - EventSessionDisplayUpdated EventType = "session_display_updated" - EventSessionUpdated EventType = "session_updated" -) +// PRWriter records the PR facts a PR observation carries. The pr table's own DB +// triggers emit the CDC; this just writes the rows. +type PRWriter interface { + UpsertPR(ctx context.Context, r PRRow) error + RecordCheck(ctx context.Context, r PRCheckRow) error + RecentCheckStatuses(ctx context.Context, prURL, name string, limit int) ([]string, error) + ReplacePRComments(ctx context.Context, prURL string, comments []PRComment) error +} -// Notifier delivers events to the human (desktop/Slack later). Push, never pull. +// Notifier delivers an event to the human (desktop/Slack later). Push, never poll. type Notifier interface { - Notify(ctx context.Context, event OrchestratorEvent) error + Notify(ctx context.Context, event Event) error +} + +// AgentMessenger injects a message into a running agent (busy-detecting until the +// agent is ready). Used by the auto-nudge reactions. +type AgentMessenger interface { + Send(ctx context.Context, id domain.SessionID, message string) error } -type EventPriority string +type Priority string const ( - PriorityUrgent EventPriority = "urgent" - PriorityAction EventPriority = "action" - PriorityWarning EventPriority = "warning" - PriorityInfo EventPriority = "info" + PriorityUrgent Priority = "urgent" + PriorityAction Priority = "action" + PriorityInfo Priority = "info" ) -type OrchestratorEvent struct { +// Event is a human-facing notification produced by a reaction. +type Event struct { Type string - Priority EventPriority + Priority Priority SessionID domain.SessionID ProjectID domain.ProjectID Message string - Data map[string]any -} - -// AgentMessenger injects a message into a running agent. The implementation -// busy-detects (waits for the agent to be idle/ready) and verifies delivery, -// which is why activity-detection accuracy matters. -type AgentMessenger interface { - Send(ctx context.Context, id domain.SessionID, message string) error } -// The runtime/agent/workspace plugin ports are co-owned with the coding-agents -// lane; the method sets below are the minimum the Session Manager spawn/kill -// pipelines call. They will be fleshed out alongside the tmux/claude-code impls. +// ---- runtime / agent / workspace plugin ports (used by the Session Manager) ---- type Runtime interface { Create(ctx context.Context, cfg RuntimeConfig) (RuntimeHandle, error) Destroy(ctx context.Context, handle RuntimeHandle) error - SendMessage(ctx context.Context, handle RuntimeHandle, message string) error - GetOutput(ctx context.Context, handle RuntimeHandle, lines int) (string, error) IsAlive(ctx context.Context, handle RuntimeHandle) (bool, error) } @@ -104,10 +81,6 @@ type RuntimeHandle struct { type Agent interface { GetLaunchCommand(cfg AgentConfig) string GetEnvironment(cfg AgentConfig) map[string]string - // ProbeProcess returns the agent process liveness classification - // (alive/dead/indeterminate/failed) — not a boolean and not an activity - // state. Activity classification arrives separately via ActivitySignal. - ProbeProcess(ctx context.Context, handle RuntimeHandle) (ProcessProbe, error) GetRestoreCommand(agentSessionID string) string } @@ -120,7 +93,6 @@ type AgentConfig struct { type Workspace interface { Create(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) Destroy(ctx context.Context, info WorkspaceInfo) error - List(ctx context.Context, project domain.ProjectID) ([]WorkspaceInfo, error) Restore(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) } diff --git a/backend/internal/session/fakes_test.go b/backend/internal/session/fakes_test.go deleted file mode 100644 index 033f6de757..0000000000 --- a/backend/internal/session/fakes_test.go +++ /dev/null @@ -1,400 +0,0 @@ -package session - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -// callLog records the cross-fake call order so tests can assert pipeline -// sequencing (e.g. OnKillRequested before Runtime.Destroy before Workspace.Destroy). -type callLog struct { - mu sync.Mutex - calls []string -} - -func (c *callLog) add(s string) { - c.mu.Lock() - defer c.mu.Unlock() - c.calls = append(c.calls, s) -} - -func (c *callLog) snapshot() []string { - c.mu.Lock() - defer c.mu.Unlock() - out := make([]string, len(c.calls)) - copy(out, c.calls) - return out -} - -// indexOf returns the position of the first call equal to name, or -1. -func (c *callLog) indexOf(name string) int { - for i, s := range c.snapshot() { - if s == name { - return i - } - } - return -1 -} - -// ---- fakeStore: in-memory LifecycleStore with full-row Upsert + Get ---- - -type fakeStore struct { - mu sync.Mutex - records map[domain.SessionID]*domain.SessionRecord - metadata map[domain.SessionID]domain.SessionMetadata -} - -var _ ports.LifecycleStore = (*fakeStore)(nil) - -func newFakeStore() *fakeStore { - return &fakeStore{ - records: map[domain.SessionID]*domain.SessionRecord{}, - metadata: map[domain.SessionID]domain.SessionMetadata{}, - } -} - -func (s *fakeStore) Upsert(_ context.Context, rec domain.SessionRecord, _ ports.EventType) error { - s.mu.Lock() - defer s.mu.Unlock() - if existing, ok := s.records[rec.ID]; ok { - if rec.Lifecycle.Revision != existing.Lifecycle.Revision { - return fmt.Errorf("revision mismatch for %s: have %d, want %d", rec.ID, rec.Lifecycle.Revision, existing.Lifecycle.Revision) - } - rec.Lifecycle.Revision = existing.Lifecycle.Revision + 1 - } else { - if rec.Lifecycle.Revision != 0 { - return fmt.Errorf("revision mismatch for insert %s: have %d, want 0", rec.ID, rec.Lifecycle.Revision) - } - rec.Lifecycle.Revision = 1 - } - if rec.Lifecycle.Version == 0 { - rec.Lifecycle.Version = domain.LifecycleVersion - } - r := rec - s.records[rec.ID] = &r - return nil -} - -func (s *fakeStore) Get(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - rec, ok := s.records[id] - if !ok { - return domain.SessionRecord{}, false, nil - } - return s.withMetadata(*rec), true, nil -} - -func (s *fakeStore) Load(_ context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - rec, ok := s.records[id] - if !ok { - return domain.CanonicalSessionLifecycle{}, false, nil - } - return rec.Lifecycle, true, nil -} - -func (s *fakeStore) List(_ context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { - s.mu.Lock() - defer s.mu.Unlock() - var out []domain.SessionRecord - for _, rec := range s.records { - if rec.ProjectID == project { - out = append(out, s.withMetadata(*rec)) - } - } - return out, nil -} - -func (s *fakeStore) GetMetadata(_ context.Context, id domain.SessionID) (domain.SessionMetadata, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.metadata[id], nil -} - -func (s *fakeStore) PatchMetadata(_ context.Context, id domain.SessionID, meta domain.SessionMetadata) error { - s.mu.Lock() - defer s.mu.Unlock() - s.metadata[id] = mergeSessionMetadata(s.metadata[id], meta) - return nil -} - -// mergeSessionMetadata applies meta onto dst with the store's "empty = leave -// unchanged" semantics, so partial patches do not clobber earlier values. -func mergeSessionMetadata(dst, meta domain.SessionMetadata) domain.SessionMetadata { - if meta.Branch != "" { - dst.Branch = meta.Branch - } - if meta.WorkspacePath != "" { - dst.WorkspacePath = meta.WorkspacePath - } - if meta.RuntimeHandleID != "" { - dst.RuntimeHandleID = meta.RuntimeHandleID - } - if meta.RuntimeName != "" { - dst.RuntimeName = meta.RuntimeName - } - if meta.AgentSessionID != "" { - dst.AgentSessionID = meta.AgentSessionID - } - if meta.Prompt != "" { - dst.Prompt = meta.Prompt - } - return dst -} - -// withMetadata attaches the separately-stored metadata to a record copy (a real -// store would return them together). Caller holds s.mu. -func (s *fakeStore) withMetadata(rec domain.SessionRecord) domain.SessionRecord { - rec.Metadata = s.metadata[rec.ID] - return rec -} - -// ---- fakeRuntime ---- - -type fakeRuntime struct { - log *callLog - createErr error - alive bool - - created []ports.RuntimeConfig - destroyed []ports.RuntimeHandle - sent []string -} - -var _ ports.Runtime = (*fakeRuntime)(nil) - -func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { - r.log.add("Runtime.Create") - if r.createErr != nil { - return ports.RuntimeHandle{}, r.createErr - } - r.created = append(r.created, cfg) - return ports.RuntimeHandle{ID: "rt-" + string(cfg.SessionID), RuntimeName: "tmux"}, nil -} - -func (r *fakeRuntime) Destroy(_ context.Context, h ports.RuntimeHandle) error { - r.log.add("Runtime.Destroy") - r.destroyed = append(r.destroyed, h) - return nil -} - -func (r *fakeRuntime) SendMessage(_ context.Context, _ ports.RuntimeHandle, message string) error { - r.sent = append(r.sent, message) - return nil -} - -func (r *fakeRuntime) GetOutput(_ context.Context, _ ports.RuntimeHandle, _ int) (string, error) { - return "", nil -} - -func (r *fakeRuntime) IsAlive(_ context.Context, _ ports.RuntimeHandle) (bool, error) { - return r.alive, nil -} - -// ---- fakeAgent ---- - -type fakeAgent struct { - env map[string]string -} - -var _ ports.Agent = (*fakeAgent)(nil) - -func (a *fakeAgent) GetLaunchCommand(_ ports.AgentConfig) string { return "claude" } - -func (a *fakeAgent) GetEnvironment(_ ports.AgentConfig) map[string]string { return cloneMap(a.env) } - -func (a *fakeAgent) ProbeProcess(_ context.Context, _ ports.RuntimeHandle) (ports.ProcessProbe, error) { - return ports.ProcessProbeAlive, nil -} - -func (a *fakeAgent) GetRestoreCommand(agentSessionID string) string { - return "claude --resume " + agentSessionID -} - -// ---- fakeWorkspace (with worktree-remove refusal mode) ---- - -type fakeWorkspace struct { - log *callLog - createErr error - refuse map[string]bool // path -> still registered after prune (uncommitted work) - created []ports.WorkspaceConfig - destroyed []ports.WorkspaceInfo - restoredID []domain.SessionID -} - -var _ ports.Workspace = (*fakeWorkspace)(nil) - -func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { - w.log.add("Workspace.Create") - if w.createErr != nil { - return ports.WorkspaceInfo{}, w.createErr - } - w.created = append(w.created, cfg) - return workspaceFor(cfg), nil -} - -func (w *fakeWorkspace) Destroy(_ context.Context, info ports.WorkspaceInfo) error { - w.log.add("Workspace.Destroy") - if w.refuse[info.Path] { - // Worktree-remove safety: after `git worktree prune` the path is still - // registered, so it may hold the agent's uncommitted work — refuse. - return fmt.Errorf("workspace: refusing to rm -rf %s: still registered after prune", info.Path) - } - w.destroyed = append(w.destroyed, info) - return nil -} - -func (w *fakeWorkspace) List(_ context.Context, _ domain.ProjectID) ([]ports.WorkspaceInfo, error) { - return nil, nil -} - -func (w *fakeWorkspace) Restore(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { - w.log.add("Workspace.Restore") - w.restoredID = append(w.restoredID, cfg.SessionID) - return workspaceFor(cfg), nil -} - -func workspaceFor(cfg ports.WorkspaceConfig) ports.WorkspaceInfo { - return ports.WorkspaceInfo{ - Path: "/tmp/ws/" + string(cfg.SessionID), - Branch: cfg.Branch, - SessionID: cfg.SessionID, - ProjectID: cfg.ProjectID, - } -} - -// ---- recordingMessenger ---- - -type recordingMessenger struct { - sent []struct { - ID domain.SessionID - Message string - } -} - -var _ ports.AgentMessenger = (*recordingMessenger)(nil) - -func (m *recordingMessenger) Send(_ context.Context, id domain.SessionID, message string) error { - m.sent = append(m.sent, struct { - ID domain.SessionID - Message string - }{id, message}) - return nil -} - -// ---- noopNotifier ---- - -type noopNotifier struct{} - -var _ ports.Notifier = (*noopNotifier)(nil) - -func (noopNotifier) Notify(_ context.Context, _ ports.OrchestratorEvent) error { return nil } - -// ---- recordingLCM: wraps the REAL lifecycle.Manager and logs SM-facing calls ---- - -type recordingLCM struct { - log *callLog - inner ports.LifecycleManager - - // onSpawnErr, when set, makes OnSpawnCompleted fail (without touching the - // inner manager) so tests can exercise the SM's post-spawn failure paths. - onSpawnErr error -} - -var _ ports.LifecycleManager = (*recordingLCM)(nil) - -func (l *recordingLCM) OnSpawnInitiated(ctx context.Context, rec domain.SessionRecord) error { - l.log.add("OnSpawnInitiated") - return l.inner.OnSpawnInitiated(ctx, rec) -} - -func (l *recordingLCM) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error { - l.log.add("OnSpawnCompleted") - if l.onSpawnErr != nil { - return l.onSpawnErr - } - return l.inner.OnSpawnCompleted(ctx, id, o) -} - -func (l *recordingLCM) OnKillRequested(ctx context.Context, id domain.SessionID, r ports.KillReason) error { - l.log.add("OnKillRequested") - return l.inner.OnKillRequested(ctx, id, r) -} - -func (l *recordingLCM) ApplySCMObservation(ctx context.Context, id domain.SessionID, f ports.SCMFacts) error { - return l.inner.ApplySCMObservation(ctx, id, f) -} - -func (l *recordingLCM) ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error { - return l.inner.ApplyRuntimeObservation(ctx, id, f) -} - -func (l *recordingLCM) ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error { - return l.inner.ApplyActivitySignal(ctx, id, s) -} - -func (l *recordingLCM) TickEscalations(ctx context.Context, now time.Time) error { - return l.inner.TickEscalations(ctx, now) -} - -func (l *recordingLCM) RunningSessions(ctx context.Context) ([]domain.SessionRecord, error) { - return l.inner.RunningSessions(ctx) -} - -// ---- harness: wires the SM against the fakes + the real LCM ---- - -type harness struct { - sm *Manager - store *fakeStore - runtime *fakeRuntime - agent *fakeAgent - workspace *fakeWorkspace - messenger *recordingMessenger - lcm *recordingLCM - log *callLog -} - -var fixedTime = time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) - -func newHarness(id domain.SessionID) *harness { - log := &callLog{} - store := newFakeStore() - rt := &fakeRuntime{log: log, alive: true} - ag := &fakeAgent{env: map[string]string{"BASE": "1"}} - ws := &fakeWorkspace{log: log, refuse: map[string]bool{}} - msg := &recordingMessenger{} - - lcm := &recordingLCM{log: log, inner: lifecycle.New(store, noopNotifier{}, msg)} - - sm := New(Deps{ - Runtime: rt, - Agent: ag, - Workspace: ws, - Store: store, - Messenger: msg, - Lifecycle: lcm, - Clock: func() time.Time { return fixedTime }, - NewID: func(ports.SpawnConfig) domain.SessionID { return id }, - }) - - return &harness{sm: sm, store: store, runtime: rt, agent: ag, workspace: ws, messenger: msg, lcm: lcm, log: log} -} - -func cloneMap(in map[string]string) map[string]string { - if in == nil { - return nil - } - out := make(map[string]string, len(in)) - for k, v := range in { - out[k] = v - } - return out -} diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index dce6330558..d7350f5f33 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -1,75 +1,53 @@ -// Package session implements ports.SessionManager: the explicit-mutation half -// of the lane. The SM is impure plumbing — it drives the Runtime/Agent/Workspace -// plugins to create and tear down sessions, and routes mutation commands and -// outcomes to the LCM (OnSpawnInitiated / OnSpawnCompleted / OnKillRequested). -// -// It NEVER writes sessions directly: observed transitions and explicit -// canonical mutations are the LCM's job under the Writer contract. The SM is the -// single producer of the derived display status, attached on read in List/Get -// and never persisted. +// Package session implements ports.SessionManager: the explicit-mutation half of +// the lane. It drives the runtime/agent/workspace plugins to create and tear +// down sessions, routes canonical writes to the LCM, and is the single producer +// of the derived display status (attached on read in List/Get). package session import ( "context" - "crypto/rand" - "encoding/hex" "errors" "fmt" - "strconv" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// ErrNotFound is returned by Get/Restore when no record exists for the id. -var ErrNotFound = errors.New("session: not found") - -// ErrNotRestorable is returned by Restore when the session is not torn down. -// Restoring a live session would spin up a second runtime/workspace for the same -// id, duplicating the agent and risking data loss. -var ErrNotRestorable = errors.New("session: not restorable (not terminal)") - -// ErrIncompleteTeardownMetadata is returned when a record's teardown handles are -// missing (empty workspace path or runtime handle), so calling a real adapter's -// Destroy could act on empty args — an unsafe delete. The teardown is skipped. -var ErrIncompleteTeardownMetadata = errors.New("session: incomplete teardown metadata") +var ( + ErrNotFound = errors.New("session: not found") + ErrNotRestorable = errors.New("session: not restorable (not terminal)") + ErrIncompleteHandle = errors.New("session: incomplete teardown handle") +) -// Env vars a spawned process reads to learn who it is (distillation §5.4). +// Env vars a spawned process reads to learn who it is. const ( EnvSessionID = "AO_SESSION_ID" EnvProjectID = "AO_PROJECT_ID" EnvIssueID = "AO_ISSUE_ID" ) -// Manager implements ports.SessionManager against the outbound ports. Every -// dependency is an interface so the SM runs entirely against fakes in tests. +// Manager implements ports.SessionManager over the outbound ports. type Manager struct { runtime ports.Runtime agent ports.Agent workspace ports.Workspace - store ports.LifecycleStore + store ports.SessionStore messenger ports.AgentMessenger lcm ports.LifecycleManager - - clock func() time.Time - newID func(ports.SpawnConfig) domain.SessionID + clock func() time.Time } var _ ports.SessionManager = (*Manager)(nil) -// Deps groups the SM's collaborators. Clock and NewID are optional (defaulted) -// so production wiring only supplies the ports. type Deps struct { Runtime ports.Runtime Agent ports.Agent Workspace ports.Workspace - Store ports.LifecycleStore + Store ports.SessionStore Messenger ports.AgentMessenger Lifecycle ports.LifecycleManager - - Clock func() time.Time - NewID func(ports.SpawnConfig) domain.SessionID + Clock func() time.Time } func New(d Deps) *Manager { @@ -81,38 +59,27 @@ func New(d Deps) *Manager { messenger: d.Messenger, lcm: d.Lifecycle, clock: d.Clock, - newID: d.NewID, } if m.clock == nil { m.clock = time.Now } - if m.newID == nil { - m.newID = defaultNewID - } return m } -// ---- Spawn ---- - -// Spawn runs the create pipeline in spec order: workspace -> runtime -> route -// seed command to the LCM -> report completion to the LCM. The record is seeded LATE (after the runtime is up), so a -// failure before the seed leaves no record for Cleanup to reclaim — hence each -// step eagerly rolls back the steps that already succeeded. +// Spawn creates the session row (which assigns the "{project}-{n}" id), then the +// workspace and runtime, then reports completion to the LCM. A failure after the +// row exists routes it to a terminal errored state and rolls back what was built. func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { - id := m.newID(cfg) - if _, ok, err := m.store.Get(ctx, id); err != nil { - return domain.Session{}, fmt.Errorf("spawn %s: check existing: %w", id, err) - } else if ok { - return domain.Session{}, fmt.Errorf("spawn %s: already exists", id) + rec, err := m.store.CreateSession(ctx, seedRecord(cfg, m.clock())) + if err != nil { + return domain.Session{}, fmt.Errorf("spawn: create: %w", err) } + id := rec.ID - ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ - ProjectID: cfg.ProjectID, - SessionID: id, - Branch: cfg.Branch, - }) + ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ProjectID: cfg.ProjectID, SessionID: id, Branch: cfg.Branch}) if err != nil { - return domain.Session{}, fmt.Errorf("spawn %s: workspace create: %w", id, err) + m.markErrored(ctx, id) + return domain.Session{}, fmt.Errorf("spawn %s: workspace: %w", id, err) } agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: buildPrompt(cfg)} @@ -123,121 +90,127 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, cfg.ProjectID, cfg.IssueID), }) if err != nil { - m.rollbackWorkspace(ctx, ws) // nothing seeded yet - return domain.Session{}, fmt.Errorf("spawn %s: runtime create: %w", id, err) - } - - if err := m.lcm.OnSpawnInitiated(ctx, seedRecord(id, cfg, m.clock())); err != nil { - m.rollbackRuntime(ctx, handle) - m.rollbackWorkspace(ctx, ws) - return domain.Session{}, fmt.Errorf("spawn %s: on spawn initiated: %w", id, err) + _ = m.workspace.Destroy(ctx, ws) + m.markErrored(ctx, id) + return domain.Session{}, fmt.Errorf("spawn %s: runtime: %w", id, err) } - // Prompt is persisted via OnSpawnCompleted -> spawnMetadata so a later Restore - // can fall back to a fresh launch if the agent's native session id was never - // captured (the capture path is a separate hook that may never have run). outcome := ports.SpawnOutcome{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle, Prompt: agentCfg.Prompt} if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { - // The record is seeded but the runtime/workspace are about to be torn - // down. The store has no delete, so route the orphan to a terminal - // errored state (best effort) rather than strand a phantom "spawning". - _ = m.lcm.OnKillRequested(ctx, id, ports.KillReason{Kind: ports.KillError, Detail: "spawn completion failed"}) - m.rollbackRuntime(ctx, handle) - m.rollbackWorkspace(ctx, ws) - return domain.Session{}, fmt.Errorf("spawn %s: on spawn completed: %w", id, err) + _ = m.runtime.Destroy(ctx, handle) + _ = m.workspace.Destroy(ctx, ws) + m.markErrored(ctx, id) + return domain.Session{}, fmt.Errorf("spawn %s: completed: %w", id, err) } - return m.Get(ctx, id) } -// rollback* are best-effort: the caller already has the originating failure, and -// there is no logger at this layer, so a secondary teardown error is dropped -// rather than masking the real cause. -func (m *Manager) rollbackWorkspace(ctx context.Context, ws ports.WorkspaceInfo) { - _ = m.workspace.Destroy(ctx, ws) -} - -func (m *Manager) rollbackRuntime(ctx context.Context, h ports.RuntimeHandle) { - _ = m.runtime.Destroy(ctx, h) +// markErrored best-effort parks an orphaned spawn in a terminal errored state +// (the store has no delete; a phantom "spawning" row is worse than a terminal one). +func (m *Manager) markErrored(ctx context.Context, id domain.SessionID) { + _ = m.lcm.OnKillRequested(ctx, id, domain.TermErrorInProcess) } -// ---- Kill ---- - -// Kill records terminal intent with the LCM FIRST, then tears down the runtime -// and workspace. There is no separate Agent stop: the agent runs inside the -// runtime, so Runtime.Destroy stops it. The workspace teardown honors the -// worktree-remove safety — a refusal (path still registered after prune, so it -// may hold uncommitted work) surfaces as an error with WorkspaceFreed=false and -// is never forced. -func (m *Manager) Kill(ctx context.Context, id domain.SessionID, opts ports.KillOptions) (ports.KillResult, error) { - rec, ok, err := m.store.Get(ctx, id) +// Kill records terminal intent with the LCM, then tears down the runtime and +// workspace. A workspace teardown refused by the worktree-remove safety +// (uncommitted work) surfaces as an error with freed=false and is never forced. +func (m *Manager) Kill(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) (bool, error) { + rec, ok, err := m.store.GetSession(ctx, id) if err != nil { - return ports.KillResult{ID: id}, fmt.Errorf("kill %s: %w", id, err) + return false, fmt.Errorf("kill %s: %w", id, err) } if !ok { - // Already gone: benign race, mirrors LCM.OnKillRequested's no-op. - return ports.KillResult{ID: id}, nil + return false, nil // already gone: benign race } - meta, err := m.store.GetMetadata(ctx, id) - if err != nil { - return ports.KillResult{ID: id}, fmt.Errorf("kill %s: metadata: %w", id, err) + handle := runtimeHandle(rec.Metadata) + ws := workspaceInfo(rec) + if handle.ID == "" || ws.Path == "" { + return false, fmt.Errorf("kill %s: %w", id, ErrIncompleteHandle) + } + if err := m.lcm.OnKillRequested(ctx, id, reason); err != nil { + return false, fmt.Errorf("kill %s: %w", id, err) + } + if err := m.runtime.Destroy(ctx, handle); err != nil { + return false, fmt.Errorf("kill %s: runtime: %w", id, err) + } + if err := m.workspace.Destroy(ctx, ws); err != nil { + return false, fmt.Errorf("kill %s: workspace: %w", id, err) } + return true, nil +} - // Validate the teardown handles BEFORE recording intent or touching an - // adapter: a corrupted/partially-seeded record with empty handles must never - // reach Destroy (empty path / handle could be an unsafe delete). - rtHandle := runtimeHandle(meta) - wsInfo := workspaceInfo(rec, meta) - if !validRuntimeHandle(rtHandle) { - return ports.KillResult{ID: id}, fmt.Errorf("kill %s: %w: runtime handle", id, ErrIncompleteTeardownMetadata) +// Restore relaunches a torn-down session in its workspace. The fallible I/O runs +// before any canonical write, so a failure never resurrects the row or destroys +// the worktree (it may hold the agent's prior work). +func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { + rec, ok, err := m.store.GetSession(ctx, id) + if err != nil { + return domain.Session{}, fmt.Errorf("restore %s: %w", id, err) } - if !validWorkspaceInfo(wsInfo) { - return ports.KillResult{ID: id}, fmt.Errorf("kill %s: %w: workspace path", id, ErrIncompleteTeardownMetadata) + if !ok { + return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotFound) + } + if !isTerminal(rec.Lifecycle.Session.State) { + return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotRestorable) + } + meta := rec.Metadata + if meta.AgentSessionID == "" && meta.Prompt == "" { + return domain.Session{}, fmt.Errorf("restore %s: nothing to resume from", id) } - if err := m.lcm.OnKillRequested(ctx, id, ports.KillReason{Kind: opts.Reason, Detail: opts.Detail}); err != nil { - return ports.KillResult{ID: id}, fmt.Errorf("kill %s: on kill requested: %w", id, err) + ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ProjectID: rec.ProjectID, SessionID: id, Branch: meta.Branch}) + if err != nil { + return domain.Session{}, fmt.Errorf("restore %s: workspace: %w", id, err) + } + agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: meta.Prompt} + launch := m.agent.GetRestoreCommand(meta.AgentSessionID) + if meta.AgentSessionID == "" { + launch = m.agent.GetLaunchCommand(agentCfg) } - if err := m.runtime.Destroy(ctx, rtHandle); err != nil { - return ports.KillResult{ID: id}, fmt.Errorf("kill %s: runtime destroy: %w", id, err) + handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ + SessionID: id, + WorkspacePath: ws.Path, + LaunchCommand: launch, + Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, rec.ProjectID, rec.IssueID), + }) + if err != nil { + return domain.Session{}, fmt.Errorf("restore %s: runtime: %w", id, err) } - if err := m.workspace.Destroy(ctx, wsInfo); err != nil { - return ports.KillResult{ID: id, WorkspaceFreed: false}, fmt.Errorf("kill %s: workspace destroy: %w", id, err) + outcome := ports.SpawnOutcome{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle, AgentSessionID: meta.AgentSessionID, Prompt: meta.Prompt} + if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { + _ = m.runtime.Destroy(ctx, handle) + return domain.Session{}, fmt.Errorf("restore %s: completed: %w", id, err) } - return ports.KillResult{ID: id, WorkspaceFreed: true}, nil + return m.Get(ctx, id) } -// ---- read-model ---- - -// List builds the read-model for a project: stored records with the display -// status derived on read. The SM is the single producer of that status. func (m *Manager) List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) { - recs, err := m.store.List(ctx, project) + recs, err := m.store.ListSessions(ctx, project) if err != nil { return nil, fmt.Errorf("list %s: %w", project, err) } out := make([]domain.Session, 0, len(recs)) for _, rec := range recs { - out = append(out, toSession(rec)) + s, err := m.toSession(ctx, rec) + if err != nil { + return nil, err + } + out = append(out, s) } return out, nil } func (m *Manager) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { - rec, ok, err := m.store.Get(ctx, id) + rec, ok, err := m.store.GetSession(ctx, id) if err != nil { return domain.Session{}, fmt.Errorf("get %s: %w", id, err) } if !ok { return domain.Session{}, fmt.Errorf("get %s: %w", id, ErrNotFound) } - return toSession(rec), nil + return m.toSession(ctx, rec) } -// ---- Send ---- - -// Send routes a message to the running agent through the AgentMessenger, which -// busy-detects and verifies delivery. func (m *Manager) Send(ctx context.Context, id domain.SessionID, message string) error { if err := m.messenger.Send(ctx, id, message); err != nil { return fmt.Errorf("send %s: %w", id, err) @@ -245,156 +218,64 @@ func (m *Manager) Send(ctx context.Context, id domain.SessionID, message string) return nil } -// ---- Restore ---- - -// Restore relaunches a previously torn-down session in its workspace. The -// fallible I/O (workspace restore + runtime create) runs first so a failure -// touches no canonical state and never destroys the worktree (it may hold the -// agent's prior work). Only once the runtime is up do we reopen the lifecycle: -// resetting a terminal session is an explicit mutation routed to the LCM (the -// LCM's observe path would never resurrect a terminal session), and the PR axis -// is cleared. OnSpawnCompleted then flips the runtime to alive. -func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { - rec, ok, err := m.store.Get(ctx, id) - if err != nil { - return domain.Session{}, fmt.Errorf("restore %s: %w", id, err) - } - if !ok { - return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotFound) - } - // Only a torn-down session may be restored. Reopening a live one would spawn a - // duplicate runtime/workspace for the same id and reset its lifecycle. - if !isTerminalSession(rec.Lifecycle.Session.State) { - return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotRestorable) - } - meta, err := m.store.GetMetadata(ctx, id) - if err != nil { - return domain.Session{}, fmt.Errorf("restore %s: metadata: %w", id, err) - } - - // Resume is only possible with the agent's captured session id; without it we - // fall back to a fresh launch using the seeded prompt persisted at spawn time - // (the agent's id-capture path is a separate hook that may never have run, so - // "no id" is the common case rather than an error). If neither is available - // there is nothing to relaunch from — fail early, before any I/O. - agentSessionID := meta.AgentSessionID - seededPrompt := meta.Prompt - if agentSessionID == "" && seededPrompt == "" { - return domain.Session{}, fmt.Errorf("restore %s: no agent session id or seeded prompt (cannot resume or relaunch)", id) - } - - ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ - ProjectID: rec.ProjectID, - SessionID: id, - Branch: meta.Branch, - }) - if err != nil { - return domain.Session{}, fmt.Errorf("restore %s: workspace restore: %w", id, err) - } - - agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: seededPrompt} - launchCommand := m.agent.GetRestoreCommand(agentSessionID) - if agentSessionID == "" { - launchCommand = m.agent.GetLaunchCommand(agentCfg) - } - handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ - SessionID: id, - WorkspacePath: ws.Path, - LaunchCommand: launchCommand, - Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, rec.ProjectID, rec.IssueID), - }) - if err != nil { - return domain.Session{}, fmt.Errorf("restore %s: runtime create: %w", id, err) - } - - // Past this point the runtime is live: a failure must tear it back down (but - // never the workspace, which holds the agent's prior work) so we don't strand - // a process while parking the session in a terminal lifecycle. - reopen := rec - reopen.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionNotStarted, Reason: domain.ReasonSpawnRequested} - reopen.Lifecycle.PR = domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonClearedOnRestore} - reopen.Lifecycle.Runtime = domain.RuntimeSubstate{State: domain.RuntimeUnknown, Reason: domain.RuntimeReasonSpawnIncomplete} - reopen.Lifecycle.Detecting = nil - if err := m.lcm.OnSpawnInitiated(ctx, reopen); err != nil { - m.rollbackRuntime(ctx, handle) - return domain.Session{}, fmt.Errorf("restore %s: on spawn initiated: %w", id, err) - } - - outcome := ports.SpawnOutcome{ - Branch: ws.Branch, - WorkspacePath: ws.Path, - RuntimeHandle: handle, - AgentSessionID: agentSessionID, - Prompt: seededPrompt, - } - if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { - m.rollbackRuntime(ctx, handle) - // Re-upsert the original record to undo the reopen; the store will - // assign the next revision. - if revertErr := m.lcm.OnSpawnInitiated(ctx, rec); revertErr != nil { - return domain.Session{}, fmt.Errorf("restore %s: revert after spawn completed failure: %w (original error: %v)", id, revertErr, err) - } - if !rec.Metadata.IsZero() { - if revertErr := m.store.PatchMetadata(ctx, id, rec.Metadata); revertErr != nil { - return domain.Session{}, fmt.Errorf("restore %s: revert metadata after spawn completed failure: %w (original error: %v)", id, revertErr, err) - } - } - return domain.Session{}, fmt.Errorf("restore %s: on spawn completed: %w", id, err) - } - return m.Get(ctx, id) -} - -// ---- Cleanup ---- - // Cleanup reclaims the workspaces of terminal sessions in a project. A workspace -// whose teardown is refused by the worktree-remove safety (uncommitted work) is -// skipped, never forced. Runtime teardown is best-effort (a terminal session's -// runtime is usually already gone); the workspace result decides cleaned/skipped. -func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) (ports.CleanupResult, error) { - recs, err := m.store.List(ctx, project) +// whose teardown is refused (uncommitted work) is skipped, never forced. +func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) { + recs, err := m.store.ListSessions(ctx, project) if err != nil { - return ports.CleanupResult{}, fmt.Errorf("cleanup %s: %w", project, err) + return nil, fmt.Errorf("cleanup %s: %w", project, err) } - var res ports.CleanupResult + var cleaned []domain.SessionID for _, rec := range recs { - if !isTerminalSession(rec.Lifecycle.Session.State) { + if !isTerminal(rec.Lifecycle.Session.State) { continue } - meta, err := m.store.GetMetadata(ctx, rec.ID) - if err != nil { - return res, fmt.Errorf("cleanup %s: metadata %s: %w", project, rec.ID, err) - } - wsInfo := workspaceInfo(rec, meta) - if !validWorkspaceInfo(wsInfo) { - // No workspace path to reclaim — skip rather than hand empty args to a - // real adapter's Destroy (an unsafe delete). - res.Skipped = append(res.Skipped, rec.ID) + ws := workspaceInfo(rec) + if ws.Path == "" { continue } - if rtHandle := runtimeHandle(meta); validRuntimeHandle(rtHandle) { - _ = m.runtime.Destroy(ctx, rtHandle) // best effort; usually already gone + if h := runtimeHandle(rec.Metadata); h.ID != "" { + _ = m.runtime.Destroy(ctx, h) // best effort; usually already gone } - if err := m.workspace.Destroy(ctx, wsInfo); err != nil { - res.Skipped = append(res.Skipped, rec.ID) - continue + if err := m.workspace.Destroy(ctx, ws); err != nil { + continue // skipped: uncommitted work } - res.Cleaned = append(res.Cleaned, rec.ID) + cleaned = append(cleaned, rec.ID) } - return res, nil + return cleaned, nil } // ---- helpers ---- -func toSession(rec domain.SessionRecord) domain.Session { - return domain.Session{SessionRecord: rec, Status: domain.DeriveLegacyStatus(rec.Lifecycle)} +func (m *Manager) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) { + pr, err := m.store.PRFactsForSession(ctx, rec.ID) + if err != nil { + return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err) + } + return domain.Session{SessionRecord: rec, Status: domain.DeriveStatus(rec.Lifecycle, pr)}, nil } -func isTerminalSession(s domain.SessionState) bool { +func isTerminal(s domain.SessionState) bool { return s == domain.SessionDone || s == domain.SessionTerminated } -// buildPrompt assembles the spawn prompt from the explicit config only; the full -// 3-layer assembly (base protocol + config-derived + user rules) lands later. +func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { + return domain.SessionRecord{ + ProjectID: cfg.ProjectID, + IssueID: cfg.IssueID, + Kind: cfg.Kind, + CreatedAt: now, + UpdatedAt: now, + Lifecycle: domain.CanonicalSessionLifecycle{ + Version: domain.LifecycleVersion, + Session: domain.SessionSubstate{State: domain.SessionNotStarted}, + Harness: cfg.Harness, + }, + } +} + +// buildPrompt assembles the spawn prompt from the explicit config (the full +// 3-layer assembly lands later). func buildPrompt(cfg ports.SpawnConfig) string { switch { case cfg.AgentRules == "": @@ -406,8 +287,6 @@ func buildPrompt(cfg ports.SpawnConfig) string { } } -// spawnEnv overlays the AO_* identity vars onto the agent's environment without -// mutating the map the agent returned. func spawnEnv(base map[string]string, id domain.SessionID, project domain.ProjectID, issue domain.IssueID) map[string]string { env := make(map[string]string, len(base)+3) for k, v := range base { @@ -419,70 +298,15 @@ func spawnEnv(base map[string]string, id domain.SessionID, project domain.Projec return env } -func seedRecord(id domain.SessionID, cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { - return domain.SessionRecord{ - ID: id, - ProjectID: cfg.ProjectID, - IssueID: cfg.IssueID, - Kind: cfg.Kind, - CreatedAt: now, - UpdatedAt: now, - Lifecycle: domain.CanonicalSessionLifecycle{ - Version: domain.LifecycleVersion, - Session: domain.SessionSubstate{State: domain.SessionNotStarted, Reason: domain.ReasonSpawnRequested}, - Runtime: domain.RuntimeSubstate{State: domain.RuntimeUnknown, Reason: domain.RuntimeReasonSpawnIncomplete}, - PR: domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonNotCreated}, - }, - } -} - -// runtimeHandle / workspaceInfo reconstruct teardown handles from the metadata -// the LCM persisted in OnSpawnCompleted (the metadata-key contract is shared -// with the lifecycle package). func runtimeHandle(meta domain.SessionMetadata) ports.RuntimeHandle { - return ports.RuntimeHandle{ - ID: meta.RuntimeHandleID, - RuntimeName: meta.RuntimeName, - } + return ports.RuntimeHandle{ID: meta.RuntimeHandleID, RuntimeName: meta.RuntimeName} } -func workspaceInfo(rec domain.SessionRecord, meta domain.SessionMetadata) ports.WorkspaceInfo { +func workspaceInfo(rec domain.SessionRecord) ports.WorkspaceInfo { return ports.WorkspaceInfo{ - Path: meta.WorkspacePath, - Branch: meta.Branch, + Path: rec.Metadata.WorkspacePath, + Branch: rec.Metadata.Branch, SessionID: rec.ID, ProjectID: rec.ProjectID, } } - -// validRuntimeHandle reports whether the handle identifies a runtime to destroy. -// An adapter needs the handle id to target the right process; an empty handle -// would be ambiguous, so we refuse to call Destroy with one. -func validRuntimeHandle(h ports.RuntimeHandle) bool { - return h.ID != "" -} - -// validWorkspaceInfo reports whether there is a concrete path to reclaim. An -// empty path handed to a worktree-remove could resolve to an unsafe target. -func validWorkspaceInfo(w ports.WorkspaceInfo) bool { - return w.Path != "" -} - -func defaultNewID(cfg ports.SpawnConfig) domain.SessionID { - base := string(cfg.IssueID) - if base == "" { - base = string(cfg.Kind) - } - if base == "" { - base = "session" - } - return domain.SessionID(base + "-" + randHex(4)) -} - -func randHex(n int) string { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return strconv.FormatInt(time.Now().UnixNano(), 16) - } - return hex.EncodeToString(b) -} diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index c0c98cf7c7..669e0c2544 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -3,630 +3,294 @@ package session import ( "context" "errors" + "fmt" "testing" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - testProject = domain.ProjectID("proj") - testIssue = domain.IssueID("42") -) - -func spawnCfg() ports.SpawnConfig { - return ports.SpawnConfig{ - ProjectID: testProject, - IssueID: testIssue, - Kind: domain.KindWorker, - Branch: "feat/42", - Prompt: "do the thing", - AgentRules: "be careful", - } -} - -func TestSpawn_HappyPath(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() +var ctx = context.Background() - sess, err := h.sm.Spawn(ctx, spawnCfg()) - if err != nil { - t.Fatalf("spawn: %v", err) - } - - // Display status is derived (single producer) — a freshly spawned, not_started - // session shows as spawning. - if sess.Status != domain.StatusSpawning { - t.Errorf("status = %q, want %q", sess.Status, domain.StatusSpawning) - } +// ---- fakes ---- - // Record seeded by the LCM with identity + initial lifecycle, then OnSpawnCompleted flipped - // the runtime axis to alive. - rec, ok, err := h.store.Get(ctx, "sess-1") - if err != nil || !ok { - t.Fatalf("get seeded record: ok=%v err=%v", ok, err) - } - if rec.ProjectID != testProject || rec.IssueID != testIssue || rec.Kind != domain.KindWorker { - t.Errorf("identity = %+v, want proj/42/worker", rec) - } - if !rec.CreatedAt.Equal(fixedTime) { - t.Errorf("createdAt = %v, want %v", rec.CreatedAt, fixedTime) - } - if got := rec.Lifecycle.Session; got.State != domain.SessionNotStarted || got.Reason != domain.ReasonSpawnRequested { - t.Errorf("session substate = %+v, want not_started/spawn_requested", got) - } - if got := rec.Lifecycle.Runtime; got.State != domain.RuntimeAlive || got.Reason != domain.RuntimeReasonProcessRunning { - t.Errorf("runtime substate = %+v, want alive/process_running", got) - } +type fakeStore struct { + sessions map[domain.SessionID]domain.SessionRecord + pr map[domain.SessionID]domain.PRFacts + num int +} - // Pipeline order: workspace -> runtime -> LCM seed command -> LCM completion. - wantOrder := []string{"Workspace.Create", "Runtime.Create", "OnSpawnInitiated", "OnSpawnCompleted"} - if got := h.log.snapshot(); !equalStrings(got, wantOrder) { - t.Errorf("call order = %v, want %v", got, wantOrder) - } +func newFakeStore() *fakeStore { + return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}} +} - // Identity env wired onto the runtime config, layered over the agent's env. - if len(h.runtime.created) != 1 { - t.Fatalf("runtime.created = %d, want 1", len(h.runtime.created)) - } - env := h.runtime.created[0].Env - for k, want := range map[string]string{ - EnvSessionID: "sess-1", - EnvProjectID: "proj", - EnvIssueID: "42", - "BASE": "1", - } { - if env[k] != want { - t.Errorf("env[%q] = %q, want %q", k, env[k], want) +func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { + f.num++ + rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num)) + f.sessions[rec.ID] = rec + return rec, nil +} +func (f *fakeStore) UpdateSession(_ context.Context, rec domain.SessionRecord) error { + f.sessions[rec.ID] = rec + return nil +} +func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { + r, ok := f.sessions[id] + return r, ok, nil +} +func (f *fakeStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) { + var out []domain.SessionRecord + for _, r := range f.sessions { + if r.ProjectID == p { + out = append(out, r) } } - - // Handles persisted to metadata for later teardown/restore. The prompt is - // persisted too so a later Restore that finds no captured agent session id - // can still fall back to a fresh launch using the same prompt. - meta, _ := h.store.GetMetadata(ctx, "sess-1") - want := domain.SessionMetadata{ - Branch: "feat/42", - WorkspacePath: "/tmp/ws/sess-1", - RuntimeHandleID: "rt-sess-1", - RuntimeName: "tmux", - Prompt: "do the thing\n\nbe careful", - } - if meta != want { - t.Errorf("metadata = %+v, want %+v", meta, want) - } + return out, nil } - -func TestSpawn_RuntimeCreateFailure_RollsBack(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() - h.runtime.createErr = errors.New("boom") - - _, err := h.sm.Spawn(ctx, spawnCfg()) - if err == nil { - t.Fatal("spawn: want error, got nil") - } - - // No record seeded for a spawn that never completed. - if _, ok, _ := h.store.Get(ctx, "sess-1"); ok { - t.Error("record was seeded despite runtime-create failure") - } - // The already-created workspace was rolled back (eager rollback), since a - // late-seeded record means Cleanup could never find this orphan. - if len(h.workspace.destroyed) != 1 || h.workspace.destroyed[0].Path != "/tmp/ws/sess-1" { - t.Errorf("workspace.destroyed = %+v, want the created worktree", h.workspace.destroyed) - } - // LCM never told a spawn completed. - if h.log.indexOf("OnSpawnCompleted") != -1 { - t.Error("OnSpawnCompleted should not fire on a failed spawn") +func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) { + out := make([]domain.SessionRecord, 0, len(f.sessions)) + for _, r := range f.sessions { + out = append(out, r) } + return out, nil } - -func TestSpawn_ExistingSessionIDRejectedBeforeWork(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() - if err := h.store.Upsert(ctx, domain.SessionRecord{ - ID: "sess-1", - ProjectID: testProject, - Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), - }, ports.EventSessionCreated); err != nil { - t.Fatalf("seed existing row: %v", err) - } - - _, err := h.sm.Spawn(ctx, spawnCfg()) - if err == nil { - t.Fatal("spawn: want error for existing session id, got nil") - } - if len(h.workspace.created) != 0 { - t.Error("workspace should not be created when session id already exists") - } - if len(h.runtime.created) != 0 { - t.Error("runtime should not be created when session id already exists") - } - if h.log.indexOf("OnSpawnInitiated") != -1 || h.log.indexOf("OnSpawnCompleted") != -1 { - t.Error("LCM should not be called when session id already exists") - } +func (f *fakeStore) PRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, error) { + return f.pr[id], nil } -func TestSpawn_OnSpawnCompletedFailure_RoutesOrphanToErrored(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() - h.lcm.onSpawnErr = errors.New("lcm boom") - - _, err := h.sm.Spawn(ctx, spawnCfg()) - if err == nil { - t.Fatal("spawn: want error, got nil") - } - - // Runtime + workspace are torn down on the failure path. - if len(h.runtime.destroyed) != 1 { - t.Errorf("runtime.destroyed = %d, want 1", len(h.runtime.destroyed)) - } - if len(h.workspace.destroyed) != 1 { - t.Errorf("workspace.destroyed = %d, want 1", len(h.workspace.destroyed)) - } - // The record was already seeded and the store has no delete, so the orphan is - // routed to a terminal errored state (via OnKillRequested(KillError)) rather - // than stranded forever as "spawning". - rec, ok, _ := h.store.Get(ctx, "sess-1") - if !ok { - t.Fatal("seeded record vanished; expected it parked as errored") - } - if got := rec.Lifecycle.Session; got.State != domain.SessionTerminated || got.Reason != domain.ReasonErrorInProcess { - t.Errorf("session substate = %+v, want terminated/error_in_process", got) - } - if status := domain.DeriveLegacyStatus(rec.Lifecycle); status != domain.StatusErrored { - t.Errorf("status = %q, want errored", status) - } +// fakeLCM is the minimal lifecycle the Session Manager drives: it persists the +// spawn/kill canonical writes into the store so Get reflects them. +type fakeLCM struct { + store *fakeStore + completed int } -func TestKill_OrderingAndTerminalState(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() - if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { - t.Fatalf("spawn: %v", err) - } - - res, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}) - if err != nil { - t.Fatalf("kill: %v", err) - } - if !res.WorkspaceFreed { - t.Error("WorkspaceFreed = false, want true") - } - - // Intent recorded with the LCM BEFORE any teardown, runtime before workspace. - iKill := h.log.indexOf("OnKillRequested") - iRT := h.log.indexOf("Runtime.Destroy") - iWS := h.log.indexOf("Workspace.Destroy") - if !(iKill >= 0 && iKill < iRT && iRT < iWS) { - t.Errorf("kill order indices: OnKillRequested=%d Runtime.Destroy=%d Workspace.Destroy=%d (want ascending)", iKill, iRT, iWS) - } - - // Terminal canonical written by the LCM; display derives to killed. - rec, _, _ := h.store.Get(ctx, "sess-1") - if got := rec.Lifecycle.Session; got.State != domain.SessionTerminated || got.Reason != domain.ReasonManuallyKilled { - t.Errorf("session substate = %+v, want terminated/manually_killed", got) - } - if status := domain.DeriveLegacyStatus(rec.Lifecycle); status != domain.StatusKilled { - t.Errorf("status = %q, want killed", status) - } +func (l *fakeLCM) OnSpawnCompleted(_ context.Context, id domain.SessionID, o ports.SpawnOutcome) error { + l.completed++ + rec := l.store.sessions[id] + rec.Lifecycle.Session.State = domain.SessionNotStarted + rec.Lifecycle.IsAlive = true + rec.Lifecycle.TerminationReason = domain.TermNone + rec.Metadata = domain.SessionMetadata{ + Branch: o.Branch, WorkspacePath: o.WorkspacePath, + RuntimeHandleID: o.RuntimeHandle.ID, RuntimeName: o.RuntimeHandle.RuntimeName, + AgentSessionID: o.AgentSessionID, Prompt: o.Prompt, + } + l.store.sessions[id] = rec + return nil } - -func TestKill_WorktreeRemoveRefusalSurfaced(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() - if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { - t.Fatalf("spawn: %v", err) - } - // The worktree path is still registered after prune (uncommitted work). - h.workspace.refuse["/tmp/ws/sess-1"] = true - - res, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}) - if err == nil { - t.Fatal("kill: want refusal error, got nil") - } - if res.WorkspaceFreed { - t.Error("WorkspaceFreed = true, want false on refusal") - } - // The refusal must be honored — the path is never force-deleted. - if len(h.workspace.destroyed) != 0 { - t.Errorf("workspace.destroyed = %+v, want none (refused)", h.workspace.destroyed) - } - // Runtime still torn down and intent still recorded — only the worktree is spared. - if h.log.indexOf("Runtime.Destroy") == -1 || h.log.indexOf("OnKillRequested") == -1 { - t.Error("runtime teardown / kill intent should still happen on a workspace refusal") - } +func (l *fakeLCM) OnKillRequested(_ context.Context, id domain.SessionID, reason domain.TerminationReason) error { + rec := l.store.sessions[id] + rec.Lifecycle.Session.State = domain.SessionTerminated + rec.Lifecycle.TerminationReason = reason + rec.Lifecycle.IsAlive = false + l.store.sessions[id] = rec + return nil } - -func TestKill_IncompleteMetadata_RefusesTeardown(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() - // A record with no teardown metadata (empty runtime handle + workspace path), - // e.g. a partially-seeded or corrupted record. - if err := h.store.Upsert(ctx, domain.SessionRecord{ - ID: "sess-1", ProjectID: testProject, - Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), - }, ports.EventSessionCreated); err != nil { - t.Fatalf("upsert: %v", err) - } - - if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); !errors.Is(err, ErrIncompleteTeardownMetadata) { - t.Fatalf("kill: err = %v, want ErrIncompleteTeardownMetadata", err) - } - // Nothing destroyed with empty args, and no intent recorded. - if len(h.runtime.destroyed) != 0 || len(h.workspace.destroyed) != 0 { - t.Errorf("teardown ran despite incomplete metadata: rt=%v ws=%v", h.runtime.destroyed, h.workspace.destroyed) - } - if h.log.indexOf("OnKillRequested") != -1 { - t.Error("kill intent recorded despite incomplete metadata") - } +func (l *fakeLCM) ApplyRuntimeObservation(context.Context, domain.SessionID, ports.RuntimeFacts) error { + return nil +} +func (l *fakeLCM) ApplyActivitySignal(context.Context, domain.SessionID, ports.ActivitySignal) error { + return nil +} +func (l *fakeLCM) ApplyPRObservation(context.Context, domain.SessionID, ports.PRObservation) error { + return nil +} +func (l *fakeLCM) TickEscalations(context.Context, time.Time) error { return nil } +func (l *fakeLCM) RunningSessions(context.Context) ([]domain.SessionRecord, error) { + return nil, nil } -func TestCleanup_IncompleteMetadata_Skipped(t *testing.T) { - h := newHarness("unused") - ctx := context.Background() - // Terminal session but no workspace path persisted — must be skipped, never - // handed to Destroy with an empty path. - if err := h.store.Upsert(ctx, domain.SessionRecord{ - ID: "orphan-1", ProjectID: testProject, - Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), - }, ports.EventSessionCreated); err != nil { - t.Fatalf("upsert: %v", err) - } +type fakeRuntime struct { + createErr error + created, destroyed int +} - res, err := h.sm.Cleanup(ctx, testProject) - if err != nil { - t.Fatalf("cleanup: %v", err) - } - if !equalIDSet(res.Skipped, []domain.SessionID{"orphan-1"}) { - t.Errorf("skipped = %v, want [orphan-1]", res.Skipped) - } - if len(res.Cleaned) != 0 { - t.Errorf("cleaned = %v, want none", res.Cleaned) - } - if len(h.workspace.destroyed) != 0 { - t.Errorf("workspace.destroyed = %v, want none (empty path must not reach Destroy)", h.workspace.destroyed) +func (r *fakeRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) { + if r.createErr != nil { + return ports.RuntimeHandle{}, r.createErr } + r.created++ + return ports.RuntimeHandle{ID: "h1", RuntimeName: "tmux"}, nil +} +func (r *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { r.destroyed++; return nil } +func (r *fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { + return true, nil } -func TestRestore_LiveSession_Rejected(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() - if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { - t.Fatalf("spawn: %v", err) - } - // The session is live (never torn down). Capture an agent id so the only thing - // blocking restore is the non-terminal lifecycle, not missing metadata. - if err := h.store.PatchMetadata(ctx, "sess-1", domain.SessionMetadata{AgentSessionID: "agent-xyz"}); err != nil { - t.Fatalf("patch metadata: %v", err) - } - createdBefore := len(h.runtime.created) - restoresBefore := len(h.workspace.restoredID) +type fakeAgent struct{} - if _, err := h.sm.Restore(ctx, "sess-1"); !errors.Is(err, ErrNotRestorable) { - t.Fatalf("restore: err = %v, want ErrNotRestorable", err) - } - // No second runtime/workspace spun up for the still-live session. - if len(h.runtime.created) != createdBefore { - t.Error("runtime created for a live-session restore") - } - if len(h.workspace.restoredID) != restoresBefore { - t.Error("workspace restored for a live-session restore") - } +func (fakeAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" } +func (fakeAgent) GetEnvironment(ports.AgentConfig) map[string]string { + return map[string]string{"X": "1"} } +func (fakeAgent) GetRestoreCommand(id string) string { return "resume " + id } -func TestListAndGet_DeriveStatus(t *testing.T) { - cases := []struct { - name string - lc domain.CanonicalSessionLifecycle - want domain.SessionStatus - }{ - {"not_started", lc(domain.SessionNotStarted, domain.ReasonSpawnRequested, domain.PRNone, ""), domain.StatusSpawning}, - {"working", lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), domain.StatusWorking}, - {"idle", lc(domain.SessionIdle, domain.ReasonResearchComplete, domain.PRNone, ""), domain.StatusIdle}, - {"needs_input", lc(domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, domain.PRNone, ""), domain.StatusNeedsInput}, - {"pr_ci_failed", lc(domain.SessionWorking, domain.ReasonFixingCI, domain.PROpen, domain.PRReasonCIFailing), domain.StatusCIFailed}, - {"pr_merged", lc(domain.SessionIdle, domain.ReasonMergedWaitingDecision, domain.PRMerged, domain.PRReasonMerged), domain.StatusMerged}, - {"killed", lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), domain.StatusKilled}, - } +type fakeWorkspace struct { + destroyErr error + destroyed int +} - h := newHarness("unused") - ctx := context.Background() - for _, c := range cases { - if err := h.store.Upsert(ctx, domain.SessionRecord{ID: domain.SessionID(c.name), ProjectID: testProject, Lifecycle: c.lc}, ports.EventSessionCreated); err != nil { - t.Fatalf("upsert %s: %v", c.name, err) - } - } +func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + return ports.WorkspaceInfo{Path: "/ws/" + string(cfg.SessionID), Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil +} +func (w *fakeWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { + w.destroyed++ + return w.destroyErr +} +func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + return w.Create(ctx, cfg) +} - // Get derives per-record. - for _, c := range cases { - got, err := h.sm.Get(ctx, domain.SessionID(c.name)) - if err != nil { - t.Fatalf("get %s: %v", c.name, err) - } - if got.Status != c.want { - t.Errorf("get %s: status = %q, want %q", c.name, got.Status, c.want) - } - } +type fakeMessenger struct{ msgs []string } - // List derives for every record in the project. - got, err := h.sm.List(ctx, testProject) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(got) != len(cases) { - t.Fatalf("list len = %d, want %d", len(got), len(cases)) - } - byID := map[domain.SessionID]domain.SessionStatus{} - for _, s := range got { - byID[s.ID] = s.Status - } - for _, c := range cases { - if byID[domain.SessionID(c.name)] != c.want { - t.Errorf("list %s: status = %q, want %q", c.name, byID[domain.SessionID(c.name)], c.want) - } - } +func (m *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error { + m.msgs = append(m.msgs, msg) + return nil } -func TestGet_NotFound(t *testing.T) { - h := newHarness("sess-1") - if _, err := h.sm.Get(context.Background(), "missing"); !errors.Is(err, ErrNotFound) { - t.Errorf("get missing: err = %v, want ErrNotFound", err) - } +func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) { + st := newFakeStore() + rt := &fakeRuntime{} + ws := &fakeWorkspace{} + m := New(Deps{ + Runtime: rt, Agent: fakeAgent{}, Workspace: ws, + Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, + }) + return m, st, rt, ws } -func TestSend_RoutesToMessenger(t *testing.T) { - h := newHarness("sess-1") - if err := h.sm.Send(context.Background(), "sess-1", "hello"); err != nil { - t.Fatalf("send: %v", err) - } - if len(h.messenger.sent) != 1 || h.messenger.sent[0].ID != "sess-1" || h.messenger.sent[0].Message != "hello" { - t.Errorf("messenger.sent = %+v, want one {sess-1, hello}", h.messenger.sent) +func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) { + st.sessions[id] = domain.SessionRecord{ + ID: id, ProjectID: "mer", Metadata: meta, + Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionTerminated}}, } } -func TestRestore_RelaunchesWithResumeCommand(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() - if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { - t.Fatalf("spawn: %v", err) - } - if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); err != nil { - t.Fatalf("kill: %v", err) - } - // The agent's resume id is captured in metadata (here set explicitly). - if err := h.store.PatchMetadata(ctx, "sess-1", domain.SessionMetadata{AgentSessionID: "agent-xyz"}); err != nil { - t.Fatalf("patch metadata: %v", err) - } +// ---- tests ---- - sess, err := h.sm.Restore(ctx, "sess-1") - if err != nil { - t.Fatalf("restore: %v", err) - } +func TestSpawn_AssignsIDAndGoesLive(t *testing.T) { + m, st, rt, _ := newManager() - // Reopened: terminal session reset to a fresh spawn, PR cleared, runtime alive. - if sess.Status != domain.StatusSpawning { - t.Errorf("status = %q, want spawning", sess.Status) - } - rec, _, _ := h.store.Get(ctx, "sess-1") - if got := rec.Lifecycle.Session; got.State != domain.SessionNotStarted || got.Reason != domain.ReasonSpawnRequested { - t.Errorf("session substate = %+v, want not_started/spawn_requested", got) + s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"}) + if err != nil { + t.Fatal(err) } - if got := rec.Lifecycle.PR; got.State != domain.PRNone || got.Reason != domain.PRReasonClearedOnRestore { - t.Errorf("pr substate = %+v, want none/cleared_on_restore", got) + if s.ID != "mer-1" { + t.Fatalf("store should assign mer-1, got %q", s.ID) } - if rec.Lifecycle.Runtime.State != domain.RuntimeAlive { - t.Errorf("runtime state = %q, want alive", rec.Lifecycle.Runtime.State) + if s.Status != domain.StatusSpawning { + t.Fatalf("fresh session displays spawning, got %q", s.Status) } - - // Relaunched via the agent's resume command (created[0] is the original spawn). - if len(h.runtime.created) != 2 { - t.Fatalf("runtime.created = %d, want 2 (spawn + restore)", len(h.runtime.created)) + if rt.created != 1 { + t.Fatalf("runtime not created") } - if got := h.runtime.created[1].LaunchCommand; got != "claude --resume agent-xyz" { - t.Errorf("restore launch command = %q, want resume", got) - } - if h.log.indexOf("Workspace.Restore") == -1 { - t.Error("Workspace.Restore was not called") + if st.sessions["mer-1"].Metadata.RuntimeHandleID != "h1" { + t.Fatal("spawn handle not folded into the row") } } -func TestRestore_NoAgentSessionID_FreshLaunchFallback(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() - if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { - t.Fatalf("spawn: %v", err) - } - if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); err != nil { - t.Fatalf("kill: %v", err) - } - // No agent session id was ever captured (the capture hook is a separate - // path that may never have run), but Spawn persisted the prompt, so Restore - // must fall back to a fresh launch instead of failing. - createdBefore := len(h.runtime.created) +func TestSpawn_RollsBackOnRuntimeFailure(t *testing.T) { + m, st, _, ws := newManager() + m.runtime = &fakeRuntime{createErr: errors.New("boom")} - sess, err := h.sm.Restore(ctx, "sess-1") - if err != nil { - t.Fatalf("restore: %v", err) + if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer"}); err == nil { + t.Fatal("expected spawn to fail") } - if sess.Status != domain.StatusSpawning { - t.Errorf("status = %q, want spawning", sess.Status) + if ws.destroyed != 1 { + t.Fatal("workspace should be rolled back") } - if len(h.runtime.created) != createdBefore+1 { - t.Fatalf("runtime.created grew by %d, want 1 (fresh-launch fallback)", len(h.runtime.created)-createdBefore) - } - // Fresh launch uses GetLaunchCommand (returns "claude" in the fake) — not - // the resume command, which would have read "claude --resume ". - if got := h.runtime.created[createdBefore].LaunchCommand; got != "claude" { - t.Errorf("restore launch command = %q, want fresh-launch %q", got, "claude") + if st.sessions["mer-1"].Lifecycle.Session.State != domain.SessionTerminated { + t.Fatal("orphaned spawn should be parked terminal") } } -func TestRestore_NoIDAndNoPrompt_Errors(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() - // Seed a terminal record directly without any metadata — no agent session id, - // no prompt. Restore has nothing to resume and nothing to relaunch from, so - // it must fail early without touching workspace/runtime. - if err := h.store.Upsert(ctx, domain.SessionRecord{ - ID: "sess-1", ProjectID: testProject, - Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), - }, ports.EventSessionCreated); err != nil { - t.Fatalf("upsert: %v", err) - } - beforeRestores := len(h.workspace.restoredID) - beforeCreated := len(h.runtime.created) +func TestKill_TearsDownRuntimeAndWorkspace(t *testing.T) { + m, st, rt, ws := newManager() + st.sessions["mer-1"] = mkLive("mer-1") - if _, err := h.sm.Restore(ctx, "sess-1"); err == nil { - t.Fatal("restore: want error for missing agent session id and prompt, got nil") + freed, err := m.Kill(ctx, "mer-1", domain.TermManuallyKilled) + if err != nil || !freed { + t.Fatalf("kill should free the workspace: freed=%v err=%v", freed, err) } - if len(h.workspace.restoredID) != beforeRestores { - t.Error("workspace was touched despite a doomed restore") - } - if len(h.runtime.created) != beforeCreated { - t.Error("runtime was created despite a doomed restore") - } - // The session stays terminal — a failed restore does not reopen it. - rec, _, _ := h.store.Get(ctx, "sess-1") - if rec.Lifecycle.Session.State != domain.SessionTerminated { - t.Errorf("session state = %q, want terminated (unchanged)", rec.Lifecycle.Session.State) + if rt.destroyed != 1 || ws.destroyed != 1 { + t.Fatal("kill should destroy runtime and workspace") } } -func TestRestore_OnSpawnCompletedFailure_RollsBackRuntime(t *testing.T) { - h := newHarness("sess-1") - ctx := context.Background() - if _, err := h.sm.Spawn(ctx, spawnCfg()); err != nil { - t.Fatalf("spawn: %v", err) - } - if _, err := h.sm.Kill(ctx, "sess-1", ports.KillOptions{Reason: ports.KillManual}); err != nil { - t.Fatalf("kill: %v", err) +func TestKill_RefusesIncompleteHandle(t *testing.T) { + m, st, _, _ := newManager() + st.sessions["mer-1"] = domain.SessionRecord{ // live, but no teardown handles + ID: "mer-1", ProjectID: "mer", + Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionWorking}, IsAlive: true}, } - if err := h.store.PatchMetadata(ctx, "sess-1", domain.SessionMetadata{AgentSessionID: "agent-xyz"}); err != nil { - t.Fatalf("patch metadata: %v", err) - } - beforeMeta, _ := h.store.GetMetadata(ctx, "sess-1") - - // Fail the post-create LCM call; capture teardown counts just before restore. - h.lcm.onSpawnErr = errors.New("lcm boom") - before, _, _ := h.store.Get(ctx, "sess-1") - destroyedBefore := len(h.runtime.destroyed) - wsDestroyedBefore := len(h.workspace.destroyed) - if _, err := h.sm.Restore(ctx, "sess-1"); err == nil { - t.Fatal("restore: want error, got nil") - } - - rec, _, _ := h.store.Get(ctx, "sess-1") - if got := rec.Lifecycle.Session; got.State != domain.SessionTerminated || got.Reason != domain.ReasonManuallyKilled { - t.Fatalf("restore failure should restore terminal lifecycle, got %+v", got) - } - if rec.Lifecycle.Revision != before.Lifecycle.Revision+2 { - t.Fatalf("restore failure should advance revision twice, got %d want %d", rec.Lifecycle.Revision, before.Lifecycle.Revision+2) - } - afterMeta, _ := h.store.GetMetadata(ctx, "sess-1") - if afterMeta != beforeMeta { - t.Fatalf("restore failure should restore metadata, got %+v want %+v", afterMeta, beforeMeta) - } - - // The runtime created during restore is torn back down so no process is - // stranded; the workspace is left intact (it holds the agent's prior work). - if len(h.runtime.destroyed) != destroyedBefore+1 { - t.Errorf("runtime.destroyed grew by %d, want 1 (restore rollback)", len(h.runtime.destroyed)-destroyedBefore) - } - if len(h.workspace.destroyed) != wsDestroyedBefore { - t.Errorf("workspace was destroyed on restore rollback; it must be preserved") + if _, err := m.Kill(ctx, "mer-1", domain.TermManuallyKilled); !errors.Is(err, ErrIncompleteHandle) { + t.Fatalf("want ErrIncompleteHandle, got %v", err) } } -func TestCleanup_SkipsUncommittedWork(t *testing.T) { - h := newHarness("unused") - ctx := context.Background() - - // Two terminal sessions (reclaimable) + one working session (must be ignored). - seedTerminal(t, h, "done-1", "/tmp/ws/done-1") - seedTerminal(t, h, "dirty-1", "/tmp/ws/dirty-1") - if err := h.store.Upsert(ctx, domain.SessionRecord{ - ID: "live-1", ProjectID: testProject, - Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), - }, ports.EventSessionCreated); err != nil { - t.Fatalf("upsert live: %v", err) - } - // dirty-1's worktree still holds uncommitted work — Destroy refuses it. - h.workspace.refuse["/tmp/ws/dirty-1"] = true +func TestRestore_ReopensTerminal(t *testing.T) { + m, st, rt, _ := newManager() + seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}) - res, err := h.sm.Cleanup(ctx, testProject) + s, err := m.Restore(ctx, "mer-1") if err != nil { - t.Fatalf("cleanup: %v", err) + t.Fatal(err) } - - if !equalIDSet(res.Cleaned, []domain.SessionID{"done-1"}) { - t.Errorf("cleaned = %v, want [done-1]", res.Cleaned) - } - if !equalIDSet(res.Skipped, []domain.SessionID{"dirty-1"}) { - t.Errorf("skipped = %v, want [dirty-1]", res.Skipped) + if s.Status != domain.StatusSpawning { + t.Fatalf("restored session displays spawning, got %q", s.Status) } - // The live session was never a candidate. - if contains(res.Cleaned, "live-1") || contains(res.Skipped, "live-1") { - t.Error("non-terminal session must not be cleaned or skipped") + if rt.created != 1 { + t.Fatal("restore should relaunch the runtime") } } -// ---- test helpers ---- +func TestRestore_RefusesLiveSession(t *testing.T) { + m, st, _, _ := newManager() + st.sessions["mer-1"] = mkLive("mer-1") -func lc(s domain.SessionState, r domain.SessionReason, prs domain.PRState, prr domain.PRReason) domain.CanonicalSessionLifecycle { - return domain.CanonicalSessionLifecycle{ - Version: domain.LifecycleVersion, - Session: domain.SessionSubstate{State: s, Reason: r}, - PR: domain.PRSubstate{State: prs, Reason: prr}, - Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, + if _, err := m.Restore(ctx, "mer-1"); !errors.Is(err, ErrNotRestorable) { + t.Fatalf("want ErrNotRestorable, got %v", err) } } -func seedTerminal(t *testing.T, h *harness, id domain.SessionID, wsPath string) { - t.Helper() - ctx := context.Background() - if err := h.store.Upsert(ctx, domain.SessionRecord{ - ID: id, ProjectID: testProject, - Lifecycle: lc(domain.SessionTerminated, domain.ReasonManuallyKilled, domain.PRNone, ""), - }, ports.EventSessionCreated); err != nil { - t.Fatalf("upsert %s: %v", id, err) +func TestList_DerivesStatusFromPRFacts(t *testing.T) { + m, st, _, _ := newManager() + st.sessions["mer-1"] = mkLive("mer-1") + st.pr["mer-1"] = domain.PRFacts{Exists: true, CI: domain.CIFailing} + + list, err := m.List(ctx, "mer") + if err != nil { + t.Fatal(err) } - if err := h.store.PatchMetadata(ctx, id, domain.SessionMetadata{WorkspacePath: wsPath}); err != nil { - t.Fatalf("patch metadata %s: %v", id, err) + if len(list) != 1 || list[0].Status != domain.StatusCIFailed { + t.Fatalf("status should reflect PR facts, got %+v", list) } } -func equalStrings(a, b []string) bool { - if len(a) != len(b) { - return false +func TestCleanup_ReclaimsTerminalWorkspaces(t *testing.T) { + m, st, _, ws := newManager() + seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1"}) + st.sessions["mer-2"] = mkLive("mer-2") // live: must be skipped + + cleaned, err := m.Cleanup(ctx, "mer") + if err != nil { + t.Fatal(err) } - for i := range a { - if a[i] != b[i] { - return false - } + if len(cleaned) != 1 || cleaned[0] != "mer-1" { + t.Fatalf("only the terminal session should be reclaimed, got %v", cleaned) } - return true -} - -func contains(ids []domain.SessionID, id domain.SessionID) bool { - for _, x := range ids { - if x == id { - return true - } + if ws.destroyed != 1 { + t.Fatal("the live session's workspace must not be destroyed") } - return false } -func equalIDSet(got, want []domain.SessionID) bool { - if len(got) != len(want) { - return false - } - for _, w := range want { - if !contains(got, w) { - return false - } +func mkLive(id domain.SessionID) domain.SessionRecord { + return domain.SessionRecord{ + ID: id, ProjectID: "mer", + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/" + string(id), RuntimeHandleID: "h1", RuntimeName: "tmux"}, + Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionWorking}, IsAlive: true}, } - return true } diff --git a/backend/lifecycle_wiring.go b/backend/lifecycle_wiring.go index 3836baf683..f69b1ce463 100644 --- a/backend/lifecycle_wiring.go +++ b/backend/lifecycle_wiring.go @@ -11,116 +11,132 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) -// lifecycleStack owns the running LCM + reaper. The LCM is the sole writer into -// the store (every Apply*/On* call ends in store.Upsert, which the CDC pipeline -// then drains); the reaper is the OBSERVE-layer timer that probes live runtimes -// and reports facts back through the LCM. Together with the CDC substrate this -// makes the write path live end-to-end: LCM -> store -> outbox -> JSONL -> -// broadcaster. +// lifecycleStack owns the running LCM + reaper. The LCM is the sole writer of +// canonical transitions; the reaper is the OBSERVE-layer timer that probes live +// runtimes and reports facts back through it. type lifecycleStack struct { LCM *lifecycle.Manager reaperDone <-chan struct{} } -// startLifecycle constructs the LCM over store, makes escalation budgets durable, -// teaches it to enumerate sessions for the reaper, and starts the reaper loop. +// startLifecycle constructs the LCM over the store adapter and starts the reaper. // The goroutine stops when ctx is cancelled; Stop waits for it to drain. // -// TEMPORARY STUBS (replace as the daemon lane lands the real collaborators): -// -// - noopNotifier — swap for the production notifier multiplexer once the -// notifier plugins (desktop/Slack/webhook) are ported. Wire it where -// noopNotifier{} is passed to lifecycle.New below. -// - noopMessenger — swap for the AgentMessenger backed by the runtime/agent -// plugins (it injects a prompt into the live agent pane). Wire it at the -// same lifecycle.New call site. -// - reaper.MapRegistry{} — empty runtime registry, so the reaper probes -// nothing. Register the real runtime adapters (tmux/process) keyed by -// runtime name once those plugins exist: reaper.MapRegistry{"tmux": rt}. +// TEMPORARY STUBS (replace as the daemon lane lands the collaborators): +// - noopNotifier — swap for the notifier multiplexer (desktop/Slack/webhook). +// - noopMessenger — swap for the runtime/agent-plugin-backed AgentMessenger. +// - reaper.MapRegistry{} — empty runtime registry, so the reaper ticks +// escalations but probes nothing until the runtime plugins exist. func startLifecycle(ctx context.Context, store *sqlite.Store, logger *slog.Logger) (*lifecycleStack, error) { - // TODO(daemon-lane): replace noopNotifier{}/noopMessenger{} with the real - // notifier multiplexer and the plugin-backed AgentMessenger. - lcm := lifecycle.New(store, noopNotifier{}, noopMessenger{}) - - // Durable escalation budgets (flaw #3 fix): hydrate from the store and turn - // on write-through so a restart does not re-fire an already-escalated page. - // Must run before the reaper starts dispatching TickEscalations. - if err := lcm.WithReactionStore(ctx, lifecycleReactionStore{store}); err != nil { - return nil, err - } - - // The reaper's RunningSessions snapshot needs to see every session; ListAll - // spans all projects (the per-project List would hide cross-project work). - lcm.WithSessionLister(store.ListAll) - - // TODO(daemon-lane): pass the real runtime registry so the reaper actually - // probes live panes. With an empty registry it ticks escalations but probes - // nothing, which is correct until runtimes exist. + a := storeAdapter{store} + lcm := lifecycle.New(a, a, noopNotifier{}, noopMessenger{}) rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Logger: logger}) - return &lifecycleStack{LCM: lcm, reaperDone: rp.Start(ctx)}, nil } // Stop waits for the reaper goroutine to exit (the caller must have cancelled the // ctx passed to startLifecycle). -func (l *lifecycleStack) Stop() { - <-l.reaperDone -} - -// noopNotifier satisfies ports.Notifier by dropping every event. TEMPORARY: the -// daemon lane replaces this with the notifier multiplexer over the real notifier -// plugins. Until then human-facing notifications are silently discarded — the -// write path and CDC still work, only the human push is absent. -type noopNotifier struct{} +func (l *lifecycleStack) Stop() { <-l.reaperDone } -func (noopNotifier) Notify(context.Context, ports.OrchestratorEvent) error { return nil } +// storeAdapter bridges *sqlite.Store to the engine's ports. It embeds the store +// (so CreateSession/UpdateSession/GetSession/ListSessions/ListAllSessions and +// RecentCheckStatuses promote directly) and adds the PR conversions + the +// PRFacts read-model the display status needs. +type storeAdapter struct{ *sqlite.Store } -// noopMessenger satisfies ports.AgentMessenger by dropping every send. TEMPORARY: -// replace with the runtime/agent-plugin-backed messenger that injects prompts -// into the live agent pane. Until then auto-nudge reactions are no-ops. -type noopMessenger struct{} - -func (noopMessenger) Send(context.Context, domain.SessionID, string) error { return nil } - -// lifecycleReactionStore bridges the concrete *sqlite.Store to the lifecycle -// package's ReactionStore interface (string/row types <-> domain types). It is -// the production twin of the reactionStoreAdapter used in the lifecycle tests. -type lifecycleReactionStore struct{ store *sqlite.Store } +var ( + _ ports.SessionStore = storeAdapter{} + _ ports.PRWriter = storeAdapter{} +) -func (a lifecycleReactionStore) LoadReactionTrackers(ctx context.Context) ([]lifecycle.PersistedTracker, error) { - rows, err := a.store.ListReactionTrackers(ctx) +// PRFactsForSession picks the PR that drives display status — the most-recently +// updated non-closed PR, else the most recent — and folds in whether it has +// unresolved review comments. +func (a storeAdapter) PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) { + rows, err := a.Store.ListPRsBySession(ctx, string(id)) // newest first + if err != nil { + return domain.PRFacts{}, err + } + if len(rows) == 0 { + return domain.PRFacts{}, nil + } + pick := rows[0] + for _, r := range rows { + if r.State == "draft" || r.State == "open" { + pick = r + break + } + } + facts := domain.PRFacts{ + URL: pick.URL, Number: int(pick.Number), Exists: true, + Draft: pick.State == "draft", Merged: pick.State == "merged", Closed: pick.State == "closed", + CI: domain.CIState(pick.CIState), + Review: domain.ReviewDecision(pick.ReviewDecision), + Mergeability: domain.Mergeability(pick.Mergeability), + } + comments, err := a.Store.ListPRComments(ctx, pick.URL) if err != nil { - return nil, err + return domain.PRFacts{}, err } - out := make([]lifecycle.PersistedTracker, len(rows)) - for i, r := range rows { - out[i] = lifecycle.PersistedTracker{ - SessionID: domain.SessionID(r.SessionID), - Key: r.ReactionKey, - Attempts: r.Attempts, - Escalated: r.Escalated, - FirstAttemptAt: r.FirstAttemptAt, - ProjectID: domain.ProjectID(r.ProjectID), + for _, c := range comments { + if !c.Resolved { + facts.ReviewComments = true + break } } - return out, nil + return facts, nil +} + +func (a storeAdapter) UpsertPR(ctx context.Context, r ports.PRRow) error { + return a.Store.UpsertPR(ctx, sqlite.PRRow{ + URL: r.URL, SessionID: r.SessionID, Number: int64(r.Number), + State: prState(r), + ReviewDecision: string(r.Review), + CIState: string(r.CI), + Mergeability: string(r.Mergeability), + UpdatedAt: r.UpdatedAt, + }) } -func (a lifecycleReactionStore) SaveReactionTracker(ctx context.Context, t lifecycle.PersistedTracker) error { - return a.store.SaveReactionTracker(ctx, sqlite.ReactionTrackerRow{ - SessionID: string(t.SessionID), - ReactionKey: t.Key, - Attempts: t.Attempts, - Escalated: t.Escalated, - FirstAttemptAt: t.FirstAttemptAt, - ProjectID: string(t.ProjectID), +func (a storeAdapter) RecordCheck(ctx context.Context, r ports.PRCheckRow) error { + return a.Store.RecordCheck(ctx, sqlite.PRCheckRow{ + PRURL: r.PRURL, Name: r.Name, CommitHash: r.CommitHash, + Status: r.Status, URL: r.URL, LogTail: r.LogTail, CreatedAt: r.CreatedAt, }) } -func (a lifecycleReactionStore) DeleteReactionTracker(ctx context.Context, id domain.SessionID, key string) error { - return a.store.DeleteReactionTracker(ctx, string(id), key) +func (a storeAdapter) ReplacePRComments(ctx context.Context, prURL string, comments []ports.PRComment) error { + rows := make([]sqlite.PRCommentRow, len(comments)) + for i, c := range comments { + rows[i] = sqlite.PRCommentRow{ + PRURL: prURL, CommentID: c.ID, Author: c.Author, File: c.File, + Line: int64(c.Line), Body: c.Body, Resolved: c.Resolved, CreatedAt: c.CreatedAt, + } + } + return a.Store.ReplacePRComments(ctx, prURL, rows) } -func (a lifecycleReactionStore) DeleteSessionReactionTrackers(ctx context.Context, id domain.SessionID) error { - return a.store.DeleteSessionReactionTrackers(ctx, string(id)) +// prState collapses the PR's bools into the single pr.state column value. +func prState(r ports.PRRow) string { + switch { + case r.Merged: + return "merged" + case r.Closed: + return "closed" + case r.Draft: + return "draft" + default: + return "open" + } } + +// noopNotifier / noopMessenger are TEMPORARY stubs (see startLifecycle): the +// write path and CDC work without them; only the human push / agent nudge are +// absent until the real plugins are wired. +type noopNotifier struct{} + +func (noopNotifier) Notify(context.Context, ports.Event) error { return nil } + +type noopMessenger struct{} + +func (noopMessenger) Send(context.Context, domain.SessionID, string) error { return nil } diff --git a/backend/main.go b/backend/main.go index 8db058ea58..c4d4da52f0 100644 --- a/backend/main.go +++ b/backend/main.go @@ -53,19 +53,18 @@ func run() error { // lane and are wired there once their collaborators (Notifier, AgentMessenger, // and the runtime/agent/workspace plugins) have production implementations; // here we stand up the persistence + change-delivery foundation they build on. - db, err := sqlite.Open(cfg.DataDir) + store, err := sqlite.Open(cfg.DataDir) if err != nil { return fmt.Errorf("open store: %w", err) } - defer db.Close() - store := sqlite.NewStore(db) + defer store.Close() // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the // graceful shutdown inside Server.Run. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - cdcPipe, err := startCDC(ctx, store, cfg.DataDir, log) + cdcPipe, err := startCDC(ctx, store, log) if err != nil { return err } diff --git a/backend/main_test.go b/backend/main_test.go deleted file mode 100644 index c8f3254189..0000000000 --- a/backend/main_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" -) - -// These tests cover the composition-root adapters in cdc_wiring.go directly -// (package main otherwise has no test coverage): the outboxAdapter mapping the -// store's OutboxEvent to cdc.PendingEvent, and the snapshotSource rebuilding -// full-state events from the sessions table. - -func newWiringStore(t *testing.T) *sqlite.Store { - t.Helper() - db, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatalf("open: %v", err) - } - t.Cleanup(func() { db.Close() }) - return sqlite.NewStore(db) -} - -func wiringRec(id string) domain.SessionRecord { - now := time.Now().UTC() - return domain.SessionRecord{ - ID: domain.SessionID(id), ProjectID: "proj", Kind: domain.KindWorker, CreatedAt: now, UpdatedAt: now, - Lifecycle: domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress}, - PR: domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonNotCreated}, - Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, - Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, - }, - } -} - -func TestOutboxAdapterMapsPendingEvents(t *testing.T) { - ctx := context.Background() - store := newWiringStore(t) - a := outboxAdapter{store} - - if err := store.Upsert(ctx, wiringRec("s1"), ports.EventSessionCreated); err != nil { - t.Fatalf("upsert: %v", err) - } - - pending, err := a.ListUnsent(ctx, 10) - if err != nil { - t.Fatalf("list unsent: %v", err) - } - if len(pending) != 1 { - t.Fatalf("want 1 pending event, got %d", len(pending)) - } - pe := pending[0] - if pe.Seq != 1 || pe.SessionID != "s1" || pe.EventType != string(ports.EventSessionCreated) || pe.Revision != 1 { - t.Fatalf("unexpected mapping: %+v", pe) - } - if pe.Payload == "" { - t.Fatal("payload should carry the marshaled record") - } - - // MarkSent must clear it from the unsent set. - if err := a.MarkSent(ctx, pe.OutboxID, time.Now().UTC()); err != nil { - t.Fatalf("mark sent: %v", err) - } - again, err := a.ListUnsent(ctx, 10) - if err != nil { - t.Fatalf("list unsent 2: %v", err) - } - if len(again) != 0 { - t.Fatalf("sent event should not reappear, got %d", len(again)) - } -} - -func TestSnapshotSourceRebuildsState(t *testing.T) { - ctx := context.Background() - store := newWiringStore(t) - s := snapshotSource{store} - - // Empty store: no events, maxSeq 0. - events, maxSeq, err := s.Snapshot(ctx) - if err != nil { - t.Fatalf("empty snapshot: %v", err) - } - if len(events) != 0 || maxSeq != 0 { - t.Fatalf("empty store should yield no events and maxSeq 0, got %d events maxSeq %d", len(events), maxSeq) - } - - // Two canonical writes (seq 1,2) across two sessions. - if err := store.Upsert(ctx, wiringRec("s1"), ports.EventSessionCreated); err != nil { - t.Fatalf("upsert s1: %v", err) - } - if err := store.Upsert(ctx, wiringRec("s2"), ports.EventSessionCreated); err != nil { - t.Fatalf("upsert s2: %v", err) - } - - events, maxSeq, err = s.Snapshot(ctx) - if err != nil { - t.Fatalf("snapshot: %v", err) - } - if maxSeq != 2 { - t.Fatalf("maxSeq = %d, want 2 (change_log high-water)", maxSeq) - } - if len(events) != 2 { - t.Fatalf("want one event per session (2), got %d", len(events)) - } - for _, e := range events { - if e.Seq != maxSeq { - t.Errorf("snapshot event seq = %d, want resume watermark %d", e.Seq, maxSeq) - } - if e.EventType != "session_snapshot" { - t.Errorf("event type = %q, want session_snapshot", e.EventType) - } - // Payload must be a parseable full record at the persisted revision with - // metadata excluded and the schema version stamped. - var rec domain.SessionRecord - if err := json.Unmarshal([]byte(e.Payload), &rec); err != nil { - t.Fatalf("payload not a SessionRecord: %v", err) - } - if rec.Lifecycle.Version != domain.LifecycleVersion { - t.Errorf("payload version = %d, want %d", rec.Lifecycle.Version, domain.LifecycleVersion) - } - if rec.Lifecycle.Revision != 1 { - t.Errorf("payload revision = %d, want 1", rec.Lifecycle.Revision) - } - if !rec.Metadata.IsZero() { - t.Errorf("snapshot payload must exclude metadata, got %v", rec.Metadata) - } - } -} diff --git a/backend/wiring_test.go b/backend/wiring_test.go new file mode 100644 index 0000000000..74b314b057 --- /dev/null +++ b/backend/wiring_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// TestWiring_WriteFlowsToBroadcaster exercises the real boot path end to end: +// a lifecycle write -> sqlite -> DB trigger -> change_log -> CDC poller -> +// broadcaster, through the production storeAdapter and cdcSource. +func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + a := storeAdapter{store} + lcm := lifecycle.New(a, a, noopNotifier{}, noopMessenger{}) + + bcast := cdc.NewBroadcaster() + poller := cdc.NewPoller(cdcSource{store}, bcast, cdc.PollerConfig{}) + if err := poller.SeekToHead(ctx); err != nil { + t.Fatal(err) + } + + var mu sync.Mutex + var got []cdc.Event + bcast.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) + + if err := store.UpsertProject(ctx, sqlite.ProjectRow{ID: "mer", Path: "/repo/mer"}); err != nil { + t.Fatal(err) + } + rec, err := store.CreateSession(ctx, domain.SessionRecord{ + ProjectID: "mer", Kind: domain.KindWorker, + Lifecycle: domain.CanonicalSessionLifecycle{Version: domain.LifecycleVersion, Session: domain.SessionSubstate{State: domain.SessionNotStarted}}, + }) + if err != nil { + t.Fatal(err) + } + // A real transition through the engine, which writes the row and fires the + // is_alive/activity_state CDC trigger. + if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{Valid: true, State: domain.ActivityActive, Timestamp: time.Now()}); err != nil { + t.Fatal(err) + } + + if err := poller.Poll(ctx); err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + var sawSession bool + for _, e := range got { + if e.SessionID == string(rec.ID) { + sawSession = true + } + } + if !sawSession { + t.Fatalf("expected a change_log event for %s to reach the broadcaster, got %d events", rec.ID, len(got)) + } +} From 0a69b8429ee89156107f6a2d7f6a608e2bf60bdc Mon Sep 17 00:00:00 2001 From: prateek Date: Sun, 31 May 2026 07:18:22 +0530 Subject: [PATCH 055/250] docs(config): drop stale CDC-JSONL mention in resolveDataDir CDC is trigger-driven in the SQLite DB now; there is no JSONL log. Co-Authored-By: Claude Opus 4.8 --- backend/internal/config/config.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 68aab00e7e..719e75244f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -149,9 +149,9 @@ func resolveRunFilePath() (string, error) { return filepath.Join(dir, "agent-orchestrator", "running.json"), nil } -// resolveDataDir picks where durable state (SQLite DB, CDC JSONL) lives. An -// explicit AO_DATA_DIR wins; otherwise it sits under the per-user state -// directory alongside running.json. +// resolveDataDir picks where durable state (the SQLite DB) lives. An explicit +// AO_DATA_DIR wins; otherwise it sits under the per-user state directory +// alongside running.json. func resolveDataDir() (string, error) { if p, ok := os.LookupEnv("AO_DATA_DIR"); ok && p != "" { return p, nil From 0dbd304e5832045b050b7e7679a563cd494d3107 Mon Sep 17 00:00:00 2001 From: prateek Date: Sun, 31 May 2026 16:14:02 +0530 Subject: [PATCH 056/250] fix(backend): drain CDC/lifecycle goroutines without deadlocking on non-signal exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lcStack.Stop()/cdcPipe.Stop() block on done channels that close only when ctx is cancelled, but the deferred stop() that cancels ctx ran last (LIFO) — so any non-signal exit (e.g. a listener bind error) hung the daemon forever. Cancel ctx first, then drain, explicitly after srv.Run instead of via defer. Also refresh the startup comments that still described the removed outbox/JSONL/janitor flow. Co-Authored-By: Claude Opus 4.8 --- backend/main.go | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/backend/main.go b/backend/main.go index c4d4da52f0..60d9e26e38 100644 --- a/backend/main.go +++ b/backend/main.go @@ -47,12 +47,13 @@ func run() error { return err } - // Open the durable store and bring up the CDC substrate (outbox publisher, - // JSONL consumer + broadcaster, outbox janitor). The LCM/Session Manager and - // the HTTP API routes that drive and read this store are owned by the daemon - // lane and are wired there once their collaborators (Notifier, AgentMessenger, - // and the runtime/agent/workspace plugins) have production implementations; - // here we stand up the persistence + change-delivery foundation they build on. + // Open the durable store and bring up the CDC substrate: the DB triggers + // capture changes into change_log, the poller tails it, and the broadcaster + // fans events out to the SSE transport. The LCM/Session Manager and the HTTP + // API routes that drive and read this store are owned by the daemon lane and + // are wired there once their collaborators (Notifier, AgentMessenger, and the + // runtime/agent/workspace plugins) have production implementations; here we + // stand up the persistence + change-delivery foundation they build on. store, err := sqlite.Open(cfg.DataDir) if err != nil { return fmt.Errorf("open store: %w", err) @@ -60,7 +61,7 @@ func run() error { defer store.Close() // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the - // graceful shutdown inside Server.Run. + // graceful shutdown inside Server.Run and stops the background goroutines. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -68,17 +69,12 @@ func run() error { if err != nil { return err } - defer func() { - if err := cdcPipe.Stop(); err != nil { - log.Error("cdc pipeline shutdown", "err", err) - } - }() // Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE - // timer). This makes the write path live end-to-end: LCM.Upsert -> store -> - // outbox -> CDC JSONL -> broadcaster. The collaborators it needs that don't - // yet have production implementations (Notifier, AgentMessenger, runtime - // registry) are stubbed in lifecycle_wiring.go with TODO markers. + // timer). This makes the write path live end-to-end: LCM write -> store -> DB + // trigger -> change_log -> poller -> broadcaster. The collaborators it needs + // that don't yet have production implementations (Notifier, AgentMessenger, + // runtime registry) are stubbed in lifecycle_wiring.go with TODO markers. // // NOT wired here yet — both await collaborators the daemon lane owns: // - Session Manager: session.New needs Runtime/Agent/Workspace plugins to @@ -90,11 +86,21 @@ func run() error { // the SM work since the routes call into it. lcStack, err := startLifecycle(ctx, store, log) if err != nil { - return fmt.Errorf("start lifecycle: %w", err) + return err } - defer lcStack.Stop() - return srv.Run(ctx) + runErr := srv.Run(ctx) + + // Shut the background goroutines down in order: cancel the context FIRST so + // their loops exit, then wait for them to drain. Doing this explicitly (not + // via defer) avoids the LIFO trap where a Stop() that blocks on ctx-cancel + // runs before the cancel — which would hang any non-signal exit path. + stop() + lcStack.Stop() + if err := cdcPipe.Stop(); err != nil { + log.Error("cdc pipeline shutdown", "err", err) + } + return runErr } // newLogger returns the daemon's slog logger. It writes to stderr so the From 70aab5eb26352869842b4a96db615422f55acf55 Mon Sep 17 00:00:00 2001 From: prateek Date: Sun, 31 May 2026 17:02:47 +0530 Subject: [PATCH 057/250] feat(backend): atomic PR-observation write + CDC on check status updates Addresses review on PR-observation persistence: - pr_checks now has an AFTER UPDATE CDC trigger (guarded on status change), so a check flipping in_progress->failed on the same commit emits change_log instead of updating silently. Restores symmetry with the sessions/pr triggers. - writePR persists scalar facts + checks + comments in ONE transaction via Store.WritePRObservation, so a mid-write failure can't leave the pr row (and its CDC event) committed while checks/comments are partial. Collapses the PRWriter port's three write methods into one WritePR. - db.go: record why modernc.org/sqlite (pure-Go, CGO-free static binary) at the import site. Regression tests for both the update-trigger (emit on change, suppress no-op re-poll) and the transactional write. go test -race ./... green. Co-Authored-By: Claude Opus 4.8 --- backend/internal/lifecycle/manager.go | 22 +++-- backend/internal/lifecycle/manager_test.go | 14 +-- backend/internal/ports/outbound.go | 8 +- backend/internal/storage/sqlite/db.go | 3 + .../storage/sqlite/migrations/0001_init.sql | 19 ++++ .../internal/storage/sqlite/pr_cdc_test.go | 86 +++++++++++++++++++ backend/internal/storage/sqlite/pr_store.go | 74 +++++++++++++--- backend/lifecycle_wiring.go | 43 +++++----- 8 files changed, 212 insertions(+), 57 deletions(-) create mode 100644 backend/internal/storage/sqlite/pr_cdc_test.go diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 5c58f0a27e..f61d38b450 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -176,27 +176,31 @@ func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o return m.runReactions(ctx, id, prContent(o)) } -// writePR upserts the scalar facts, records each check run, and replaces the -// comment set. PR-table CDC is emitted by the DB triggers. +// writePR persists the observation's scalar facts, check runs, and comment set +// in one atomic store call. PR-table CDC is emitted by the DB triggers. func (m *Manager) writePR(ctx context.Context, id domain.SessionID, o ports.PRObservation) error { now := m.clock() - if err := m.pr.UpsertPR(ctx, ports.PRRow{ + row := ports.PRRow{ URL: o.URL, SessionID: string(id), Number: o.Number, Draft: o.Draft, Merged: o.Merged, Closed: o.Closed, CI: o.CI, Review: o.Review, Mergeability: o.Mergeability, UpdatedAt: now, - }); err != nil { - return err } - for _, c := range o.Checks { + checks := make([]ports.PRCheckRow, len(o.Checks)) + for i, c := range o.Checks { c.PRURL = o.URL if c.CreatedAt.IsZero() { c.CreatedAt = now } - if err := m.pr.RecordCheck(ctx, c); err != nil { - return err + checks[i] = c + } + comments := make([]ports.PRComment, len(o.Comments)) + for i, c := range o.Comments { + if c.CreatedAt.IsZero() { + c.CreatedAt = now } + comments[i] = c } - return m.pr.ReplacePRComments(ctx, o.URL, o.Comments) + return m.pr.WritePR(ctx, row, checks, comments) } // ---- mutation commands from the Session Manager ---- diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index 7843f8af20..4ae9aaafd0 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -82,12 +82,10 @@ func (f *fakeStore) PRFactsForSession(_ context.Context, id domain.SessionID) (d } return facts, nil } -func (f *fakeStore) UpsertPR(_ context.Context, r ports.PRRow) error { - f.pr[domain.SessionID(r.SessionID)] = r - return nil -} -func (f *fakeStore) RecordCheck(_ context.Context, r ports.PRCheckRow) error { - f.checks = append(f.checks, r) +func (f *fakeStore) WritePR(_ context.Context, pr ports.PRRow, checks []ports.PRCheckRow, comments []ports.PRComment) error { + f.pr[domain.SessionID(pr.SessionID)] = pr + f.checks = append(f.checks, checks...) + f.comments[pr.URL] = comments return nil } func (f *fakeStore) RecentCheckStatuses(_ context.Context, url, name string, limit int) ([]string, error) { @@ -99,10 +97,6 @@ func (f *fakeStore) RecentCheckStatuses(_ context.Context, url, name string, lim } return out, nil } -func (f *fakeStore) ReplacePRComments(_ context.Context, url string, cs []ports.PRComment) error { - f.comments[url] = cs - return nil -} type fakeNotifier struct{ events []ports.Event } diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index d180f53858..75a24bf070 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -24,10 +24,12 @@ type SessionStore interface { // PRWriter records the PR facts a PR observation carries. The pr table's own DB // triggers emit the CDC; this just writes the rows. type PRWriter interface { - UpsertPR(ctx context.Context, r PRRow) error - RecordCheck(ctx context.Context, r PRCheckRow) error + // WritePR persists a full PR observation — scalar facts, check runs, and the + // replacement comment set — in one transaction, so the rows and the CDC + // events they emit are all-or-nothing. + WritePR(ctx context.Context, pr PRRow, checks []PRCheckRow, comments []PRComment) error + // RecentCheckStatuses reads the last `limit` runs of a check (the CI brake). RecentCheckStatuses(ctx context.Context, prURL, name string, limit int) ([]string, error) - ReplacePRComments(ctx context.Context, prURL string, comments []PRComment) error } // Notifier delivers an event to the human (desktop/Slack later). Push, never poll. diff --git a/backend/internal/storage/sqlite/db.go b/backend/internal/storage/sqlite/db.go index 63f6b7dd5f..8b001d119a 100644 --- a/backend/internal/storage/sqlite/db.go +++ b/backend/internal/storage/sqlite/db.go @@ -11,6 +11,9 @@ import ( "path/filepath" "github.com/pressly/goose/v3" + // modernc.org/sqlite is the pure-Go (CGO-free) SQLite driver — chosen so the + // daemon cross-compiles and ships as a static binary with no libsqlite/CGO + // toolchain dependency, at the cost of some raw throughput vs a C-backed driver. _ "modernc.org/sqlite" ) diff --git a/backend/internal/storage/sqlite/migrations/0001_init.sql b/backend/internal/storage/sqlite/migrations/0001_init.sql index 6534816d7d..9d5a6a22da 100644 --- a/backend/internal/storage/sqlite/migrations/0001_init.sql +++ b/backend/internal/storage/sqlite/migrations/0001_init.sql @@ -202,6 +202,25 @@ BEGIN END; -- +goose StatementEnd +-- A re-polled check can change status on the same commit (in_progress -> failed) +-- via UpsertPRCheck's ON CONFLICT DO UPDATE. Without this trigger that status +-- transition would update the row silently, so CDC consumers would never see it. +-- Guarded on the status so a no-op re-poll emits nothing. +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_update +AFTER UPDATE ON pr_checks +WHEN OLD.status <> NEW.status +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + NEW.created_at); +END; +-- +goose StatementEnd + -- +goose Down -- +goose StatementBegin DROP TABLE change_log; diff --git a/backend/internal/storage/sqlite/pr_cdc_test.go b/backend/internal/storage/sqlite/pr_cdc_test.go new file mode 100644 index 0000000000..8c8f7ea2ef --- /dev/null +++ b/backend/internal/storage/sqlite/pr_cdc_test.go @@ -0,0 +1,86 @@ +package sqlite + +import ( + "context" + "strings" + "testing" + "time" +) + +// A check can change status on the same commit (in_progress -> failed) via +// UpsertPRCheck's ON CONFLICT DO UPDATE. CDC must emit on that transition, not +// only on the first insert — otherwise live clients never see the status change. +func TestPRChecksCDC_EmitsOnInsertAndStatusUpdate(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + rec, err := s.CreateSession(ctx, sampleRecord("mer")) + if err != nil { + t.Fatal(err) + } + url := "https://example/pr/1" + if err := s.UpsertPR(ctx, PRRow{URL: url, SessionID: string(rec.ID), Number: 1}); err != nil { + t.Fatal(err) + } + + now := time.Now() + mustCheck := func(status string) { + if err := s.RecordCheck(ctx, PRCheckRow{PRURL: url, Name: "build", CommitHash: "c1", Status: status, CreatedAt: now}); err != nil { + t.Fatal(err) + } + } + mustCheck("in_progress") // insert -> event + mustCheck("failed") // status change on same commit (update) -> event + mustCheck("failed") // no-op re-poll (status unchanged) -> NO event + + rows, err := s.ReadChangeLogAfter(ctx, 0, 100) + if err != nil { + t.Fatal(err) + } + var checkEvents []ChangeLogRow + for _, r := range rows { + if r.EventType == "pr_check_recorded" { + checkEvents = append(checkEvents, r) + } + } + if len(checkEvents) != 2 { + t.Fatalf("want 2 check CDC events (insert + status change, no-op suppressed), got %d", len(checkEvents)) + } + if !strings.Contains(checkEvents[1].Payload, `"status":"failed"`) { + t.Fatalf("the update event should carry the new status, got %q", checkEvents[1].Payload) + } +} + +// WritePRObservation persists scalar facts, checks, and comments in one tx; all +// three should be queryable afterward. +func TestWritePRObservation_PersistsScalarsChecksAndComments(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + rec, err := s.CreateSession(ctx, sampleRecord("mer")) + if err != nil { + t.Fatal(err) + } + url := "https://example/pr/7" + now := time.Now() + + err = s.WritePRObservation(ctx, + PRRow{URL: url, SessionID: string(rec.ID), Number: 7, CIState: "failing", UpdatedAt: now}, + []PRCheckRow{{PRURL: url, Name: "build", CommitHash: "c1", Status: "failed", CreatedAt: now}}, + []PRCommentRow{{PRURL: url, CommentID: "1", Author: "reviewer", Body: "use a const", CreatedAt: now}}, + ) + if err != nil { + t.Fatal(err) + } + + pr, ok, err := s.GetPR(ctx, url) + if err != nil || !ok || pr.CIState != "failing" { + t.Fatalf("scalar facts not persisted: ok=%v ci=%q err=%v", ok, pr.CIState, err) + } + if checks, _ := s.ListChecks(ctx, url); len(checks) != 1 || checks[0].Status != "failed" { + t.Fatalf("check not persisted: %+v", checks) + } + if comments, _ := s.ListPRComments(ctx, url); len(comments) != 1 || comments[0].Body != "use a const" { + t.Fatalf("comment not persisted: %+v", comments) + } +} diff --git a/backend/internal/storage/sqlite/pr_store.go b/backend/internal/storage/sqlite/pr_store.go index 4170da4d99..8b41396c57 100644 --- a/backend/internal/storage/sqlite/pr_store.go +++ b/backend/internal/storage/sqlite/pr_store.go @@ -27,18 +27,7 @@ type PRRow struct { // fields default to their "nothing known yet" value so a partial row is valid // against the CHECK constraints (matches the domain zero values none/unknown). func (s *Store) UpsertPR(ctx context.Context, r PRRow) error { - if r.State == "" { - r.State = "open" - } - if r.ReviewDecision == "" { - r.ReviewDecision = "none" - } - if r.CIState == "" { - r.CIState = "unknown" - } - if r.Mergeability == "" { - r.Mergeability = "unknown" - } + r = r.withDefaults() s.writeMu.Lock() defer s.writeMu.Unlock() return s.qw.UpsertPR(ctx, gen.UpsertPRParams{ @@ -53,6 +42,67 @@ func (s *Store) UpsertPR(ctx context.Context, r PRRow) error { }) } +// WritePRObservation persists a full PR observation — scalar facts, check runs, +// and the replacement comment set — in one write transaction, so the rows and +// the change_log events their triggers emit are committed all-or-nothing. The +// scalar PR upsert runs first so the checks'/comments' CDC triggers can resolve +// the session id from the pr row within the same transaction. +func (s *Store) WritePRObservation(ctx context.Context, pr PRRow, checks []PRCheckRow, comments []PRCommentRow) error { + pr = pr.withDefaults() + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.inTx(ctx, "write pr observation", func(q *gen.Queries) error { + if err := q.UpsertPR(ctx, gen.UpsertPRParams{ + Url: pr.URL, SessionID: pr.SessionID, Number: pr.Number, + PrState: pr.State, ReviewDecision: pr.ReviewDecision, + CiState: pr.CIState, Mergeability: pr.Mergeability, UpdatedAt: pr.UpdatedAt, + }); err != nil { + return err + } + for _, c := range checks { + if c.Status == "" { + c.Status = "unknown" + } + if err := q.UpsertPRCheck(ctx, gen.UpsertPRCheckParams{ + PrUrl: c.PRURL, Name: c.Name, CommitHash: c.CommitHash, + Status: c.Status, Url: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, + }); err != nil { + return err + } + } + if err := q.DeletePRComments(ctx, pr.URL); err != nil { + return err + } + for _, cm := range comments { + if err := q.UpsertPRComment(ctx, gen.UpsertPRCommentParams{ + PrUrl: pr.URL, CommentID: cm.CommentID, Author: cm.Author, File: cm.File, + Line: cm.Line, Body: cm.Body, Resolved: boolToInt(cm.Resolved), CreatedAt: cm.CreatedAt, + }); err != nil { + return fmt.Errorf("comment %q: %w", cm.CommentID, err) + } + } + return nil + }) +} + +// withDefaults fills empty enum fields with their "nothing known yet" value so a +// partial row satisfies the CHECK constraints (matches UpsertPR). +func (r PRRow) withDefaults() PRRow { + if r.State == "" { + r.State = "open" + } + if r.ReviewDecision == "" { + r.ReviewDecision = "none" + } + if r.CIState == "" { + r.CIState = "unknown" + } + if r.Mergeability == "" { + r.Mergeability = "unknown" + } + return r +} + // GetPR returns the PR facts for a URL, or ok=false if absent. func (s *Store) GetPR(ctx context.Context, url string) (PRRow, bool, error) { p, err := s.qr.GetPR(ctx, url) diff --git a/backend/lifecycle_wiring.go b/backend/lifecycle_wiring.go index f69b1ce463..d736d65367 100644 --- a/backend/lifecycle_wiring.go +++ b/backend/lifecycle_wiring.go @@ -87,33 +87,30 @@ func (a storeAdapter) PRFactsForSession(ctx context.Context, id domain.SessionID return facts, nil } -func (a storeAdapter) UpsertPR(ctx context.Context, r ports.PRRow) error { - return a.Store.UpsertPR(ctx, sqlite.PRRow{ - URL: r.URL, SessionID: r.SessionID, Number: int64(r.Number), - State: prState(r), - ReviewDecision: string(r.Review), - CIState: string(r.CI), - Mergeability: string(r.Mergeability), - UpdatedAt: r.UpdatedAt, - }) -} - -func (a storeAdapter) RecordCheck(ctx context.Context, r ports.PRCheckRow) error { - return a.Store.RecordCheck(ctx, sqlite.PRCheckRow{ - PRURL: r.PRURL, Name: r.Name, CommitHash: r.CommitHash, - Status: r.Status, URL: r.URL, LogTail: r.LogTail, CreatedAt: r.CreatedAt, - }) -} - -func (a storeAdapter) ReplacePRComments(ctx context.Context, prURL string, comments []ports.PRComment) error { - rows := make([]sqlite.PRCommentRow, len(comments)) +func (a storeAdapter) WritePR(ctx context.Context, pr ports.PRRow, checks []ports.PRCheckRow, comments []ports.PRComment) error { + row := sqlite.PRRow{ + URL: pr.URL, SessionID: pr.SessionID, Number: int64(pr.Number), + State: prState(pr), + ReviewDecision: string(pr.Review), + CIState: string(pr.CI), + Mergeability: string(pr.Mergeability), + UpdatedAt: pr.UpdatedAt, + } + checkRows := make([]sqlite.PRCheckRow, len(checks)) + for i, c := range checks { + checkRows[i] = sqlite.PRCheckRow{ + PRURL: c.PRURL, Name: c.Name, CommitHash: c.CommitHash, + Status: c.Status, URL: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, + } + } + commentRows := make([]sqlite.PRCommentRow, len(comments)) for i, c := range comments { - rows[i] = sqlite.PRCommentRow{ - PRURL: prURL, CommentID: c.ID, Author: c.Author, File: c.File, + commentRows[i] = sqlite.PRCommentRow{ + PRURL: pr.URL, CommentID: c.ID, Author: c.Author, File: c.File, Line: int64(c.Line), Body: c.Body, Resolved: c.Resolved, CreatedAt: c.CreatedAt, } } - return a.Store.ReplacePRComments(ctx, prURL, rows) + return a.Store.WritePRObservation(ctx, row, checkRows, commentRows) } // prState collapses the PR's bools into the single pr.state column value. From ad1c4dacece42cd7810ab005a98fc0f343203d3b Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 31 May 2026 18:34:51 +0530 Subject: [PATCH 058/250] test(integration): LCM+SM live-fire against real SQLite store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds backend/internal/integration with five end-to-end tests that hydrate the real lifecycle.Manager + session.Manager against a tmp SQLite store and exercise the full pipeline through the DB triggers and the CDC poller: - TestHappyPath_Spawn_PR_Kill — spawn -> SCM PR observation (open + CI passing) -> kill; asserts canonical row, pr row, and change_log event types (session_created/_updated, pr_created, pr_check_recorded). - TestRestoreRoundTrip_PreservesMetadata — spawn, kill, close store, reopen same DB path, hydrate fresh LCM/SM, Restore(); asserts AgentSessionID and the rest of SessionMetadata survive across the daemon restart. - TestCIFailureAndRecovery_NudgeThenClears — failing CI observation drives the CI-failed reaction nudge with the log tail injected; passing CI observation switches to approved-and-green human notify; pr_checks history reads back the failure (the brake's source of truth). - TestDetectingPersistsAcrossRestart — failed probe parks the session in detecting with detecting_* columns populated, round-trips across a close/reopen, alive probe clears the quarantine memory. - TestCDCPollerReceivesAllStages — drives the real cdc.Poller; asserts the trigger pipeline emits each expected event_type and seq is monotonic. Wiring gap fixed (minimal): goose v3 keeps baseFS/logger/dialect as package-level globals, so two concurrent sqlite.Open() calls — uncommon in production but normal under -race with t.Parallel() — race on goose.SetBaseFS/SetLogger/SetDialect inside migrate(). Added a process-level sync.Mutex around the migrate() call. ~11 lines, no signature changes. Scope notes (the task brief assumed a fancier architecture than what actually shipped in PR #37): - No outbox / consumer_offsets / janitor exist on main — the change_log table IS the durable, ordered source of truth (see cdc/event.go), so the brief's janitor-watermark step is skipped. - No reaction_trackers table / ReactionStore port — trackers are in-memory per lifecycle/reactions.go; persistence-round-trip there is N/A. - No revision column / Upsert(rec, eventType) — write-mutex serialises and change_log.seq orders, so the assertions land on event_type + seq, not on a per-row revision counter. All 219 tests pass under -race across 18 packages. lifecycle/fakes_test.go is untouched; existing unit tests still drive the in-memory fake. --- .../integration/lifecycle_sqlite_test.go | 644 ++++++++++++++++++ backend/internal/storage/sqlite/db.go | 11 + 2 files changed, 655 insertions(+) create mode 100644 backend/internal/integration/lifecycle_sqlite_test.go diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go new file mode 100644 index 0000000000..214bf08382 --- /dev/null +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -0,0 +1,644 @@ +// Package integration exercises the lifecycle + session lane against the real +// SQLite store and the real CDC trigger pipeline. Unit tests stay on the +// in-memory fakes in lifecycle/ and session/; these live-fire tests prove the +// wiring across packages actually flows: SM -> store row -> LCM mutate -> store +// update -> DB trigger -> change_log read. +package integration + +import ( + "context" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/session" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// ---- store adapter (mirrors backend.storeAdapter so integration tests don't +// import package main; kept local and minimal). ---- +// +// MIRROR OF backend/lifecycle_wiring.go's storeAdapter. The integration tests +// can't import package main, so the small set of methods that bridge +// *sqlite.Store to ports.SessionStore + ports.PRWriter is duplicated here. Keep +// in sync; the obvious follow-up is to extract the production adapter into a +// shared internal package once the lane stabilises. + +type storeAdapter struct{ *sqlite.Store } + +var ( + _ ports.SessionStore = storeAdapter{} + _ ports.PRWriter = storeAdapter{} +) + +func (a storeAdapter) PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) { + rows, err := a.Store.ListPRsBySession(ctx, string(id)) + if err != nil || len(rows) == 0 { + return domain.PRFacts{}, err + } + pick := rows[0] + for _, r := range rows { + if r.State == "draft" || r.State == "open" { + pick = r + break + } + } + facts := domain.PRFacts{ + URL: pick.URL, Number: int(pick.Number), Exists: true, + Draft: pick.State == "draft", + Merged: pick.State == "merged", + Closed: pick.State == "closed", + CI: domain.CIState(pick.CIState), + Review: domain.ReviewDecision(pick.ReviewDecision), + Mergeability: domain.Mergeability(pick.Mergeability), + } + comments, err := a.Store.ListPRComments(ctx, pick.URL) + if err != nil { + return domain.PRFacts{}, err + } + for _, c := range comments { + if !c.Resolved { + facts.ReviewComments = true + break + } + } + return facts, nil +} + +func (a storeAdapter) WritePR(ctx context.Context, pr ports.PRRow, checks []ports.PRCheckRow, comments []ports.PRComment) error { + state := "open" + switch { + case pr.Merged: + state = "merged" + case pr.Closed: + state = "closed" + case pr.Draft: + state = "draft" + } + row := sqlite.PRRow{ + URL: pr.URL, SessionID: pr.SessionID, Number: int64(pr.Number), + State: state, ReviewDecision: string(pr.Review), + CIState: string(pr.CI), Mergeability: string(pr.Mergeability), UpdatedAt: pr.UpdatedAt, + } + checkRows := make([]sqlite.PRCheckRow, len(checks)) + for i, c := range checks { + checkRows[i] = sqlite.PRCheckRow{ + PRURL: c.PRURL, Name: c.Name, CommitHash: c.CommitHash, + Status: c.Status, URL: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, + } + } + commentRows := make([]sqlite.PRCommentRow, len(comments)) + for i, c := range comments { + commentRows[i] = sqlite.PRCommentRow{ + PRURL: pr.URL, CommentID: c.ID, Author: c.Author, File: c.File, + Line: int64(c.Line), Body: c.Body, Resolved: c.Resolved, CreatedAt: c.CreatedAt, + } + } + return a.Store.WritePRObservation(ctx, row, checkRows, commentRows) +} + +// ---- plugin fakes (minimal: only enough to drive SM through real LCM) ---- + +type stubRuntime struct { + id, name string +} + +func (s *stubRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { + return ports.RuntimeHandle{ID: s.id, RuntimeName: s.name}, nil +} +func (s *stubRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil } +func (s *stubRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { + return true, nil +} + +type stubAgent struct{} + +func (stubAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" } +func (stubAgent) GetEnvironment(ports.AgentConfig) map[string]string { return map[string]string{} } +func (stubAgent) GetRestoreCommand(id string) string { return "resume " + id } + +type stubWorkspace struct { + root string +} + +func (w *stubWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + return ports.WorkspaceInfo{ + Path: filepath.Join(w.root, string(cfg.SessionID)), + Branch: cfg.Branch, + SessionID: cfg.SessionID, + ProjectID: cfg.ProjectID, + }, nil +} +func (w *stubWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { return nil } +func (w *stubWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + return w.Create(ctx, cfg) +} + +type captureMessenger struct { + mu sync.Mutex + msgs []string +} + +func (m *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.msgs = append(m.msgs, msg) + return nil +} +func (m *captureMessenger) drain() []string { + m.mu.Lock() + defer m.mu.Unlock() + out := append([]string(nil), m.msgs...) + m.msgs = nil + return out +} + +type captureNotifier struct { + mu sync.Mutex + events []ports.Event +} + +func (n *captureNotifier) Notify(_ context.Context, e ports.Event) error { + n.mu.Lock() + defer n.mu.Unlock() + n.events = append(n.events, e) + return nil +} +func (n *captureNotifier) drain() []ports.Event { + n.mu.Lock() + defer n.mu.Unlock() + out := append([]ports.Event(nil), n.events...) + n.events = nil + return out +} + +// ---- harness: real store + real LCM + real SM + change_log poller ---- + +type liveStack struct { + dataDir string + store *sqlite.Store + adapter storeAdapter + lcm *lifecycle.Manager + sm *session.Manager + notifier *captureNotifier + messenger *captureMessenger +} + +func openLiveStack(t *testing.T, dataDir string) *liveStack { + t.Helper() + store, err := sqlite.Open(dataDir) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + adapter := storeAdapter{store} + notifier := &captureNotifier{} + messenger := &captureMessenger{} + lcm := lifecycle.New(adapter, adapter, notifier, messenger) + + wsRoot := t.TempDir() + sm := session.New(session.Deps{ + Runtime: &stubRuntime{id: "h1", name: "tmux"}, + Agent: stubAgent{}, + Workspace: &stubWorkspace{root: wsRoot}, + Store: adapter, + Messenger: messenger, + Lifecycle: lcm, + }) + return &liveStack{ + dataDir: dataDir, + store: store, + adapter: adapter, + lcm: lcm, + sm: sm, + notifier: notifier, + messenger: messenger, + } +} + +func (s *liveStack) close(t *testing.T) { + t.Helper() + if err := s.store.Close(); err != nil { + t.Fatalf("close store: %v", err) + } +} + +func seedProject(t *testing.T, store *sqlite.Store, id string) { + t.Helper() + if err := store.UpsertProject(context.Background(), sqlite.ProjectRow{ + ID: id, Path: "/repo/" + id, RegisteredAt: time.Now(), + }); err != nil { + t.Fatalf("upsert project: %v", err) + } +} + +// ---- tests ---- + +// TestHappyPath drives Spawn -> SCM PR observation (open + CI passing) -> Kill, +// asserting via direct store reads that the canonical row, the PR row, and the +// change_log stream all reflect what each step contributed. +func TestHappyPath_Spawn_PR_Kill(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := openLiveStack(t, t.TempDir()) + defer st.close(t) + seedProject(t, st.store, "mer") + + // 1. Spawn — SM inserts the session row, LCM marks it live. + sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ + ProjectID: "mer", Kind: domain.KindWorker, Prompt: "ship it", + }) + if err != nil { + t.Fatalf("spawn: %v", err) + } + if sess.ID != "mer-1" { + t.Fatalf("want id mer-1, got %q", sess.ID) + } + + rec, ok, err := st.store.GetSession(ctx, sess.ID) + if err != nil || !ok { + t.Fatalf("get session: ok=%v err=%v", ok, err) + } + if !rec.Lifecycle.IsAlive { + t.Fatal("post-spawn: is_alive should be true") + } + if rec.Lifecycle.Session.State != domain.SessionNotStarted { + t.Fatalf("post-spawn state want not_started, got %q", rec.Lifecycle.Session.State) + } + if rec.Metadata.RuntimeHandleID != "h1" || rec.Metadata.RuntimeName != "tmux" { + t.Fatalf("post-spawn handles missing: %+v", rec.Metadata) + } + if rec.Metadata.WorkspacePath == "" || rec.Metadata.Prompt != "ship it" { + t.Fatalf("post-spawn metadata missing: %+v", rec.Metadata) + } + + // 2. SCM observes a fresh PR — open, CI passing. LCM writes the pr row + // atomically (one tx, triggers fire pr_created). + prURL := "https://github.com/repo/mer/pull/1" + if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ + Fetched: true, URL: prURL, Number: 1, + CI: domain.CIPassing, Review: domain.ReviewNone, Mergeability: domain.MergeMergeable, + Checks: []ports.PRCheckRow{{ + Name: "ci/build", CommitHash: "abc123", Status: "passed", CreatedAt: time.Now(), + }}, + }); err != nil { + t.Fatalf("apply pr: %v", err) + } + prRow, ok, err := st.store.GetPR(ctx, prURL) + if err != nil || !ok { + t.Fatalf("get pr: ok=%v err=%v", ok, err) + } + if prRow.SessionID != string(sess.ID) || prRow.CIState != "passing" || prRow.State != "open" { + t.Fatalf("pr row wrong: %+v", prRow) + } + + // 3. Kill — SM routes to LCM and tears down runtime+workspace. + freed, err := st.sm.Kill(ctx, sess.ID, domain.TermManuallyKilled) + if err != nil || !freed { + t.Fatalf("kill freed=%v err=%v", freed, err) + } + rec, _, _ = st.store.GetSession(ctx, sess.ID) + if rec.Lifecycle.Session.State != domain.SessionTerminated || + rec.Lifecycle.TerminationReason != domain.TermManuallyKilled || + rec.Lifecycle.IsAlive { + t.Fatalf("post-kill canonical wrong: %+v", rec.Lifecycle) + } + + // 4. Assert the change_log captured the full timeline. The DB triggers + // write the only durable CDC; we don't want to assume an ordering of + // interleaved events, just that each expected event_type shows up. + rows, err := st.store.ReadChangeLogAfter(ctx, 0, 100) + if err != nil { + t.Fatalf("read change_log: %v", err) + } + seen := map[string]bool{} + for _, r := range rows { + seen[r.EventType] = true + } + for _, want := range []string{"session_created", "session_updated", "pr_created", "pr_check_recorded"} { + if !seen[want] { + t.Fatalf("missing change_log event %q (got: %v)", want, seen) + } + } +} + +// TestRestoreRoundTrip simulates a daemon restart: spawn a session, persist the +// kill, fully close the in-process LCM/SM, open a fresh stack against the SAME +// DB file, and Restore. The restored session must keep its metadata (the agent +// session id is the must-survive bit). +func TestRestoreRoundTrip_PreservesMetadata(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + st := openLiveStack(t, dir) + seedProject(t, st.store, "mer") + + // Phase A: spawn with an agent session id, then kill so the row is terminal + // and Restore is legal. + sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ + ProjectID: "mer", Kind: domain.KindWorker, Prompt: "remember me", + }) + if err != nil { + t.Fatalf("spawn: %v", err) + } + // fold an AgentSessionID into the row — the LCM does this through the spawn + // outcome on Restore too, but a fresh spawn doesn't (the agent has not + // reported one yet). We patch via the store so the restore branch has + // something to resume from. + rec, _, _ := st.store.GetSession(ctx, sess.ID) + rec.Metadata.AgentSessionID = "agent-xyz" + if err := st.store.UpdateSession(ctx, rec); err != nil { + t.Fatalf("patch agent id: %v", err) + } + if _, err := st.sm.Kill(ctx, sess.ID, domain.TermManuallyKilled); err != nil { + t.Fatalf("kill: %v", err) + } + st.close(t) + + // Phase B: reopen against the same data dir; everything in memory is gone. + st2 := openLiveStack(t, dir) + defer st2.close(t) + + // Confirm the row survived the restart. + rec2, ok, err := st2.store.GetSession(ctx, sess.ID) + if err != nil || !ok { + t.Fatalf("reopen get: ok=%v err=%v", ok, err) + } + if rec2.Metadata.AgentSessionID != "agent-xyz" { + t.Fatalf("agent session id lost across restart: %+v", rec2.Metadata) + } + if rec2.Lifecycle.Session.State != domain.SessionTerminated { + t.Fatalf("expected terminal after reopen, got %q", rec2.Lifecycle.Session.State) + } + + // Phase C: Restore — must drive a fresh OnSpawnCompleted and surface the + // preserved AgentSessionID into the new outcome. + restored, err := st2.sm.Restore(ctx, sess.ID) + if err != nil { + t.Fatalf("restore: %v", err) + } + if !restored.Lifecycle.IsAlive { + t.Fatal("restored session should be is_alive after spawn-completed") + } + if restored.Metadata.AgentSessionID != "agent-xyz" { + t.Fatalf("restored row dropped AgentSessionID: %+v", restored.Metadata) + } +} + +// TestCIFailureAndRecovery drives the CI-failed reaction path: a failing +// observation injects a nudge into the agent (messenger), a recovery +// observation (CI passing) flips state without re-firing the nudge, and the +// pr_checks history records both runs so the brake's "last 3 all failed" query +// reads the truth. +func TestCIFailureAndRecovery_NudgeThenClears(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := openLiveStack(t, t.TempDir()) + defer st.close(t) + seedProject(t, st.store, "mer") + + sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + // Move the session out of not_started so the reaction path engages on real + // PR facts (not_started doesn't react on PRs). + if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{ + Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(), + }); err != nil { + t.Fatalf("activity: %v", err) + } + _ = st.messenger.drain() // ignore startup nudges, focus on CI + + prURL := "https://github.com/repo/mer/pull/2" + // Failing CI: handleCIFailure should send a CI-failed nudge with the log + // tail injected. + if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ + Fetched: true, URL: prURL, Number: 2, + CI: domain.CIFailing, Mergeability: domain.MergeUnstable, + Checks: []ports.PRCheckRow{{ + Name: "ci/build", CommitHash: "c1", Status: "failed", LogTail: "panic: nil map", CreatedAt: time.Now(), + }}, + }); err != nil { + t.Fatalf("apply pr (failing): %v", err) + } + got := st.messenger.drain() + if len(got) == 0 { + t.Fatal("expected CI-failed nudge to the agent") + } + if !strings.Contains(got[0], "CI is failing") || !strings.Contains(got[0], "panic: nil map") { + t.Fatalf("ci-failed message missing content: %q", got[0]) + } + + // Brake confirmation: only one failure so far, RecentCheckStatuses should + // reflect it. + history, err := st.adapter.RecentCheckStatuses(ctx, prURL, "ci/build", 3) + if err != nil { + t.Fatalf("recent checks: %v", err) + } + if len(history) != 1 || history[0] != "failed" { + t.Fatalf("ci history wrong: %v", history) + } + + // Recovery: CI passing on a new commit. With the dedupe slot still on + // rxCIFailed, the dispatch path moves to rxApprovedGreen (mergeable) and + // the human notifier is the one that pages. + if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ + Fetched: true, URL: prURL, Number: 2, + CI: domain.CIPassing, Mergeability: domain.MergeMergeable, + Checks: []ports.PRCheckRow{{ + Name: "ci/build", CommitHash: "c2", Status: "passed", CreatedAt: time.Now(), + }}, + }); err != nil { + t.Fatalf("apply pr (recovery): %v", err) + } + ev := st.notifier.drain() + if len(ev) == 0 { + t.Fatal("recovery: notifier should have received an event (approved-and-green)") + } + if !anyEventType(ev, "reaction.approved-and-green") { + t.Fatalf("recovery should notify approved-and-green, got %+v", ev) + } + + // And the pr row reflects the recovery in the canonical fact store. + prRow, ok, _ := st.store.GetPR(ctx, prURL) + if !ok || prRow.CIState != "passing" { + t.Fatalf("pr ci_state should be passing post-recovery: %+v", prRow) + } +} + +// TestDetectingPersistsAcrossRestart drives the runtime quarantine path: a +// failed probe puts the session into the detecting state, which means the +// decider's anti-flap memory MUST be flushed to the detecting_* columns and +// survive a restart. A subsequent alive probe must clear it. +func TestDetectingPersistsAcrossRestart(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + st := openLiveStack(t, dir) + seedProject(t, st.store, "mer") + + sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + // Move to working so the runtime decider doesn't bail on not_started. + if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{ + Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(), + }); err != nil { + t.Fatalf("activity: %v", err) + } + // One failed probe should park the session in detecting with attempts=1. + if err := st.lcm.ApplyRuntimeObservation(ctx, sess.ID, ports.RuntimeFacts{ + ObservedAt: time.Now(), + Runtime: ports.ProbeFailed, + Process: ports.ProbeFailed, + }); err != nil { + t.Fatalf("apply runtime: %v", err) + } + rec, _, _ := st.store.GetSession(ctx, sess.ID) + if rec.Lifecycle.Session.State != domain.SessionDetecting { + t.Fatalf("expected detecting state, got %q", rec.Lifecycle.Session.State) + } + if rec.Lifecycle.Detecting == nil || rec.Lifecycle.Detecting.Attempts == 0 { + t.Fatalf("detecting memory should be populated: %+v", rec.Lifecycle.Detecting) + } + + // Restart: close, reopen, verify the detecting_* columns round-tripped. + st.close(t) + st2 := openLiveStack(t, dir) + defer st2.close(t) + + rec2, ok, _ := st2.store.GetSession(ctx, sess.ID) + if !ok || rec2.Lifecycle.Detecting == nil { + t.Fatalf("detecting lost across restart: %+v", rec2.Lifecycle) + } + if rec2.Lifecycle.Detecting.Attempts != rec.Lifecycle.Detecting.Attempts { + t.Fatalf("attempts round-trip mismatch: pre=%d post=%d", + rec.Lifecycle.Detecting.Attempts, rec2.Lifecycle.Detecting.Attempts) + } + if rec2.Lifecycle.Detecting.EvidenceHash != rec.Lifecycle.Detecting.EvidenceHash { + t.Fatal("evidence hash dropped across restart") + } + + // Recovery probe — alive — must clear detecting and flip state out of it. + if err := st2.lcm.ApplyRuntimeObservation(ctx, sess.ID, ports.RuntimeFacts{ + ObservedAt: time.Now(), + Runtime: ports.ProbeAlive, + Process: ports.ProbeAlive, + }); err != nil { + t.Fatalf("recovery probe: %v", err) + } + rec3, _, _ := st2.store.GetSession(ctx, sess.ID) + if rec3.Lifecycle.Detecting != nil { + t.Fatalf("alive probe should clear detecting, got %+v", rec3.Lifecycle.Detecting) + } + if rec3.Lifecycle.Session.State == domain.SessionDetecting { + t.Fatalf("session state should leave detecting, got %q", rec3.Lifecycle.Session.State) + } +} + +// TestCDCPollerReceivesAllStages drives the full real pipeline including the +// in-process CDC poller — proving the trigger writes become broadcaster events +// in the same order the storage layer observes them. +func TestCDCPollerReceivesAllStages(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := openLiveStack(t, t.TempDir()) + defer st.close(t) + seedProject(t, st.store, "mer") + + bcast := cdc.NewBroadcaster() + src := pollerSource{st.store} + poller := cdc.NewPoller(src, bcast, cdc.PollerConfig{Batch: 100}) + + var ( + mu sync.Mutex + events []cdc.Event + ) + bcast.Subscribe(func(e cdc.Event) { + mu.Lock() + defer mu.Unlock() + events = append(events, e) + }) + + sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{ + Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(), + }); err != nil { + t.Fatalf("activity: %v", err) + } + if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ + Fetched: true, URL: "https://github.com/repo/mer/pull/3", Number: 3, + CI: domain.CIPassing, Mergeability: domain.MergeMergeable, + }); err != nil { + t.Fatalf("apply pr: %v", err) + } + + if err := poller.Poll(ctx); err != nil { + t.Fatalf("poll: %v", err) + } + + mu.Lock() + defer mu.Unlock() + types := map[cdc.EventType]bool{} + for _, e := range events { + types[e.Type] = true + } + for _, want := range []cdc.EventType{cdc.EventSessionCreated, cdc.EventSessionUpdated, cdc.EventPRCreated} { + if !types[want] { + t.Fatalf("poller missed event %q (got %+v)", want, types) + } + } + // Seq monotonicity invariant — the wiring assumes it; assert it here. + var prev int64 + for _, e := range events { + if e.Seq <= prev { + t.Fatalf("seq not monotonic: %d after %d", e.Seq, prev) + } + prev = e.Seq + } +} + +// ---- small helpers ---- + +type pollerSource struct{ *sqlite.Store } + +func (s pollerSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) { + rows, err := s.Store.ReadChangeLogAfter(ctx, after, limit) + if err != nil { + return nil, err + } + out := make([]cdc.Event, len(rows)) + for i, r := range rows { + out[i] = cdc.Event{ + Seq: r.Seq, + ProjectID: r.ProjectID, + SessionID: r.SessionID, + Type: cdc.EventType(r.EventType), + Payload: []byte(r.Payload), + CreatedAt: r.CreatedAt, + } + } + return out, nil +} +func (s pollerSource) LatestSeq(ctx context.Context) (int64, error) { + return s.Store.MaxChangeLogSeq(ctx) +} + +func anyEventType(evs []ports.Event, t string) bool { + for _, e := range evs { + if e.Type == t { + return true + } + } + return false +} diff --git a/backend/internal/storage/sqlite/db.go b/backend/internal/storage/sqlite/db.go index 8b001d119a..926d08d3e1 100644 --- a/backend/internal/storage/sqlite/db.go +++ b/backend/internal/storage/sqlite/db.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "sync" "github.com/pressly/goose/v3" // modernc.org/sqlite is the pure-Go (CGO-free) SQLite driver — chosen so the @@ -70,7 +71,17 @@ func Open(dataDir string) (*Store, error) { return NewStore(writeDB, readDB), nil } +// gooseMu serialises calls into goose. goose v3 keeps its baseFS / logger / +// dialect as package-level globals (goose.SetBaseFS, goose.SetLogger, +// goose.SetDialect), so two concurrent Open() calls — uncommon in production +// but normal in -race test runs — race on those writes. The cost of holding the +// mutex is one process-startup migration; readers and writers afterwards never +// touch goose. +var gooseMu sync.Mutex + func migrate(db *sql.DB) error { + gooseMu.Lock() + defer gooseMu.Unlock() goose.SetBaseFS(migrationsFS) goose.SetLogger(goose.NopLogger()) if err := goose.SetDialect("sqlite3"); err != nil { From ee0af288a49dcf2e85e9cf789af41796d834ee7d Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 31 May 2026 18:37:06 +0530 Subject: [PATCH 059/250] chore: gofmt integration test file The Check formatting step failed on the stub method alignment in stubAgent's three method declarations. Re-run of gofmt aligns the column gutter on those signatures; no behavioural change. --- backend/internal/integration/lifecycle_sqlite_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index 214bf08382..e395611096 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -119,9 +119,9 @@ func (s *stubRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error type stubAgent struct{} -func (stubAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" } +func (stubAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" } func (stubAgent) GetEnvironment(ports.AgentConfig) map[string]string { return map[string]string{} } -func (stubAgent) GetRestoreCommand(id string) string { return "resume " + id } +func (stubAgent) GetRestoreCommand(id string) string { return "resume " + id } type stubWorkspace struct { root string From 6552e1eee09d7b21d2759aff9eea71b5e0c1dd4f Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 31 May 2026 18:46:31 +0530 Subject: [PATCH 060/250] test(integration): address greptile review - Idempotent close + t.Cleanup so a mid-test t.Fatalf can't leak the SQLite handle for the rest of the binary run. Restart-style tests still call close() explicitly between phases; the cleanup hook becomes a no-op once that runs. - Drop the hardcoded "mer-1" assertion in TestHappyPath; assert the structural invariant (project-scoped, non-empty id) instead, so the test does not couple to the {project}-{counter} generation detail. - Realign the test storeAdapter's PRFactsForSession + WritePR bodies to be line-for-line identical to backend/lifecycle_wiring.go's production adapter (extracted prState helper, separated err vs empty-rows checks), so a future divergence shows up as a diff at review time. The proper fix (extract to internal/storeutil) remains out of scope per the brief's "do NOT redesign anything". All 219 tests still pass under -race. --- .../integration/lifecycle_sqlite_test.go | 81 +++++++++++++------ 1 file changed, 57 insertions(+), 24 deletions(-) diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index e395611096..f2f75a71d3 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -21,14 +21,15 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) -// ---- store adapter (mirrors backend.storeAdapter so integration tests don't -// import package main; kept local and minimal). ---- +// ---- store adapter ---- // // MIRROR OF backend/lifecycle_wiring.go's storeAdapter. The integration tests // can't import package main, so the small set of methods that bridge -// *sqlite.Store to ports.SessionStore + ports.PRWriter is duplicated here. Keep -// in sync; the obvious follow-up is to extract the production adapter into a -// shared internal package once the lane stabilises. +// *sqlite.Store to ports.SessionStore + ports.PRWriter is duplicated here. +// Function bodies are line-for-line identical to the production adapter so a +// future divergence shows up as a real diff in code review; the obvious +// follow-up is to extract the production adapter into a shared internal +// package — explicitly out of scope for this PR ("do NOT redesign anything"). type storeAdapter struct{ *sqlite.Store } @@ -39,9 +40,12 @@ var ( func (a storeAdapter) PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) { rows, err := a.Store.ListPRsBySession(ctx, string(id)) - if err != nil || len(rows) == 0 { + if err != nil { return domain.PRFacts{}, err } + if len(rows) == 0 { + return domain.PRFacts{}, nil + } pick := rows[0] for _, r := range rows { if r.State == "draft" || r.State == "open" { @@ -51,9 +55,7 @@ func (a storeAdapter) PRFactsForSession(ctx context.Context, id domain.SessionID } facts := domain.PRFacts{ URL: pick.URL, Number: int(pick.Number), Exists: true, - Draft: pick.State == "draft", - Merged: pick.State == "merged", - Closed: pick.State == "closed", + Draft: pick.State == "draft", Merged: pick.State == "merged", Closed: pick.State == "closed", CI: domain.CIState(pick.CIState), Review: domain.ReviewDecision(pick.ReviewDecision), Mergeability: domain.Mergeability(pick.Mergeability), @@ -72,19 +74,13 @@ func (a storeAdapter) PRFactsForSession(ctx context.Context, id domain.SessionID } func (a storeAdapter) WritePR(ctx context.Context, pr ports.PRRow, checks []ports.PRCheckRow, comments []ports.PRComment) error { - state := "open" - switch { - case pr.Merged: - state = "merged" - case pr.Closed: - state = "closed" - case pr.Draft: - state = "draft" - } row := sqlite.PRRow{ URL: pr.URL, SessionID: pr.SessionID, Number: int64(pr.Number), - State: state, ReviewDecision: string(pr.Review), - CIState: string(pr.CI), Mergeability: string(pr.Mergeability), UpdatedAt: pr.UpdatedAt, + State: prState(pr), + ReviewDecision: string(pr.Review), + CIState: string(pr.CI), + Mergeability: string(pr.Mergeability), + UpdatedAt: pr.UpdatedAt, } checkRows := make([]sqlite.PRCheckRow, len(checks)) for i, c := range checks { @@ -103,6 +99,21 @@ func (a storeAdapter) WritePR(ctx context.Context, pr ports.PRRow, checks []port return a.Store.WritePRObservation(ctx, row, checkRows, commentRows) } +// prState mirrors the production helper of the same name in +// backend/lifecycle_wiring.go. +func prState(r ports.PRRow) string { + switch { + case r.Merged: + return "merged" + case r.Closed: + return "closed" + case r.Draft: + return "draft" + default: + return "open" + } +} + // ---- plugin fakes (minimal: only enough to drive SM through real LCM) ---- type stubRuntime struct { @@ -188,8 +199,14 @@ type liveStack struct { sm *session.Manager notifier *captureNotifier messenger *captureMessenger + + closed bool // guard so the explicit close() and t.Cleanup don't double-close } +// openLiveStack opens the store + hydrates the LCM/SM and registers an +// idempotent t.Cleanup so a mid-test t.Fatalf can't leak the SQLite handle. +// Tests that need to simulate a daemon restart still call close() explicitly +// between phases; the cleanup hook becomes a no-op once that runs. func openLiveStack(t *testing.T, dataDir string) *liveStack { t.Helper() store, err := sqlite.Open(dataDir) @@ -210,7 +227,7 @@ func openLiveStack(t *testing.T, dataDir string) *liveStack { Messenger: messenger, Lifecycle: lcm, }) - return &liveStack{ + st := &liveStack{ dataDir: dataDir, store: store, adapter: adapter, @@ -219,10 +236,24 @@ func openLiveStack(t *testing.T, dataDir string) *liveStack { notifier: notifier, messenger: messenger, } + t.Cleanup(func() { + if st.closed { + return + } + // Best-effort: failures here would be noise after t.Fatalf already + // recorded the real cause. + _ = st.store.Close() + st.closed = true + }) + return st } func (s *liveStack) close(t *testing.T) { t.Helper() + if s.closed { + return + } + s.closed = true if err := s.store.Close(); err != nil { t.Fatalf("close store: %v", err) } @@ -249,15 +280,17 @@ func TestHappyPath_Spawn_PR_Kill(t *testing.T) { defer st.close(t) seedProject(t, st.store, "mer") - // 1. Spawn — SM inserts the session row, LCM marks it live. + // 1. Spawn — SM inserts the session row, LCM marks it live. We only assert + // the structural invariant of the id (project-scoped, non-empty), not the + // literal counter — that's a store-internal detail. sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ ProjectID: "mer", Kind: domain.KindWorker, Prompt: "ship it", }) if err != nil { t.Fatalf("spawn: %v", err) } - if sess.ID != "mer-1" { - t.Fatalf("want id mer-1, got %q", sess.ID) + if sess.ID == "" || !strings.HasPrefix(string(sess.ID), "mer-") { + t.Fatalf("expected project-scoped id like mer-N, got %q", sess.ID) } rec, ok, err := st.store.GetSession(ctx, sess.ID) From 1a9a9ec67eb0934e960e3664fb90ecc428bd9018 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 31 May 2026 18:54:55 +0530 Subject: [PATCH 061/250] test(integration): check ok/err before patching session row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1: the patch-then-Update path in TestRestoreRoundTrip was discarding GetSession's ok/err. A missed row would have handed UpdateSession a zero-value SessionRecord (ID==""), which matches zero rows and returns nil — Phase B then fails with the misleading "agent session id lost across restart" instead of the real cause. Fixed at the patch site (the only write path that could swallow the error) and at the two read-then-assert sites in TestDetectingPersistsAcrossRestart for consistency. Downstream assertions there would already fail loudly, but the explicit ok/err check makes the failure mode unambiguous. All 219 tests still pass under -race. --- .../integration/lifecycle_sqlite_test.go | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index f2f75a71d3..4774550801 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -382,8 +382,14 @@ func TestRestoreRoundTrip_PreservesMetadata(t *testing.T) { // fold an AgentSessionID into the row — the LCM does this through the spawn // outcome on Restore too, but a fresh spawn doesn't (the agent has not // reported one yet). We patch via the store so the restore branch has - // something to resume from. - rec, _, _ := st.store.GetSession(ctx, sess.ID) + // something to resume from. Check ok/err: without it, a missed row would + // hand UpdateSession a zero-value record (ID==""), which matches no rows + // and returns nil — Phase B would then fail with a misleading "agent id + // lost across restart" rather than the real cause. + rec, ok, err := st.store.GetSession(ctx, sess.ID) + if err != nil || !ok { + t.Fatalf("get session for patch: ok=%v err=%v", ok, err) + } rec.Metadata.AgentSessionID = "agent-xyz" if err := st.store.UpdateSession(ctx, rec); err != nil { t.Fatalf("patch agent id: %v", err) @@ -534,7 +540,10 @@ func TestDetectingPersistsAcrossRestart(t *testing.T) { }); err != nil { t.Fatalf("apply runtime: %v", err) } - rec, _, _ := st.store.GetSession(ctx, sess.ID) + rec, ok, err := st.store.GetSession(ctx, sess.ID) + if err != nil || !ok { + t.Fatalf("get session post-probe: ok=%v err=%v", ok, err) + } if rec.Lifecycle.Session.State != domain.SessionDetecting { t.Fatalf("expected detecting state, got %q", rec.Lifecycle.Session.State) } @@ -567,7 +576,10 @@ func TestDetectingPersistsAcrossRestart(t *testing.T) { }); err != nil { t.Fatalf("recovery probe: %v", err) } - rec3, _, _ := st2.store.GetSession(ctx, sess.ID) + rec3, ok3, err := st2.store.GetSession(ctx, sess.ID) + if err != nil || !ok3 { + t.Fatalf("get session post-recovery: ok=%v err=%v", ok3, err) + } if rec3.Lifecycle.Detecting != nil { t.Fatalf("alive probe should clear detecting, got %+v", rec3.Lifecycle.Detecting) } From 721b8b34a272a92232764fee0df32f404b052159 Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Sun, 31 May 2026 19:07:52 +0530 Subject: [PATCH 062/250] feat(terminal): PTY-attach terminal streaming feature package Add internal/terminal: a transport-agnostic feature package that attaches a PTY to a session's tmux pane and multiplexes the byte stream to WebSocket clients, plus a session-state channel fed by the CDC broadcaster. The PTY is reached through a small PTYSource interface (satisfied by the tmux runtime adapter) and spawned via an injectable spawnFunc, so fan-out, the 50KB replay ring, and re-attach resilience all test without a real process, tmux, or network. A tmux-guarded integration test exercises the real creack/pty path end-to-end. Raw PTY bytes never touch the CDC change_log; only the sessions channel is CDC-fed. Windows PTY spawning is stubbed pending a ConPTY path. --- backend/go.mod | 2 + backend/go.sum | 4 + backend/internal/terminal/doc.go | 22 ++ backend/internal/terminal/fakes_test.go | 171 ++++++++ backend/internal/terminal/manager.go | 367 ++++++++++++++++++ backend/internal/terminal/manager_test.go | 192 +++++++++ backend/internal/terminal/protocol.go | 71 ++++ backend/internal/terminal/protocol_test.go | 59 +++ backend/internal/terminal/pty_unix.go | 52 +++ backend/internal/terminal/pty_windows.go | 16 + backend/internal/terminal/ring.go | 48 +++ backend/internal/terminal/ring_test.go | 38 ++ backend/internal/terminal/session.go | 307 +++++++++++++++ .../terminal/session_integration_test.go | 52 +++ backend/internal/terminal/session_test.go | 138 +++++++ 15 files changed, 1539 insertions(+) create mode 100644 backend/internal/terminal/doc.go create mode 100644 backend/internal/terminal/fakes_test.go create mode 100644 backend/internal/terminal/manager.go create mode 100644 backend/internal/terminal/manager_test.go create mode 100644 backend/internal/terminal/protocol.go create mode 100644 backend/internal/terminal/protocol_test.go create mode 100644 backend/internal/terminal/pty_unix.go create mode 100644 backend/internal/terminal/pty_windows.go create mode 100644 backend/internal/terminal/ring.go create mode 100644 backend/internal/terminal/ring_test.go create mode 100644 backend/internal/terminal/session.go create mode 100644 backend/internal/terminal/session_integration_test.go create mode 100644 backend/internal/terminal/session_test.go diff --git a/backend/go.mod b/backend/go.mod index 88ca590cfb..b0ac013d26 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,6 +9,8 @@ require ( ) require ( + github.com/coder/websocket v1.8.14 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.21 // indirect diff --git a/backend/go.sum b/backend/go.sum index 89f839295e..44d49456be 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,7 @@ +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= diff --git a/backend/internal/terminal/doc.go b/backend/internal/terminal/doc.go new file mode 100644 index 0000000000..e9d3ebba26 --- /dev/null +++ b/backend/internal/terminal/doc.go @@ -0,0 +1,22 @@ +// Package terminal is the live-terminal streaming feature: it attaches to a +// session's tmux pane over a PTY and multiplexes the byte stream to one or more +// WebSocket clients, alongside a session-state channel fed by the CDC +// broadcaster. +// +// Boundaries (see docs/backend-code-structure.md): +// +// - This package owns the product workflow: PTY attach, output fan-out, a +// bounded replay buffer, re-attach resilience, and the ch-tagged wire +// protocol. It is transport-agnostic: it speaks to a small wsConn interface, +// not to any concrete WebSocket library. +// - internal/httpd owns the HTTP/WebSocket upgrade and adapts the accepted +// socket to wsConn; it does not contain stream logic. +// - The PTY itself is reached through PTYSource (satisfied by the tmux runtime +// adapter's AttachCommand/IsAlive) and spawned through an injectable +// spawnFunc, so the fan-out, buffering, and re-attach logic test without a +// real process, tmux, or network. +// +// Raw PTY bytes never flow through the CDC change_log; only the session channel +// is fed by cdc.Broadcaster. Terminal output is high-volume ephemeral data and +// goes straight from the PTY to the socket. +package terminal diff --git a/backend/internal/terminal/fakes_test.go b/backend/internal/terminal/fakes_test.go new file mode 100644 index 0000000000..939f6eccf1 --- /dev/null +++ b/backend/internal/terminal/fakes_test.go @@ -0,0 +1,171 @@ +package terminal + +import ( + "context" + "io" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// fakeSource is a scripted PTYSource. +type fakeSource struct { + argv []string + mu sync.Mutex + alive bool + aliveErr error + attachErr error +} + +func (f *fakeSource) AttachCommand(ports.RuntimeHandle) ([]string, error) { + if f.attachErr != nil { + return nil, f.attachErr + } + if f.argv == nil { + return []string{"tmux", "attach"}, nil + } + return f.argv, nil +} + +func (f *fakeSource) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.alive, f.aliveErr +} + +func (f *fakeSource) setAlive(v bool) { + f.mu.Lock() + f.alive = v + f.mu.Unlock() +} + +// fakePTY is a scripted ptyProcess: Read drains the out channel, Write records, +// Resize records, Close/Wait unblock on close. +type fakePTY struct { + out chan []byte + closed chan struct{} + once sync.Once + + mu sync.Mutex + written []byte + resizes [][2]uint16 +} + +func newFakePTY() *fakePTY { + return &fakePTY{out: make(chan []byte, 64), closed: make(chan struct{})} +} + +func (p *fakePTY) push(b []byte) { p.out <- b } + +func (p *fakePTY) Read(b []byte) (int, error) { + select { + case chunk := <-p.out: + return copy(b, chunk), nil + case <-p.closed: + return 0, io.EOF + } +} + +func (p *fakePTY) Write(b []byte) (int, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.written = append(p.written, b...) + return len(b), nil +} + +func (p *fakePTY) Resize(rows, cols uint16) error { + p.mu.Lock() + defer p.mu.Unlock() + p.resizes = append(p.resizes, [2]uint16{rows, cols}) + return nil +} + +func (p *fakePTY) Wait() error { + <-p.closed + return nil +} + +func (p *fakePTY) Close() error { + p.once.Do(func() { close(p.closed) }) + return nil +} + +func (p *fakePTY) writtenBytes() []byte { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]byte, len(p.written)) + copy(out, p.written) + return out +} + +func (p *fakePTY) resizeCalls() [][2]uint16 { + p.mu.Lock() + defer p.mu.Unlock() + return append([][2]uint16(nil), p.resizes...) +} + +// fakeSpawner hands out pre-built fakePTYs in order; once exhausted it returns +// idle PTYs that block until closed (so a re-attach loop does not busy-spin). +type fakeSpawner struct { + mu sync.Mutex + ptys []*fakePTY + n int + err error + created []*fakePTY +} + +func (f *fakeSpawner) spawn(context.Context, []string) (ptyProcess, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return nil, f.err + } + var p *fakePTY + if f.n < len(f.ptys) { + p = f.ptys[f.n] + } else { + p = newFakePTY() + } + f.n++ + f.created = append(f.created, p) + return p, nil +} + +func (f *fakeSpawner) calls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.n +} + +// eventually polls cond until true or the deadline, failing the test otherwise. +func eventually(t *testing.T, d time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("condition not met within " + d.String()) +} + +// safeBytes is a concurrency-safe byte accumulator for subscriber callbacks. +type safeBytes struct { + mu sync.Mutex + b []byte +} + +func (s *safeBytes) add(p []byte) { + s.mu.Lock() + s.b = append(s.b, p...) + s.mu.Unlock() +} + +func (s *safeBytes) string() string { + s.mu.Lock() + defer s.mu.Unlock() + return string(s.b) +} diff --git a/backend/internal/terminal/manager.go b/backend/internal/terminal/manager.go new file mode 100644 index 0000000000..068f36bf25 --- /dev/null +++ b/backend/internal/terminal/manager.go @@ -0,0 +1,367 @@ +package terminal + +import ( + "context" + "encoding/base64" + "log/slog" + "sync" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// EventSource is the session-state feed the "sessions" channel forwards. The CDC +// broadcaster satisfies it; the interface lives next to its consumer so terminal +// does not depend on CDC internals beyond the Event shape. +type EventSource interface { + Subscribe(fn func(cdc.Event)) (unsubscribe func()) +} + +// wsConn is the transport seam: a JSON-framed, single-reader/single-writer +// WebSocket connection. internal/httpd adapts coder/websocket to this; tests +// supply an in-memory fake. WriteJSON is only ever called from the per-conn +// writer goroutine; Ping may be called concurrently (it is a control frame). +type wsConn interface { + ReadJSON(ctx context.Context, v any) error + WriteJSON(ctx context.Context, v any) error + Ping(ctx context.Context) error + Close(reason string) error +} + +const ( + defaultHeartbeat = 15 * time.Second + defaultWriteBuffer = 1024 +) + +// Manager owns the set of live terminal sessions and serves WebSocket clients. +// Sessions outlive any single connection: multiple clients can attach to the +// same pane, and a client reconnect re-subscribes to the existing session. +type Manager struct { + src PTYSource + events EventSource + spawn spawnFunc + log *slog.Logger + heartbeat time.Duration + + // ctx scopes every session's PTY lifetime; cancelled by Close. + ctx context.Context + cancel context.CancelFunc + + mu sync.Mutex + sessions map[string]*session + closed bool +} + +// Option configures a Manager. +type Option func(*Manager) + +// WithSpawn overrides the PTY spawner (tests inject a fake). +func WithSpawn(fn spawnFunc) Option { return func(m *Manager) { m.spawn = fn } } + +// WithHeartbeat overrides the ping interval. +func WithHeartbeat(d time.Duration) Option { return func(m *Manager) { m.heartbeat = d } } + +// NewManager builds a Manager. src attaches PTYs; events feeds the session +// channel (may be nil to disable it); log is required. +func NewManager(src PTYSource, events EventSource, log *slog.Logger, opts ...Option) *Manager { + ctx, cancel := context.WithCancel(context.Background()) + m := &Manager{ + src: src, + events: events, + spawn: defaultSpawn, + log: log, + heartbeat: defaultHeartbeat, + ctx: ctx, + cancel: cancel, + sessions: map[string]*session{}, + } + for _, opt := range opts { + opt(m) + } + return m +} + +// Close tears down every session and stops re-attach loops. Safe to call once on +// daemon shutdown. +func (m *Manager) Close() { + m.mu.Lock() + if m.closed { + m.mu.Unlock() + return + } + m.closed = true + sessions := make([]*session, 0, len(m.sessions)) + for _, s := range m.sessions { + sessions = append(sessions, s) + } + m.sessions = map[string]*session{} + m.mu.Unlock() + + m.cancel() + for _, s := range sessions { + s.close() + } +} + +// openSession returns the live session for id, starting it on first open. The id +// is the runtime handle id (tmux target). +func (m *Manager) openSession(id string) (*session, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.closed { + return nil, context.Canceled + } + if s, ok := m.sessions[id]; ok { + return s, nil + } + handle := ports.RuntimeHandle{ID: id} + s := newSession(id, handle, m.src, m.spawn, m.log) + m.sessions[id] = s + go func() { + s.run(m.ctx) + m.mu.Lock() + if cur, ok := m.sessions[id]; ok && cur == s { + delete(m.sessions, id) + } + m.mu.Unlock() + }() + return s, nil +} + +// Serve runs the protocol loop for one client connection until it errors, the +// client disconnects, or ctx/the manager is cancelled. It owns the single writer +// goroutine and the heartbeat. +func (m *Manager) Serve(ctx context.Context, conn wsConn) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + c := &connState{ + mgr: m, + conn: conn, + cancel: cancel, + out: make(chan serverMsg, defaultWriteBuffer), + terms: map[string]func(){}, + } + defer c.cleanup() + + go c.writeLoop(ctx) + go c.heartbeatLoop(ctx, m.heartbeat) + + for { + var msg clientMsg + if err := conn.ReadJSON(ctx, &msg); err != nil { + return + } + if ctx.Err() != nil { + return + } + c.handle(ctx, msg) + } +} + +// connState is the per-connection mutable state. +type connState struct { + mgr *Manager + conn wsConn + cancel context.CancelFunc + out chan serverMsg + + mu sync.Mutex + terms map[string]func() // terminal id -> unsubscribe + unsubEvts func() + closed bool +} + +func (c *connState) handle(ctx context.Context, msg clientMsg) { + switch msg.Ch { + case chTerminal: + c.handleTerminal(ctx, msg) + case chSubscribe: + c.handleSubscribe() + case chSystem: + if msg.Type == msgPing { + c.enqueue(serverMsg{Ch: chSystem, Type: msgPong}) + } + } +} + +func (c *connState) handleTerminal(ctx context.Context, msg clientMsg) { + switch msg.Type { + case msgOpen: + c.openTerminal(ctx, msg.ID) + case msgData: + raw, err := base64.StdEncoding.DecodeString(msg.Data) + if err != nil { + return + } + if s := c.lookup(msg.ID); s != nil { + _ = s.write(raw) + } + case msgResize: + if s := c.lookup(msg.ID); s != nil { + _ = s.resize(msg.Rows, msg.Cols) + } + case msgClose: + c.closeTerminal(msg.ID) + } +} + +func (c *connState) openTerminal(_ context.Context, id string) { + if id == "" { + c.enqueue(serverMsg{Ch: chTerminal, Type: msgError, Error: "missing terminal id"}) + return + } + c.mu.Lock() + if _, ok := c.terms[id]; ok { + c.mu.Unlock() + return // already open on this conn; avoid duplicate replay + } + c.mu.Unlock() + + s, err := c.mgr.openSession(id) + if err != nil { + c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgError, Error: err.Error()}) + return + } + + unsub := s.subscribe( + func(data []byte) { + c.enqueue(serverMsg{ + Ch: chTerminal, + ID: id, + Type: msgData, + Data: base64.StdEncoding.EncodeToString(data), + }) + }, + func() { + c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgExited}) + }, + ) + c.mu.Lock() + c.terms[id] = unsub + c.mu.Unlock() + c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgOpened}) +} + +func (c *connState) closeTerminal(id string) { + c.mu.Lock() + unsub := c.terms[id] + delete(c.terms, id) + c.mu.Unlock() + if unsub != nil { + unsub() + } +} + +func (c *connState) lookup(id string) *session { + c.mu.Lock() + _, open := c.terms[id] + c.mu.Unlock() + if !open { + return nil + } + c.mgr.mu.Lock() + s := c.mgr.sessions[id] + c.mgr.mu.Unlock() + return s +} + +func (c *connState) handleSubscribe() { + if c.mgr.events == nil { + return + } + c.mu.Lock() + if c.unsubEvts != nil { + c.mu.Unlock() + return + } + c.mu.Unlock() + + unsub := c.mgr.events.Subscribe(func(e cdc.Event) { + c.enqueue(serverMsg{ + Ch: chSessions, + Type: msgSnapshot, + Session: &sessionUpdate{ + Seq: e.Seq, + SessionID: e.SessionID, + EventType: e.EventType, + Revision: e.Revision, + }, + }) + }) + c.mu.Lock() + c.unsubEvts = unsub + c.mu.Unlock() +} + +// enqueue pushes a frame to the writer. If the buffer is full the client is too +// slow to keep up; tear the connection down rather than stall fan-out for other +// subscribers of the same pane. +func (c *connState) enqueue(msg serverMsg) { + select { + case c.out <- msg: + default: + c.cancel() + } +} + +func (c *connState) writeLoop(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case msg := <-c.out: + if err := c.conn.WriteJSON(ctx, msg); err != nil { + c.cancel() + return + } + } + } +} + +func (c *connState) heartbeatLoop(ctx context.Context, interval time.Duration) { + if interval <= 0 { + return + } + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + pctx, cancel := context.WithTimeout(ctx, interval) + err := c.conn.Ping(pctx) + cancel() + if err != nil { + c.cancel() + return + } + } + } +} + +func (c *connState) cleanup() { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return + } + c.closed = true + unsubs := make([]func(), 0, len(c.terms)+1) + for _, u := range c.terms { + unsubs = append(unsubs, u) + } + c.terms = map[string]func(){} + if c.unsubEvts != nil { + unsubs = append(unsubs, c.unsubEvts) + c.unsubEvts = nil + } + c.mu.Unlock() + + for _, u := range unsubs { + u() + } + _ = c.conn.Close("server: connection closed") +} diff --git a/backend/internal/terminal/manager_test.go b/backend/internal/terminal/manager_test.go new file mode 100644 index 0000000000..6169c34fd4 --- /dev/null +++ b/backend/internal/terminal/manager_test.go @@ -0,0 +1,192 @@ +package terminal + +import ( + "context" + "encoding/base64" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" +) + +// fakeConn is an in-memory wsConn driven by channels. +type fakeConn struct { + in chan clientMsg + out chan serverMsg + pings int32 + once sync.Once + closed chan struct{} +} + +func newFakeConn() *fakeConn { + return &fakeConn{in: make(chan clientMsg, 16), out: make(chan serverMsg, 64), closed: make(chan struct{})} +} + +func (c *fakeConn) ReadJSON(ctx context.Context, v any) error { + select { + case m := <-c.in: + *(v.(*clientMsg)) = m + return nil + case <-ctx.Done(): + return ctx.Err() + case <-c.closed: + return context.Canceled + } +} + +func (c *fakeConn) WriteJSON(_ context.Context, v any) error { + c.out <- v.(serverMsg) + return nil +} + +func (c *fakeConn) Ping(context.Context) error { + atomic.AddInt32(&c.pings, 1) + return nil +} + +func (c *fakeConn) Close(string) error { + c.once.Do(func() { close(c.closed) }) + return nil +} + +// recv waits for a frame of the given channel+type, draining others. +func recv(t *testing.T, c *fakeConn, ch, typ string, d time.Duration) serverMsg { + t.Helper() + deadline := time.After(d) + for { + select { + case m := <-c.out: + if m.Ch == ch && m.Type == typ { + return m + } + case <-deadline: + t.Fatalf("did not receive %s/%s within %s", ch, typ, d) + } + } +} + +func TestServeOpenStreamsAndWritesTerminal(t *testing.T) { + src := &fakeSource{} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} + mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) + defer mgr.Close() + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + recv(t, conn, chTerminal, msgOpened, time.Second) + + pty.push([]byte("prompt$ ")) + data := recv(t, conn, chTerminal, msgData, time.Second) + got, _ := base64.StdEncoding.DecodeString(data.Data) + if string(got) != "prompt$ " { + t.Fatalf("streamed data = %q", got) + } + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgData, Data: base64.StdEncoding.EncodeToString([]byte("whoami\n"))} + eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "whoami\n" }) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgResize, Rows: 30, Cols: 100} + eventually(t, time.Second, func() bool { + rs := pty.resizeCalls() + return len(rs) == 1 && rs[0] == [2]uint16{30, 100} + }) +} + +func TestServeRejectsOpenWithoutID(t *testing.T) { + mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) + defer mgr.Close() + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, Type: msgOpen} + msg := recv(t, conn, chTerminal, msgError, time.Second) + if msg.Error == "" { + t.Fatal("expected an error message for open without id") + } +} + +func TestServeForwardsSessionChannelFromCDC(t *testing.T) { + bc := cdc.NewBroadcaster() + mgr := NewManager(&fakeSource{}, bc, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) + defer mgr.Close() + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chSubscribe, Type: msgSubscribe} + // Give the subscription time to register before publishing. + eventually(t, time.Second, func() bool { + bc.Publish(cdc.Event{Seq: 9, SessionID: "s1", EventType: "session_updated", Revision: 4}) + select { + case m := <-conn.out: + return m.Ch == chSessions && m.Session != nil && m.Session.Seq == 9 + default: + return false + } + }) +} + +func TestServeSystemPingGetsPong(t *testing.T) { + mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) + defer mgr.Close() + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chSystem, Type: msgPing} + recv(t, conn, chSystem, msgPong, time.Second) +} + +func TestServeHeartbeatPings(t *testing.T) { + mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(10*time.Millisecond)) + defer mgr.Close() + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + eventually(t, time.Second, func() bool { return atomic.LoadInt32(&conn.pings) >= 2 }) +} + +func TestServeClosesConnOnReadEnd(t *testing.T) { + mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) + defer mgr.Close() + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + go mgr.Serve(ctx, conn) + + cancel() // client/server context ends + select { + case <-conn.closed: + case <-time.After(time.Second): + t.Fatal("Serve must close the conn when the context is cancelled") + } +} + +func TestEnqueueOverflowCancelsConn(t *testing.T) { + cancelled := make(chan struct{}) + c := &connState{ + out: make(chan serverMsg, 1), + cancel: func() { close(cancelled) }, + terms: map[string]func(){}, + } + c.enqueue(serverMsg{Ch: chTerminal, Type: msgData}) // fills buffer + c.enqueue(serverMsg{Ch: chTerminal, Type: msgData}) // overflow -> cancel + select { + case <-cancelled: + case <-time.After(time.Second): + t.Fatal("overflow must cancel the connection") + } +} diff --git a/backend/internal/terminal/protocol.go b/backend/internal/terminal/protocol.go new file mode 100644 index 0000000000..472e03872a --- /dev/null +++ b/backend/internal/terminal/protocol.go @@ -0,0 +1,71 @@ +package terminal + +// The wire protocol is a single multiplexed JSON stream tagged by channel +// ("ch"), mirroring the legacy Node mux server so the existing xterm client can +// connect unchanged. One socket carries every logical stream: +// +// ch "terminal" — per-pane byte stream, keyed by an opaque client-chosen id +// ch "subscribe" — the client opts into the session-state channel +// ch "sessions" — server-pushed session-state notifications (CDC-fed) +// ch "system" — liveness; ws-level ping/pong also runs underneath +// +// Terminal payloads are base64 in the Data field: PTY output is arbitrary bytes +// and need not be valid UTF-8, which a raw JSON string could not carry. +const ( + chTerminal = "terminal" + chSubscribe = "subscribe" + chSessions = "sessions" + chSystem = "system" +) + +// client message types (ch "terminal" unless noted). +const ( + msgOpen = "open" + msgData = "data" + msgResize = "resize" + msgClose = "close" + msgSubscribe = "subscribe" // ch "subscribe" + msgPing = "ping" // ch "system" +) + +// server message types. +const ( + msgOpened = "opened" + msgExited = "exited" + msgError = "error" + msgSnapshot = "snapshot" // ch "sessions" + msgPong = "pong" // ch "system" +) + +// clientMsg is one inbound frame. Fields are shared across channels; which are +// populated depends on Ch/Type. +type clientMsg struct { + Ch string `json:"ch"` + ID string `json:"id,omitempty"` + Type string `json:"type"` + // Data is base64-encoded keystrokes for ch "terminal" / type "data". + Data string `json:"data,omitempty"` + Cols uint16 `json:"cols,omitempty"` + Rows uint16 `json:"rows,omitempty"` +} + +// serverMsg is one outbound frame. +type serverMsg struct { + Ch string `json:"ch"` + ID string `json:"id,omitempty"` + Type string `json:"type"` + // Data is base64-encoded PTY output for ch "terminal" / type "data". + Data string `json:"data,omitempty"` + Error string `json:"error,omitempty"` + Session *sessionUpdate `json:"session,omitempty"` +} + +// sessionUpdate is the ch "sessions" payload: a single CDC change projected to +// the fields a client needs to refresh its view. It deliberately omits the raw +// change_log payload blob; the client refetches detail over the REST surface. +type sessionUpdate struct { + Seq int64 `json:"seq"` + SessionID string `json:"sessionId"` + EventType string `json:"eventType"` + Revision int64 `json:"revision"` +} diff --git a/backend/internal/terminal/protocol_test.go b/backend/internal/terminal/protocol_test.go new file mode 100644 index 0000000000..ef521ed8ab --- /dev/null +++ b/backend/internal/terminal/protocol_test.go @@ -0,0 +1,59 @@ +package terminal + +import ( + "encoding/base64" + "encoding/json" + "testing" +) + +func TestClientMsgRoundTrip(t *testing.T) { + in := clientMsg{ + Ch: chTerminal, + ID: "sess-1", + Type: msgData, + Data: base64.StdEncoding.EncodeToString([]byte("ls -la\n")), + Cols: 80, + Rows: 24, + } + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out clientMsg + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out != in { + t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", out, in) + } +} + +func TestServerMsgSessionFrameWireShape(t *testing.T) { + msg := serverMsg{ + Ch: chSessions, + Type: msgSnapshot, + Session: &sessionUpdate{ + Seq: 7, SessionID: "s1", EventType: "session_updated", Revision: 3, + }, + } + raw, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Golden wire shape the client depends on. + want := `{"ch":"sessions","type":"snapshot","session":{"seq":7,"sessionId":"s1","eventType":"session_updated","revision":3}}` + if string(raw) != want { + t.Fatalf("wire shape:\n got %s\nwant %s", raw, want) + } +} + +func TestServerMsgOmitsEmptyOptionalFields(t *testing.T) { + raw, err := json.Marshal(serverMsg{Ch: chTerminal, ID: "t1", Type: msgOpened}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"ch":"terminal","id":"t1","type":"opened"}` + if string(raw) != want { + t.Fatalf("wire shape:\n got %s\nwant %s", raw, want) + } +} diff --git a/backend/internal/terminal/pty_unix.go b/backend/internal/terminal/pty_unix.go new file mode 100644 index 0000000000..4849215bec --- /dev/null +++ b/backend/internal/terminal/pty_unix.go @@ -0,0 +1,52 @@ +//go:build !windows + +package terminal + +import ( + "context" + "errors" + "os" + "os/exec" + + "github.com/creack/pty" +) + +// defaultSpawn starts argv on a real PTY via creack/pty. ctx cancellation kills +// the process. Windows uses a stub (see pty_windows.go) until a ConPTY path is +// added. +func defaultSpawn(ctx context.Context, argv []string) (ptyProcess, error) { + if len(argv) == 0 { + return nil, errors.New("terminal: empty attach command") + } + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + f, err := pty.Start(cmd) + if err != nil { + return nil, err + } + return &creackPTY{f: f, cmd: cmd}, nil +} + +type creackPTY struct { + f *os.File + cmd *exec.Cmd +} + +func (p *creackPTY) Read(b []byte) (int, error) { return p.f.Read(b) } +func (p *creackPTY) Write(b []byte) (int, error) { return p.f.Write(b) } + +func (p *creackPTY) Resize(rows, cols uint16) error { + return pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols}) +} + +func (p *creackPTY) Wait() error { return p.cmd.Wait() } + +// Close stops the attach process and releases the PTY. tmux attach exits cleanly +// when the master closes, but kill the process to be sure it does not linger. +func (p *creackPTY) Close() error { + closeErr := p.f.Close() + if p.cmd.Process != nil { + _ = p.cmd.Process.Kill() + } + _ = p.cmd.Wait() + return closeErr +} diff --git a/backend/internal/terminal/pty_windows.go b/backend/internal/terminal/pty_windows.go new file mode 100644 index 0000000000..c93465aa01 --- /dev/null +++ b/backend/internal/terminal/pty_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package terminal + +import ( + "context" + "errors" +) + +// defaultSpawn is not yet implemented on Windows: the POSIX PTY path uses +// creack/pty. A ConPTY-backed attach (mirroring the legacy named-pipe relay) is +// a follow-up. The rest of the package compiles and tests on Windows with an +// injected spawner. +func defaultSpawn(_ context.Context, _ []string) (ptyProcess, error) { + return nil, errors.New("terminal: PTY streaming is not supported on Windows yet") +} diff --git a/backend/internal/terminal/ring.go b/backend/internal/terminal/ring.go new file mode 100644 index 0000000000..c0194a1b2e --- /dev/null +++ b/backend/internal/terminal/ring.go @@ -0,0 +1,48 @@ +package terminal + +import "sync" + +// defaultRingMax caps per-terminal replay history. A late subscriber gets at +// most this many bytes of recent output so it can paint a usable screen without +// the whole session backlog. Matches the legacy 50KB ring. +const defaultRingMax = 50 * 1024 + +// ringBuffer is a byte ring holding the most recent output of one terminal. It +// keeps a contiguous tail capped at max bytes; snapshot returns a copy for +// replay-on-subscribe. +type ringBuffer struct { + mu sync.Mutex + buf []byte + max int +} + +func newRingBuffer(max int) *ringBuffer { + if max <= 0 { + max = defaultRingMax + } + return &ringBuffer{max: max} +} + +// append adds p and drops the oldest bytes beyond max. A single write larger +// than max is truncated to its last max bytes. +func (r *ringBuffer) append(p []byte) { + r.mu.Lock() + defer r.mu.Unlock() + if len(p) >= r.max { + r.buf = append(r.buf[:0], p[len(p)-r.max:]...) + return + } + r.buf = append(r.buf, p...) + if len(r.buf) > r.max { + r.buf = append(r.buf[:0], r.buf[len(r.buf)-r.max:]...) + } +} + +// snapshot returns a copy of the current contents (oldest first). +func (r *ringBuffer) snapshot() []byte { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]byte, len(r.buf)) + copy(out, r.buf) + return out +} diff --git a/backend/internal/terminal/ring_test.go b/backend/internal/terminal/ring_test.go new file mode 100644 index 0000000000..26b91358ca --- /dev/null +++ b/backend/internal/terminal/ring_test.go @@ -0,0 +1,38 @@ +package terminal + +import ( + "bytes" + "strings" + "testing" +) + +func TestRingBufferKeepsTailWithinCap(t *testing.T) { + r := newRingBuffer(8) + r.append([]byte("abcd")) + r.append([]byte("efgh")) + r.append([]byte("ij")) // total 10 > 8, drop oldest 2 + + if got := string(r.snapshot()); got != "cdefghij" { + t.Fatalf("snapshot = %q, want %q", got, "cdefghij") + } +} + +func TestRingBufferTruncatesOversizeWrite(t *testing.T) { + r := newRingBuffer(4) + r.append([]byte(strings.Repeat("x", 3))) + r.append([]byte("abcdefgh")) // single write larger than cap + + if got := string(r.snapshot()); got != "efgh" { + t.Fatalf("snapshot = %q, want %q", got, "efgh") + } +} + +func TestRingBufferSnapshotIsCopy(t *testing.T) { + r := newRingBuffer(16) + r.append([]byte("data")) + snap := r.snapshot() + snap[0] = 'X' + if !bytes.Equal(r.snapshot(), []byte("data")) { + t.Fatal("snapshot must not alias internal buffer") + } +} diff --git a/backend/internal/terminal/session.go b/backend/internal/terminal/session.go new file mode 100644 index 0000000000..658410b004 --- /dev/null +++ b/backend/internal/terminal/session.go @@ -0,0 +1,307 @@ +package terminal + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// PTYSource is what a terminal needs from the runtime: the argv that attaches a +// PTY to a session's pane, and a liveness check used to decide whether a dropped +// PTY should be re-attached or treated as a clean exit. The tmux runtime adapter +// satisfies this via AttachCommand/IsAlive; the interface lives here, next to its +// only consumer, so terminal does not depend on a concrete adapter. +type PTYSource interface { + AttachCommand(handle ports.RuntimeHandle) ([]string, error) + IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) +} + +// ptyProcess is a started PTY-backed attach process. It is the injection seam +// that keeps fan-out, buffering, and re-attach testable without a real process: +// unit tests supply a scripted in-memory implementation; production uses a +// creack/pty-backed one (see pty_unix.go). +type ptyProcess interface { + io.ReadWriteCloser + Resize(rows, cols uint16) error + // Wait blocks until the attach process exits. + Wait() error +} + +// spawnFunc starts a PTY for argv. ctx cancellation must terminate the process. +type spawnFunc func(ctx context.Context, argv []string) (ptyProcess, error) + +// reattach policy: a PTY that drops is re-attached while the underlying tmux +// session is still alive, up to maxReattach consecutive failures. An attach that +// survived longer than reattachResetGrace before dropping resets the counter, so +// a long-lived pane that blips recovers but a tight crash-loop gives up. +const ( + defaultMaxReattach = 5 + defaultReattachResetTime = 5 * time.Second +) + +// subscriber receives one terminal's output frames. It must not block; the +// session calls it while holding no lock, but a slow consumer stalls fan-out, so +// the WS layer funnels these onto its own buffered writer. +type subscriber func(data []byte) + +// session is one attached terminal pane, fanned out to N subscribers. It owns a +// single PTY (re-attached on drop) and a replay ring buffer. +type session struct { + id string + handle ports.RuntimeHandle + src PTYSource + spawn spawnFunc + log *slog.Logger + ring *ringBuffer + + maxReattach int + resetGrace time.Duration + + mu sync.Mutex + pty ptyProcess + subs map[int]subscriber + exitSubs map[int]func() + nextSub int + closed bool + exited bool + + doneOnce sync.Once + done chan struct{} +} + +func newSession(id string, handle ports.RuntimeHandle, src PTYSource, spawn spawnFunc, log *slog.Logger) *session { + return &session{ + id: id, + handle: handle, + src: src, + spawn: spawn, + log: log, + ring: newRingBuffer(defaultRingMax), + maxReattach: defaultMaxReattach, + resetGrace: defaultReattachResetTime, + subs: map[int]subscriber{}, + exitSubs: map[int]func(){}, + done: make(chan struct{}), + } +} + +// run drives attach → read-loop → re-attach until the pane exits cleanly, the +// session is closed, or ctx is cancelled. It is started once per session. +func (s *session) run(ctx context.Context) { + defer s.markDone() + + failures := 0 + for { + if s.isClosed() || ctx.Err() != nil { + return + } + + argv, err := s.src.AttachCommand(s.handle) + if err != nil { + s.fail("attach command: " + err.Error()) + return + } + p, err := s.spawn(ctx, argv) + if err != nil { + failures++ + if !s.shouldReattach(ctx, failures) { + s.fail("spawn pty: " + err.Error()) + return + } + continue + } + + s.setPTY(p) + start := time.Now() + s.copyOut(p) + _ = p.Close() + + if time.Since(start) >= s.resetGrace { + failures = 0 + } + failures++ + + if !s.shouldReattach(ctx, failures) { + s.markExited() + return + } + s.log.Debug("terminal re-attaching", "id", s.id, "failures", failures) + } +} + +// copyOut pumps PTY output into the ring buffer and out to subscribers until the +// PTY closes or errors. +func (s *session) copyOut(p ptyProcess) { + buf := make([]byte, 32*1024) + for { + n, err := p.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + s.ring.append(chunk) + s.fanout(chunk) + } + if err != nil { + return + } + } +} + +// shouldReattach decides whether a dropped/failed PTY warrants another attempt: +// only while not closed/cancelled, the tmux session still exists, and we are +// under the consecutive-failure cap. A backoff sleep separates attempts. +func (s *session) shouldReattach(ctx context.Context, failures int) bool { + if s.isClosed() || ctx.Err() != nil || failures > s.maxReattach { + return false + } + alive, err := s.src.IsAlive(ctx, s.handle) + if err != nil || !alive { + return false + } + select { + case <-ctx.Done(): + return false + case <-time.After(reattachBackoff(failures)): + return true + } +} + +func reattachBackoff(failures int) time.Duration { + d := time.Duration(failures) * 200 * time.Millisecond + if d > time.Second { + d = time.Second + } + return d +} + +// subscribe registers an output callback and an exit callback, replays the ring +// buffer to the new subscriber, and returns an unsubscribe func. If the pane has +// already exited, onExit fires immediately. +func (s *session) subscribe(onData subscriber, onExit func()) (unsubscribe func()) { + s.mu.Lock() + if s.exited { + s.mu.Unlock() + if onExit != nil { + onExit() + } + return func() {} + } + id := s.nextSub + s.nextSub++ + s.subs[id] = onData + if onExit != nil { + s.exitSubs[id] = onExit + } + replay := s.ring.snapshot() + s.mu.Unlock() + + if len(replay) > 0 { + onData(replay) + } + return func() { + s.mu.Lock() + delete(s.subs, id) + delete(s.exitSubs, id) + s.mu.Unlock() + } +} + +func (s *session) fanout(data []byte) { + s.mu.Lock() + fns := make([]subscriber, 0, len(s.subs)) + for _, fn := range s.subs { + fns = append(fns, fn) + } + s.mu.Unlock() + for _, fn := range fns { + fn(data) + } +} + +// write sends client keystrokes to the PTY. It is a no-op if no PTY is attached. +func (s *session) write(p []byte) error { + s.mu.Lock() + pty := s.pty + s.mu.Unlock() + if pty == nil { + return errors.New("terminal: no active pty") + } + _, err := pty.Write(p) + return err +} + +func (s *session) resize(rows, cols uint16) error { + s.mu.Lock() + pty := s.pty + s.mu.Unlock() + if pty == nil { + return nil + } + return pty.Resize(rows, cols) +} + +func (s *session) setPTY(p ptyProcess) { + s.mu.Lock() + s.pty = p + s.mu.Unlock() +} + +// close tears the session down: stop re-attaching and kill the PTY. +func (s *session) close() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + pty := s.pty + s.pty = nil + s.mu.Unlock() + if pty != nil { + _ = pty.Close() + } +} + +func (s *session) isClosed() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.closed +} + +func (s *session) isExited() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.exited +} + +// markExited flips the pane to exited and notifies/clears subscribers. +func (s *session) markExited() { + s.mu.Lock() + if s.exited { + s.mu.Unlock() + return + } + s.exited = true + exits := make([]func(), 0, len(s.exitSubs)) + for _, fn := range s.exitSubs { + exits = append(exits, fn) + } + s.exitSubs = map[int]func(){} + s.mu.Unlock() + for _, fn := range exits { + fn() + } +} + +// fail reports an unrecoverable attach error to subscribers as an exit. +func (s *session) fail(reason string) { + s.log.Warn("terminal session failed", "id", s.id, "reason", reason) + s.markExited() +} + +func (s *session) markDone() { s.doneOnce.Do(func() { close(s.done) }) } diff --git a/backend/internal/terminal/session_integration_test.go b/backend/internal/terminal/session_integration_test.go new file mode 100644 index 0000000000..9041d96389 --- /dev/null +++ b/backend/internal/terminal/session_integration_test.go @@ -0,0 +1,52 @@ +package terminal + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// TestSessionStreamsRealTmuxPane attaches a real PTY to a real tmux session and +// asserts output streams back, then that killing the pane stops the session +// without a re-attach storm. Skipped when tmux is unavailable. +func TestSessionStreamsRealTmuxPane(t *testing.T) { + tmuxBin, err := exec.LookPath("tmux") + if err != nil { + t.Skip("tmux unavailable") + } + + name := "ao-term-it-" + strings.ReplaceAll(t.Name(), "/", "-") + mustRun(t, tmuxBin, "new-session", "-d", "-s", name, "/bin/sh") + t.Cleanup(func() { _ = exec.Command(tmuxBin, "kill-session", "-t", "="+name).Run() }) + + rt := tmux.New(tmux.Options{Binary: tmuxBin}) + handle := ports.RuntimeHandle{ID: name} + + s := newSession(name, handle, rt, defaultSpawn, testLogger()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go s.run(ctx) + + var got safeBytes + s.subscribe(got.add, nil) + + // Type a unique marker and expect it echoed back through the PTY. + eventually(t, 3*time.Second, func() bool { return s.write([]byte("echo AO_MARKER_42\n")) == nil }) + eventually(t, 5*time.Second, func() bool { return strings.Contains(got.string(), "AO_MARKER_42") }) + + // Kill the pane: the session must observe it as gone and not re-attach. + mustRun(t, tmuxBin, "kill-session", "-t", "="+name) + eventually(t, 5*time.Second, func() bool { return s.isExited() }) +} + +func mustRun(t *testing.T, name string, args ...string) { + t.Helper() + if out, err := exec.Command(name, args...).CombinedOutput(); err != nil { + t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out) + } +} diff --git a/backend/internal/terminal/session_test.go b/backend/internal/terminal/session_test.go new file mode 100644 index 0000000000..f7b9ddeadc --- /dev/null +++ b/backend/internal/terminal/session_test.go @@ -0,0 +1,138 @@ +package terminal + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func newTestSession(src PTYSource, spawn spawnFunc) *session { + return newSession("t1", ports.RuntimeHandle{ID: "t1"}, src, spawn, testLogger()) +} + +func TestSessionFansOutLiveOutputToSubscribers(t *testing.T) { + src := &fakeSource{} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} + s := newTestSession(src, sp.spawn) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go s.run(ctx) + + var a, b safeBytes + s.subscribe(a.add, nil) + s.subscribe(b.add, nil) + + pty.push([]byte("hello")) + eventually(t, time.Second, func() bool { return a.string() == "hello" && b.string() == "hello" }) +} + +func TestSessionReplaysRingBufferOnSubscribe(t *testing.T) { + src := &fakeSource{} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} + s := newTestSession(src, sp.spawn) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go s.run(ctx) + + pty.push([]byte("scrollback")) + eventually(t, time.Second, func() bool { return len(s.ring.snapshot()) == len("scrollback") }) + + var late safeBytes + s.subscribe(late.add, nil) + eventually(t, time.Second, func() bool { return late.string() == "scrollback" }) +} + +func TestSessionWriteAndResizeReachPTY(t *testing.T) { + src := &fakeSource{} + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} + s := newTestSession(src, sp.spawn) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go s.run(ctx) + + eventually(t, time.Second, func() bool { return s.write([]byte("ls\n")) == nil }) + eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "ls\n" }) + + if err := s.resize(24, 80); err != nil { + t.Fatalf("resize: %v", err) + } + eventually(t, time.Second, func() bool { + rs := pty.resizeCalls() + return len(rs) == 1 && rs[0] == [2]uint16{24, 80} + }) +} + +func TestSessionSkipsReattachOnCleanExit(t *testing.T) { + src := &fakeSource{alive: false} // tmux session gone -> no re-attach + pty := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{pty}} + s := newTestSession(src, sp.spawn) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + exited := make(chan struct{}) + go s.run(ctx) + s.subscribe(func([]byte) {}, func() { close(exited) }) + + pty.Close() // pane ends + select { + case <-exited: + case <-time.After(time.Second): + t.Fatal("expected exit notification after clean pane exit") + } + if got := sp.calls(); got != 1 { + t.Fatalf("expected exactly one attach, got %d", got) + } +} + +func TestSessionReattachesWhileSessionAlive(t *testing.T) { + src := &fakeSource{alive: true} // session still alive -> re-attach on drop + p1, p2 := newFakePTY(), newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{p1, p2}} + s := newTestSession(src, sp.spawn) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go s.run(ctx) + + eventually(t, time.Second, func() bool { return sp.calls() >= 1 }) + p1.Close() // first attach drops + eventually(t, 2*time.Second, func() bool { return sp.calls() >= 2 }) + + // Now the session is gone: the next drop must not re-attach. + src.setAlive(false) + p2.Close() + eventually(t, 2*time.Second, func() bool { return s.isExited() }) +} + +func TestSessionFailsWhenAttachCommandErrors(t *testing.T) { + src := &fakeSource{attachErr: io.ErrUnexpectedEOF} + sp := &fakeSpawner{} + s := newTestSession(src, sp.spawn) + + exited := make(chan struct{}) + s.subscribe(func([]byte) {}, func() { close(exited) }) + + go s.run(context.Background()) + select { + case <-exited: + case <-time.After(time.Second): + t.Fatal("expected exit when attach command fails") + } + if sp.calls() != 0 { + t.Fatalf("spawn should not run when attach command errors, got %d calls", sp.calls()) + } +} From edcc6310379e52ec071aa047d6d90312d16c3cd4 Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Sun, 31 May 2026 19:10:54 +0530 Subject: [PATCH 063/250] feat(httpd): mount terminal-streaming WebSocket at /mux Add the /mux route: httpd performs the WebSocket upgrade (coder/websocket) and adapts the connection to terminal.wsConn via wsjson, then hands it to terminal.Manager.Serve. httpd owns only the upgrade and transport adaptation; all stream logic stays in internal/terminal. The route is mounted outside the per-request Timeout middleware (the connection is long-lived) and is omitted entirely when no manager is wired, so the daemon degrades to no terminal surface rather than failing. New/ NewRouter take the manager; main.go passes nil until commit 3 wires it. mux_test.go drives the real upgrade + wsjson + Serve + creack/pty path with a throwaway shell command, so it needs no tmux. --- backend/go.mod | 4 +- backend/internal/httpd/mux.go | 59 +++++++++++++ backend/internal/httpd/mux_test.go | 115 ++++++++++++++++++++++++++ backend/internal/httpd/router.go | 4 +- backend/internal/httpd/server.go | 8 +- backend/internal/httpd/server_test.go | 8 +- backend/main.go | 2 +- 7 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 backend/internal/httpd/mux.go create mode 100644 backend/internal/httpd/mux_test.go diff --git a/backend/go.mod b/backend/go.mod index b0ac013d26..ae5cb9d500 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,14 +3,14 @@ module github.com/aoagents/agent-orchestrator/backend go 1.25.7 require ( + github.com/coder/websocket v1.8.14 + github.com/creack/pty v1.1.24 github.com/go-chi/chi/v5 v5.1.0 github.com/pressly/goose/v3 v3.27.1 modernc.org/sqlite v1.51.0 ) require ( - github.com/coder/websocket v1.8.14 // indirect - github.com/creack/pty v1.1.24 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.21 // indirect diff --git a/backend/internal/httpd/mux.go b/backend/internal/httpd/mux.go new file mode 100644 index 0000000000..0c17a548b1 --- /dev/null +++ b/backend/internal/httpd/mux.go @@ -0,0 +1,59 @@ +package httpd + +import ( + "context" + "log/slog" + "net/http" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/terminal" +) + +// muxReadLimit caps a single inbound frame. Client→server frames are small +// (keystrokes, resize, control), so a generous 1 MiB is ample headroom while +// still bounding memory per message. +const muxReadLimit = 1 << 20 + +// mountMux registers the long-lived terminal-multiplexing WebSocket at /mux. It +// is intentionally outside the per-request Timeout middleware (the connection is +// long-lived). When mgr is nil the route is not mounted — the daemon simply has +// no terminal surface yet. +func mountMux(r chi.Router, mgr *terminal.Manager, log *slog.Logger) { + if mgr == nil { + return + } + r.Get("/mux", muxHandler(mgr, log)) +} + +// muxHandler upgrades the request to a WebSocket and hands the connection to the +// terminal manager. httpd owns only the upgrade and the transport adaptation; +// all stream logic lives in internal/terminal. +func muxHandler(mgr *terminal.Manager, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // InsecureSkipVerify disables coder/websocket's same-origin check: the + // daemon binds loopback only and the desktop renderer's origin differs + // from the loopback host, mirroring the legacy Node mux server. + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + log.Warn("mux: websocket upgrade failed", "err", err) + return + } + c.SetReadLimit(muxReadLimit) + mgr.Serve(r.Context(), &coderConn{c: c}) + } +} + +// coderConn adapts a coder/websocket connection to terminal.wsConn. JSON framing +// uses wsjson (text messages); Ping is a control frame; Close sends a normal +// closure. +type coderConn struct{ c *websocket.Conn } + +func (a *coderConn) ReadJSON(ctx context.Context, v any) error { return wsjson.Read(ctx, a.c, v) } +func (a *coderConn) WriteJSON(ctx context.Context, v any) error { return wsjson.Write(ctx, a.c, v) } +func (a *coderConn) Ping(ctx context.Context) error { return a.c.Ping(ctx) } +func (a *coderConn) Close(reason string) error { + return a.c.Close(websocket.StatusNormalClosure, reason) +} diff --git a/backend/internal/httpd/mux_test.go b/backend/internal/httpd/mux_test.go new file mode 100644 index 0000000000..b334cf8b7f --- /dev/null +++ b/backend/internal/httpd/mux_test.go @@ -0,0 +1,115 @@ +package httpd + +import ( + "context" + "encoding/base64" + "net/http/httptest" + "runtime" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/terminal" +) + +// stubSource attaches a throwaway shell command instead of a real tmux pane, so +// the /mux path exercises the genuine upgrade + wsjson + Serve + creack/pty flow +// without needing tmux. IsAlive=false means the pane is treated as gone once the +// command exits (no re-attach). +type stubSource struct{ argv []string } + +func (s stubSource) AttachCommand(ports.RuntimeHandle) ([]string, error) { return s.argv, nil } +func (stubSource) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { + return false, nil +} + +type muxFrame struct { + Ch string `json:"ch"` + ID string `json:"id"` + Type string `json:"type"` + Data string `json:"data"` +} + +func dialMux(t *testing.T, mgr *terminal.Manager) (*websocket.Conn, func()) { + t.Helper() + router := NewRouter(config.Config{}, discardLogger(), mgr) + ts := httptest.NewServer(router) + url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/mux" + + c, _, err := websocket.Dial(context.Background(), url, nil) + if err != nil { + ts.Close() + t.Fatalf("dial /mux: %v", err) + } + return c, func() { + _ = c.Close(websocket.StatusNormalClosure, "test done") + ts.Close() + } +} + +func readFrame(t *testing.T, c *websocket.Conn, ch, typ string, d time.Duration) muxFrame { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), d) + defer cancel() + for { + var f muxFrame + if err := wsjson.Read(ctx, c, &f); err != nil { + t.Fatalf("waiting for %s/%s: %v", ch, typ, err) + } + if f.Ch == ch && f.Type == typ { + return f + } + } +} + +func TestMuxUpgradeStreamsTerminal(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY spawning not supported on Windows") + } + mgr := terminal.NewManager( + stubSource{argv: []string{"/bin/sh", "-c", "printf MUXOK; exit 0"}}, + nil, discardLogger(), + ) + defer mgr.Close() + + c, done := dialMux(t, mgr) + defer done() + + ctx := context.Background() + if err := wsjson.Write(ctx, c, muxFrame{Ch: "terminal", ID: "t1", Type: "open"}); err != nil { + t.Fatalf("write open: %v", err) + } + + readFrame(t, c, "terminal", "opened", 3*time.Second) + + data := readFrame(t, c, "terminal", "data", 5*time.Second) + got, _ := base64.StdEncoding.DecodeString(data.Data) + if !strings.Contains(string(got), "MUXOK") { + t.Fatalf("streamed data = %q, want it to contain MUXOK", got) + } + + // The shell exits; the pane is reported gone (IsAlive=false) so we get exited. + readFrame(t, c, "terminal", "exited", 5*time.Second) +} + +func TestMuxSystemPingPong(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY spawning not supported on Windows") + } + mgr := terminal.NewManager(stubSource{argv: []string{"/bin/sh"}}, nil, discardLogger()) + defer mgr.Close() + + c, done := dialMux(t, mgr) + defer done() + + ctx := context.Background() + if err := wsjson.Write(ctx, c, map[string]string{"ch": "system", "type": "ping"}); err != nil { + t.Fatalf("write ping: %v", err) + } + readFrame(t, c, "system", "pong", 3*time.Second) +} diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 6e078b8df0..744ecad96d 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) // NewRouter builds the root router with the standard middleware stack and the @@ -29,7 +30,7 @@ import ( // SSE (/events) or WebSocket (/mux) surfaces, nor the always-must-answer health // probes. It is therefore applied per-surface when those subrouters are mounted // in Phase 1b; cfg.RequestTimeout carries the value through to that point. -func NewRouter(cfg config.Config, log *slog.Logger) chi.Router { +func NewRouter(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) chi.Router { r := chi.NewRouter() r.Use(middleware.Recoverer) @@ -38,6 +39,7 @@ func NewRouter(cfg config.Config, log *slog.Logger) chi.Router { r.Use(middleware.RealIP) mountHealth(r) + mountMux(r, termMgr, log) return r } diff --git a/backend/internal/httpd/server.go b/backend/internal/httpd/server.go index f42ed88aa8..506f78b5c6 100644 --- a/backend/internal/httpd/server.go +++ b/backend/internal/httpd/server.go @@ -12,6 +12,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) // Server is the daemon's HTTP server together with its lifecycle: bind the @@ -26,8 +27,9 @@ type Server struct { // New constructs a Server and binds the listener immediately so a port // conflict fails fast — before any running.json is written. The caller owns -// the returned Server's lifecycle via Run. -func New(cfg config.Config, log *slog.Logger) (*Server, error) { +// the returned Server's lifecycle via Run. termMgr may be nil, in which case +// the /mux terminal surface is not mounted. +func New(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) (*Server, error) { ln, err := net.Listen("tcp", cfg.Addr()) if err != nil { return nil, fmt.Errorf("bind %s (is a daemon already running?): %w", cfg.Addr(), err) @@ -38,7 +40,7 @@ func New(cfg config.Config, log *slog.Logger) (*Server, error) { log: log, listen: ln, http: &http.Server{ - Handler: NewRouter(cfg, log), + Handler: NewRouter(cfg, log, termMgr), // ReadHeaderTimeout guards against slow-loris even on loopback; // per-request body/handler timeouts are applied per-surface. ReadHeaderTimeout: 10 * time.Second, diff --git a/backend/internal/httpd/server_test.go b/backend/internal/httpd/server_test.go index 2570397fca..39270d1ca0 100644 --- a/backend/internal/httpd/server_test.go +++ b/backend/internal/httpd/server_test.go @@ -19,7 +19,7 @@ func discardLogger() *slog.Logger { } func TestHealthProbes(t *testing.T) { - router := NewRouter(config.Config{}, discardLogger()) + router := NewRouter(config.Config{}, discardLogger(), nil) srv := httptest.NewServer(router) defer srv.Close() @@ -51,7 +51,7 @@ func TestServerLifecycle(t *testing.T) { RunFilePath: runPath, } - srv, err := New(cfg, discardLogger()) + srv, err := New(cfg, discardLogger(), nil) if err != nil { t.Fatalf("New: %v", err) } @@ -116,7 +116,7 @@ func waitForHealth(t *testing.T, base string) { func TestNewFailsOnPortConflict(t *testing.T) { cfg := config.Config{Host: "127.0.0.1", Port: 0, RunFilePath: filepath.Join(t.TempDir(), "r.json")} - first, err := New(cfg, discardLogger()) + first, err := New(cfg, discardLogger(), nil) if err != nil { t.Fatalf("first New: %v", err) } @@ -124,7 +124,7 @@ func TestNewFailsOnPortConflict(t *testing.T) { // Re-bind the exact port the first server took. conflict := config.Config{Host: "127.0.0.1", Port: first.boundPort(), RunFilePath: cfg.RunFilePath} - if _, err := New(conflict, discardLogger()); err == nil { + if _, err := New(conflict, discardLogger(), nil); err == nil { t.Fatal("New on an already-bound port = nil error, want bind failure") } } diff --git a/backend/main.go b/backend/main.go index 60d9e26e38..7840285fc7 100644 --- a/backend/main.go +++ b/backend/main.go @@ -42,7 +42,7 @@ func run() error { return fmt.Errorf("daemon already running (pid %d, port %d); refusing to start", live.PID, live.Port) } - srv, err := httpd.New(cfg, log) + srv, err := httpd.New(cfg, log, nil) if err != nil { return err } From 4d90d59bf080cb1cbc25af502451b33d96c54d2a Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Sun, 31 May 2026 19:12:05 +0530 Subject: [PATCH 064/250] feat(backend): wire terminal manager into the daemon at /mux Construct the tmux runtime and a terminal.Manager fed by the CDC broadcaster, and hand it to httpd.New so the /mux WebSocket surface goes live. httpd.New now runs after the CDC substrate so the broadcaster exists when the manager subscribes; the listener still binds before running.json is written, preserving fail-fast on port conflict. The manager is closed on shutdown alongside the CDC pipeline and lifecycle stack. --- backend/internal/terminal/manager.go | 4 ++-- backend/internal/terminal/manager_test.go | 2 +- backend/internal/terminal/protocol.go | 4 ++-- backend/internal/terminal/protocol_test.go | 4 ++-- backend/main.go | 20 +++++++++++++++----- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/backend/internal/terminal/manager.go b/backend/internal/terminal/manager.go index 068f36bf25..986d9d921f 100644 --- a/backend/internal/terminal/manager.go +++ b/backend/internal/terminal/manager.go @@ -284,9 +284,9 @@ func (c *connState) handleSubscribe() { Type: msgSnapshot, Session: &sessionUpdate{ Seq: e.Seq, + ProjectID: e.ProjectID, SessionID: e.SessionID, - EventType: e.EventType, - Revision: e.Revision, + EventType: string(e.Type), }, }) }) diff --git a/backend/internal/terminal/manager_test.go b/backend/internal/terminal/manager_test.go index 6169c34fd4..71187ed7b1 100644 --- a/backend/internal/terminal/manager_test.go +++ b/backend/internal/terminal/manager_test.go @@ -127,7 +127,7 @@ func TestServeForwardsSessionChannelFromCDC(t *testing.T) { conn.in <- clientMsg{Ch: chSubscribe, Type: msgSubscribe} // Give the subscription time to register before publishing. eventually(t, time.Second, func() bool { - bc.Publish(cdc.Event{Seq: 9, SessionID: "s1", EventType: "session_updated", Revision: 4}) + bc.Publish(cdc.Event{Seq: 9, ProjectID: "p1", SessionID: "s1", Type: cdc.EventSessionUpdated}) select { case m := <-conn.out: return m.Ch == chSessions && m.Session != nil && m.Session.Seq == 9 diff --git a/backend/internal/terminal/protocol.go b/backend/internal/terminal/protocol.go index 472e03872a..31a47999a9 100644 --- a/backend/internal/terminal/protocol.go +++ b/backend/internal/terminal/protocol.go @@ -65,7 +65,7 @@ type serverMsg struct { // change_log payload blob; the client refetches detail over the REST surface. type sessionUpdate struct { Seq int64 `json:"seq"` - SessionID string `json:"sessionId"` + ProjectID string `json:"projectId"` + SessionID string `json:"sessionId,omitempty"` EventType string `json:"eventType"` - Revision int64 `json:"revision"` } diff --git a/backend/internal/terminal/protocol_test.go b/backend/internal/terminal/protocol_test.go index ef521ed8ab..e42170d5d2 100644 --- a/backend/internal/terminal/protocol_test.go +++ b/backend/internal/terminal/protocol_test.go @@ -33,7 +33,7 @@ func TestServerMsgSessionFrameWireShape(t *testing.T) { Ch: chSessions, Type: msgSnapshot, Session: &sessionUpdate{ - Seq: 7, SessionID: "s1", EventType: "session_updated", Revision: 3, + Seq: 7, ProjectID: "p1", SessionID: "s1", EventType: "session_updated", }, } raw, err := json.Marshal(msg) @@ -41,7 +41,7 @@ func TestServerMsgSessionFrameWireShape(t *testing.T) { t.Fatalf("marshal: %v", err) } // Golden wire shape the client depends on. - want := `{"ch":"sessions","type":"snapshot","session":{"seq":7,"sessionId":"s1","eventType":"session_updated","revision":3}}` + want := `{"ch":"sessions","type":"snapshot","session":{"seq":7,"projectId":"p1","sessionId":"s1","eventType":"session_updated"}}` if string(raw) != want { t.Fatalf("wire shape:\n got %s\nwant %s", raw, want) } diff --git a/backend/main.go b/backend/main.go index 7840285fc7..950ea4e352 100644 --- a/backend/main.go +++ b/backend/main.go @@ -12,10 +12,12 @@ import ( "os/signal" "syscall" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) func main() { @@ -42,11 +44,6 @@ func run() error { return fmt.Errorf("daemon already running (pid %d, port %d); refusing to start", live.PID, live.Port) } - srv, err := httpd.New(cfg, log, nil) - if err != nil { - return err - } - // Open the durable store and bring up the CDC substrate: the DB triggers // capture changes into change_log, the poller tails it, and the broadcaster // fans events out to the SSE transport. The LCM/Session Manager and the HTTP @@ -70,6 +67,19 @@ func run() error { return err } + // Terminal streaming: the tmux runtime supplies the PTY-attach command and + // liveness; the CDC broadcaster feeds the session-state channel. The manager + // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow + // through the CDC change_log — only session-state events do. + runtimeAdapter := tmux.New(tmux.Options{}) + termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) + defer termMgr.Close() + + srv, err := httpd.New(cfg, log, termMgr) + if err != nil { + return err + } + // Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE // timer). This makes the write path live end-to-end: LCM write -> store -> DB // trigger -> change_log -> poller -> broadcaster. The collaborators it needs From cd84d94ee42ab6d25d81e72f9ee5cad4f0629047 Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Sun, 31 May 2026 19:39:40 +0530 Subject: [PATCH 065/250] fix(terminal): deliver ring replay and fanout atomically under session lock A new subscriber could receive the same PTY bytes twice: the ring snapshot was taken under s.mu but replayed after unlock, while copyOut appended to the ring and fanned out as two separate lock acquisitions. A chunk appended before the snapshot could then be fanned out after the replay, delivering it in both. The same gap let an exited frame overtake the replay. Make append+fanout one atomic step under s.mu (deliver) and replay the snapshot before releasing the lock in subscribe, so the two critical sections fully serialize and each chunk reaches a subscriber exactly once. Co-Authored-By: Claude Opus 4.7 --- backend/internal/terminal/session.go | 32 ++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/backend/internal/terminal/session.go b/backend/internal/terminal/session.go index 658410b004..d29d2f82b6 100644 --- a/backend/internal/terminal/session.go +++ b/backend/internal/terminal/session.go @@ -143,8 +143,7 @@ func (s *session) copyOut(p ptyProcess) { if n > 0 { chunk := make([]byte, n) copy(chunk, buf[:n]) - s.ring.append(chunk) - s.fanout(chunk) + s.deliver(chunk) } if err != nil { return @@ -197,12 +196,20 @@ func (s *session) subscribe(onData subscriber, onExit func()) (unsubscribe func( if onExit != nil { s.exitSubs[id] = onExit } + // Deliver the replay while still holding s.mu. deliver (the copyOut path) + // also takes s.mu around append+fanout, so the two are fully serialized: a + // chunk is either in this snapshot (and was fanned out before this + // subscriber registered) or it is fanned out after this returns, never both. + // Releasing the lock before replaying would let a chunk land in both the + // snapshot and a concurrent fanout, delivering it twice (or let an exit + // frame overtake the replay). onData is a non-blocking enqueue, so holding + // the lock across it cannot deadlock. replay := s.ring.snapshot() - s.mu.Unlock() - if len(replay) > 0 { onData(replay) } + s.mu.Unlock() + return func() { s.mu.Lock() delete(s.subs, id) @@ -211,15 +218,18 @@ func (s *session) subscribe(onData subscriber, onExit func()) (unsubscribe func( } } -func (s *session) fanout(data []byte) { +// deliver appends a chunk to the ring and fans it out to current subscribers as +// one atomic step under s.mu. Holding the lock across both is what lets +// subscribe (which snapshots + replays under the same lock) guarantee +// exactly-once delivery: append+fanout and register+snapshot+replay can never +// interleave. Each fn is a non-blocking enqueue, so the lock is held only +// briefly and cannot deadlock. +func (s *session) deliver(chunk []byte) { s.mu.Lock() - fns := make([]subscriber, 0, len(s.subs)) + defer s.mu.Unlock() + s.ring.append(chunk) for _, fn := range s.subs { - fns = append(fns, fn) - } - s.mu.Unlock() - for _, fn := range fns { - fn(data) + fn(chunk) } } From 27fb82dbebeba99fe127a90244e46c3e0c815565 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 31 May 2026 20:12:38 +0530 Subject: [PATCH 066/250] feat(backend): wire Session Manager into the daemon (real tmux + gitworktree, stub Agent) Constructs a live *session.Manager in main alongside the LCM, sharing the exact same SessionStore + LCM dependencies the lifecycle stack already holds. Refactor: storeAdapter moves from package main to a new internal package, wiring.Adapter, so the daemon's composition root and any in-process integration tests can share a single bridge. Stubbed for now: ports.Agent has no production adapter on main; a loud *noopAgent returns sentinel AO_AGENT_HARNESS_NOT_WIRED and logs a warning once on first call, so a future Spawn through this lane fails at the runtime layer with a clear breadcrumb rather than starting a broken session quietly. ports.Notifier and ports.AgentMessenger remain stubbed alongside the LCM. Co-Authored-By: Claude Opus 4.7 --- .../internal/storage/sqlite/wiring/adapter.go | 107 +++++++++++ backend/lifecycle_wiring.go | 181 ++++++++++-------- backend/main.go | 26 +-- backend/wiring_test.go | 89 ++++++++- 4 files changed, 305 insertions(+), 98 deletions(-) create mode 100644 backend/internal/storage/sqlite/wiring/adapter.go diff --git a/backend/internal/storage/sqlite/wiring/adapter.go b/backend/internal/storage/sqlite/wiring/adapter.go new file mode 100644 index 0000000000..8a8d017dd1 --- /dev/null +++ b/backend/internal/storage/sqlite/wiring/adapter.go @@ -0,0 +1,107 @@ +// Package wiring bridges *sqlite.Store to the engine's outbound ports. It +// embeds the store (so the SessionStore reads/writes and PRWriter.RecentCheckStatuses +// promote directly) and supplies the PR conversions plus the PRFacts read-model +// that drives the derived display status. +// +// The adapter lives in its own package so the daemon's composition root and any +// in-process integration tests (e.g. backend/internal/integration) can share the +// same bridge instead of redefining it. +package wiring + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// Adapter wraps *sqlite.Store and implements ports.SessionStore + ports.PRWriter. +// The embedded *sqlite.Store promotes CreateSession / UpdateSession / GetSession +// / ListSessions / ListAllSessions and RecentCheckStatuses verbatim; the two +// methods defined here are the ones that need shape translation between the port +// types and the sqlite row types. +type Adapter struct{ *sqlite.Store } + +var ( + _ ports.SessionStore = Adapter{} + _ ports.PRWriter = Adapter{} +) + +// PRFactsForSession picks the PR that drives display status — the most-recently +// updated non-closed PR, else the most recent — and folds in whether it has +// unresolved review comments. +func (a Adapter) PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) { + rows, err := a.Store.ListPRsBySession(ctx, string(id)) // newest first + if err != nil { + return domain.PRFacts{}, err + } + if len(rows) == 0 { + return domain.PRFacts{}, nil + } + pick := rows[0] + for _, r := range rows { + if r.State == "draft" || r.State == "open" { + pick = r + break + } + } + facts := domain.PRFacts{ + URL: pick.URL, Number: int(pick.Number), Exists: true, + Draft: pick.State == "draft", Merged: pick.State == "merged", Closed: pick.State == "closed", + CI: domain.CIState(pick.CIState), + Review: domain.ReviewDecision(pick.ReviewDecision), + Mergeability: domain.Mergeability(pick.Mergeability), + } + comments, err := a.Store.ListPRComments(ctx, pick.URL) + if err != nil { + return domain.PRFacts{}, err + } + for _, c := range comments { + if !c.Resolved { + facts.ReviewComments = true + break + } + } + return facts, nil +} + +func (a Adapter) WritePR(ctx context.Context, pr ports.PRRow, checks []ports.PRCheckRow, comments []ports.PRComment) error { + row := sqlite.PRRow{ + URL: pr.URL, SessionID: pr.SessionID, Number: int64(pr.Number), + State: prState(pr), + ReviewDecision: string(pr.Review), + CIState: string(pr.CI), + Mergeability: string(pr.Mergeability), + UpdatedAt: pr.UpdatedAt, + } + checkRows := make([]sqlite.PRCheckRow, len(checks)) + for i, c := range checks { + checkRows[i] = sqlite.PRCheckRow{ + PRURL: c.PRURL, Name: c.Name, CommitHash: c.CommitHash, + Status: c.Status, URL: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, + } + } + commentRows := make([]sqlite.PRCommentRow, len(comments)) + for i, c := range comments { + commentRows[i] = sqlite.PRCommentRow{ + PRURL: pr.URL, CommentID: c.ID, Author: c.Author, File: c.File, + Line: int64(c.Line), Body: c.Body, Resolved: c.Resolved, CreatedAt: c.CreatedAt, + } + } + return a.Store.WritePRObservation(ctx, row, checkRows, commentRows) +} + +// prState collapses the PR's bools into the single pr.state column value. +func prState(r ports.PRRow) string { + switch { + case r.Merged: + return "merged" + case r.Closed: + return "closed" + case r.Draft: + return "draft" + default: + return "open" + } +} diff --git a/backend/lifecycle_wiring.go b/backend/lifecycle_wiring.go index d736d65367..3ecab74b76 100644 --- a/backend/lifecycle_wiring.go +++ b/backend/lifecycle_wiring.go @@ -3,19 +3,29 @@ package main import ( "context" "log/slog" + "path/filepath" + "sync" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" + "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/session" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/wiring" ) // lifecycleStack owns the running LCM + reaper. The LCM is the sole writer of // canonical transitions; the reaper is the OBSERVE-layer timer that probes live -// runtimes and reports facts back through it. +// runtimes and reports facts back through it. Adapter is exposed so the Session +// Manager construction in startSession can plug the same SessionStore + PRWriter +// instance the LCM already holds. type lifecycleStack struct { LCM *lifecycle.Manager + Adapter wiring.Adapter reaperDone <-chan struct{} } @@ -28,103 +38,59 @@ type lifecycleStack struct { // - reaper.MapRegistry{} — empty runtime registry, so the reaper ticks // escalations but probes nothing until the runtime plugins exist. func startLifecycle(ctx context.Context, store *sqlite.Store, logger *slog.Logger) (*lifecycleStack, error) { - a := storeAdapter{store} + a := wiring.Adapter{Store: store} lcm := lifecycle.New(a, a, noopNotifier{}, noopMessenger{}) rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Logger: logger}) - return &lifecycleStack{LCM: lcm, reaperDone: rp.Start(ctx)}, nil + return &lifecycleStack{LCM: lcm, Adapter: a, reaperDone: rp.Start(ctx)}, nil } // Stop waits for the reaper goroutine to exit (the caller must have cancelled the // ctx passed to startLifecycle). func (l *lifecycleStack) Stop() { <-l.reaperDone } -// storeAdapter bridges *sqlite.Store to the engine's ports. It embeds the store -// (so CreateSession/UpdateSession/GetSession/ListSessions/ListAllSessions and -// RecentCheckStatuses promote directly) and adds the PR conversions + the -// PRFacts read-model the display status needs. -type storeAdapter struct{ *sqlite.Store } +// sessionStack holds the daemon's live Session Manager. It mirrors +// lifecycleStack's shape so a future teardown hook (worktree drain, runtime +// shutdown) has a place to attach. +type sessionStack struct { + SM *session.Manager +} -var ( - _ ports.SessionStore = storeAdapter{} - _ ports.PRWriter = storeAdapter{} -) +// startSession constructs the Session Manager over the real tmux Runtime and +// gitworktree Workspace, the LCM and adapter created by startLifecycle, and the +// loud-stub Agent / Messenger / Notifier ports that have no production +// implementations yet. It does NOT mount any HTTP routes — those come with the +// daemon lane (#10). Returning the SM here lets main hold the wired-but-quiet +// instance so future route wiring is a one-line plumb-through. +func startSession(_ context.Context, cfg config.Config, ls *lifecycleStack, log *slog.Logger) (*sessionStack, error) { + runtime := tmux.New(tmux.Options{}) -// PRFactsForSession picks the PR that drives display status — the most-recently -// updated non-closed PR, else the most recent — and folds in whether it has -// unresolved review comments. -func (a storeAdapter) PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) { - rows, err := a.Store.ListPRsBySession(ctx, string(id)) // newest first - if err != nil { - return domain.PRFacts{}, err - } - if len(rows) == 0 { - return domain.PRFacts{}, nil - } - pick := rows[0] - for _, r := range rows { - if r.State == "draft" || r.State == "open" { - pick = r - break - } - } - facts := domain.PRFacts{ - URL: pick.URL, Number: int(pick.Number), Exists: true, - Draft: pick.State == "draft", Merged: pick.State == "merged", Closed: pick.State == "closed", - CI: domain.CIState(pick.CIState), - Review: domain.ReviewDecision(pick.ReviewDecision), - Mergeability: domain.Mergeability(pick.Mergeability), - } - comments, err := a.Store.ListPRComments(ctx, pick.URL) + ws, err := gitworktree.New(gitworktree.Options{ + // ManagedRoot is the directory under which per-session worktrees are + // materialised. Co-located with the SQLite DB so a single AO_DATA_DIR + // override moves all durable per-user state together. + ManagedRoot: filepath.Join(cfg.DataDir, "worktrees"), + // An empty resolver fails every project lookup with a clear + // `no repo configured for project %q` error. That's the right loud + // failure until the projects table feeds repo paths into the resolver + // — hard-coding a single repo here would silently misroute spawns. + RepoResolver: gitworktree.StaticRepoResolver{}, + }) if err != nil { - return domain.PRFacts{}, err - } - for _, c := range comments { - if !c.Resolved { - facts.ReviewComments = true - break - } + return nil, err } - return facts, nil -} -func (a storeAdapter) WritePR(ctx context.Context, pr ports.PRRow, checks []ports.PRCheckRow, comments []ports.PRComment) error { - row := sqlite.PRRow{ - URL: pr.URL, SessionID: pr.SessionID, Number: int64(pr.Number), - State: prState(pr), - ReviewDecision: string(pr.Review), - CIState: string(pr.CI), - Mergeability: string(pr.Mergeability), - UpdatedAt: pr.UpdatedAt, - } - checkRows := make([]sqlite.PRCheckRow, len(checks)) - for i, c := range checks { - checkRows[i] = sqlite.PRCheckRow{ - PRURL: c.PRURL, Name: c.Name, CommitHash: c.CommitHash, - Status: c.Status, URL: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, - } - } - commentRows := make([]sqlite.PRCommentRow, len(comments)) - for i, c := range comments { - commentRows[i] = sqlite.PRCommentRow{ - PRURL: pr.URL, CommentID: c.ID, Author: c.Author, File: c.File, - Line: int64(c.Line), Body: c.Body, Resolved: c.Resolved, CreatedAt: c.CreatedAt, - } - } - return a.Store.WritePRObservation(ctx, row, checkRows, commentRows) -} + agent := newNoopAgent(log) -// prState collapses the PR's bools into the single pr.state column value. -func prState(r ports.PRRow) string { - switch { - case r.Merged: - return "merged" - case r.Closed: - return "closed" - case r.Draft: - return "draft" - default: - return "open" - } + sm := session.New(session.Deps{ + Runtime: runtime, + Agent: agent, + Workspace: ws, + Store: ls.Adapter, + Messenger: noopMessenger{}, + Lifecycle: ls.LCM, + }) + + return &sessionStack{SM: sm}, nil } // noopNotifier / noopMessenger are TEMPORARY stubs (see startLifecycle): the @@ -137,3 +103,50 @@ func (noopNotifier) Notify(context.Context, ports.Event) error { return nil } type noopMessenger struct{} func (noopMessenger) Send(context.Context, domain.SessionID, string) error { return nil } + +// agentNotWiredSentinel is the launch / restore command (and env-var key) +// noopAgent returns. tmux will try to exec a binary named exactly this and fail +// fast, so a Spawn against the loud stub surfaces a clear runtime error rather +// than starting a quiet, broken session. +const agentNotWiredSentinel = "AO_AGENT_HARNESS_NOT_WIRED" + +// noopAgent is a loud stub for ports.Agent. There is no production Agent +// adapter on main yet; rather than panic at construction, this struct lets the +// daemon stand up the Session Manager, then logs a single warning the first +// time any SM call route through it and returns sentinel commands that make +// the runtime layer fail loudly. +type noopAgent struct { + log *slog.Logger + once *sync.Once +} + +var _ ports.Agent = (*noopAgent)(nil) + +func newNoopAgent(log *slog.Logger) *noopAgent { + return &noopAgent{log: log, once: &sync.Once{}} +} + +func (n *noopAgent) warn() { + n.once.Do(func() { + n.log.Warn( + "agent harness not wired: Spawn/Restore will fail at the runtime layer until a ports.Agent adapter is built", + "sentinel", agentNotWiredSentinel, + "next_step", "implement a per-harness ports.Agent adapter and plug it into startSession", + ) + }) +} + +func (n *noopAgent) GetLaunchCommand(ports.AgentConfig) string { + n.warn() + return agentNotWiredSentinel +} + +func (n *noopAgent) GetEnvironment(ports.AgentConfig) map[string]string { + n.warn() + return map[string]string{agentNotWiredSentinel: "1"} +} + +func (n *noopAgent) GetRestoreCommand(string) string { + n.warn() + return agentNotWiredSentinel +} diff --git a/backend/main.go b/backend/main.go index 60d9e26e38..b714e69aca 100644 --- a/backend/main.go +++ b/backend/main.go @@ -72,23 +72,25 @@ func run() error { // Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE // timer). This makes the write path live end-to-end: LCM write -> store -> DB - // trigger -> change_log -> poller -> broadcaster. The collaborators it needs - // that don't yet have production implementations (Notifier, AgentMessenger, - // runtime registry) are stubbed in lifecycle_wiring.go with TODO markers. - // - // NOT wired here yet — both await collaborators the daemon lane owns: - // - Session Manager: session.New needs Runtime/Agent/Workspace plugins to - // construct. Stubbing them would make Spawn a silent no-op (a footgun), - // so it's deferred rather than faked. The LCM already exposes the read - // surface (RunningSessions) the SM would wrap. - // - HTTP API routes: httpd.New takes no SM/LCM today; surfacing the store - // over HTTP needs a constructor signature change + handlers, tracked with - // the SM work since the routes call into it. + // trigger -> change_log -> poller -> broadcaster. lcStack, err := startLifecycle(ctx, store, log) if err != nil { return err } + // Bring up the Session Manager. Runtime (tmux) and Workspace (gitworktree) + // are real on main; ports.Agent has no production adapter yet, so a loud + // stub returns a sentinel command that makes any Spawn fail at the runtime + // layer rather than start a broken session quietly. Notifier and + // AgentMessenger remain stubbed alongside the LCM until their multiplexers + // land. No HTTP routes wire to this yet — the daemon lane (#10) owns API + // surfacing — so we hold the SM in a local until it does. + sStack, err := startSession(ctx, cfg, lcStack, log) + if err != nil { + return err + } + _ = sStack + runErr := srv.Run(ctx) // Shut the background goroutines down in order: cancel the context FIRST so diff --git a/backend/wiring_test.go b/backend/wiring_test.go index 74b314b057..14bb3b4cc3 100644 --- a/backend/wiring_test.go +++ b/backend/wiring_test.go @@ -2,20 +2,27 @@ package main import ( "context" + "io" + "log/slog" + "reflect" "sync" "testing" "time" + "unsafe" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/session" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/wiring" ) // TestWiring_WriteFlowsToBroadcaster exercises the real boot path end to end: // a lifecycle write -> sqlite -> DB trigger -> change_log -> CDC poller -> -// broadcaster, through the production storeAdapter and cdcSource. +// broadcaster, through the production wiring.Adapter and cdcSource. func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { ctx := context.Background() store, err := sqlite.Open(t.TempDir()) @@ -24,7 +31,7 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { } defer store.Close() - a := storeAdapter{store} + a := wiring.Adapter{Store: store} lcm := lifecycle.New(a, a, noopNotifier{}, noopMessenger{}) bcast := cdc.NewBroadcaster() @@ -69,3 +76,81 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { t.Fatalf("expected a change_log event for %s to reach the broadcaster, got %d events", rec.ID, len(got)) } } + +// TestWiring_SessionManagerSharesLifecycleStoreAndLCM verifies that startSession +// constructs an SM whose Store and Lifecycle dependencies are the exact same +// values the LCM holds: a single canonical-store + LCM pair, not two parallel +// stacks that would diverge under concurrent writes. The brief constraint +// forbids modifying session/manager.go to add accessors, so the assertion +// reaches into the unexported fields via reflect + unsafe — scoped to the test +// and isolated in inspectSessionDeps. +func TestWiring_SessionManagerSharesLifecycleStoreAndLCM(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + // Registered first so it runs LAST (after the reaper has drained). + t.Cleanup(func() { _ = store.Close() }) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + cfg := config.Config{DataDir: t.TempDir()} + + lcStack, err := startLifecycle(ctx, store, log) + if err != nil { + t.Fatal(err) + } + // lcStack.Stop blocks on the reaper goroutine, which only exits once its + // ctx is cancelled. Production main.go calls stop() before lcStack.Stop() + // for the same reason — same ordering here. + t.Cleanup(func() { + cancel() + lcStack.Stop() + }) + + sStack, err := startSession(ctx, cfg, lcStack, log) + if err != nil { + t.Fatal(err) + } + if sStack == nil || sStack.SM == nil { + t.Fatal("startSession returned nil Session Manager") + } + + gotStore, gotLCM := inspectSessionDeps(t, sStack.SM) + + // Store should be the exact wiring.Adapter the LCM was constructed with. + gotAdapter, ok := gotStore.(wiring.Adapter) + if !ok { + t.Fatalf("SM.store is %T, want wiring.Adapter", gotStore) + } + if gotAdapter.Store != lcStack.Adapter.Store { + t.Fatalf("SM.store wraps a different *sqlite.Store than lcStack.Adapter") + } + + // Lifecycle should be the exact *lifecycle.Manager pointer from startLifecycle. + gotLCMPtr, ok := gotLCM.(*lifecycle.Manager) + if !ok { + t.Fatalf("SM.lcm is %T, want *lifecycle.Manager", gotLCM) + } + if gotLCMPtr != lcStack.LCM { + t.Fatalf("SM.lcm pointer (%p) differs from lcStack.LCM (%p)", gotLCMPtr, lcStack.LCM) + } +} + +// inspectSessionDeps reads session.Manager's unexported store and lcm fields. +// The brief forbids modifying session/manager.go to expose them; we settle for +// reflect + unsafe scoped to this one test helper. If the field names change +// upstream, the type assertion (and this helper) is the only place to touch. +func inspectSessionDeps(t *testing.T, sm *session.Manager) (store any, lcm any) { + t.Helper() + v := reflect.ValueOf(sm).Elem() + storeField := v.FieldByName("store") + lcmField := v.FieldByName("lcm") + if !storeField.IsValid() || !lcmField.IsValid() { + t.Fatalf("session.Manager fields renamed: store.IsValid=%v lcm.IsValid=%v — update inspectSessionDeps", storeField.IsValid(), lcmField.IsValid()) + } + storeVal := reflect.NewAt(storeField.Type(), unsafe.Pointer(storeField.UnsafeAddr())).Elem() + lcmVal := reflect.NewAt(lcmField.Type(), unsafe.Pointer(lcmField.UnsafeAddr())).Elem() + return storeVal.Interface(), lcmVal.Interface() +} From c1b9e7ec1f0b8f369fc4a78f1198524268365849 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 31 May 2026 20:15:46 +0530 Subject: [PATCH 067/250] chore(backend): name startSession's ctx param for forward use Renames the unused context.Context parameter from `_` to `ctx` so the parameter name is already in place when a future plugin constructor needs to honor cancellation (tmux/gitworktree are synchronous today). Co-Authored-By: Claude Opus 4.7 --- backend/lifecycle_wiring.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/lifecycle_wiring.go b/backend/lifecycle_wiring.go index 3ecab74b76..8aecd47057 100644 --- a/backend/lifecycle_wiring.go +++ b/backend/lifecycle_wiring.go @@ -61,7 +61,8 @@ type sessionStack struct { // implementations yet. It does NOT mount any HTTP routes — those come with the // daemon lane (#10). Returning the SM here lets main hold the wired-but-quiet // instance so future route wiring is a one-line plumb-through. -func startSession(_ context.Context, cfg config.Config, ls *lifecycleStack, log *slog.Logger) (*sessionStack, error) { +func startSession(ctx context.Context, cfg config.Config, ls *lifecycleStack, log *slog.Logger) (*sessionStack, error) { + _ = ctx // reserved for future ctx-aware plugin construction; today's tmux/gitworktree constructors are synchronous. runtime := tmux.New(tmux.Options{}) ws, err := gitworktree.New(gitworktree.Options{ From 7b9a9f5962bb5f6ff5dbbb28ab5ec9a17583cf01 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 31 May 2026 20:25:56 +0530 Subject: [PATCH 068/250] fix(backend): drain reaper + cdc poller when startSession fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If startSession returned an error, run() returned immediately and the reaper + cdc poller goroutines kept running while defer store.Close() fired — a data race against the SQLite handle. Mirror the bottom-of-run shutdown sequence on the error path (cancel ctx, drain reaper, drain poller) so both goroutines have exited before the store is closed. The explicit-not-defer ordering is the same the existing post-srv.Run shutdown block uses; piling on more defers would hit the LIFO trap the same comment already warns about. Reported by Greptile on PR #52. Co-Authored-By: Claude Opus 4.7 --- backend/main.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/main.go b/backend/main.go index b714e69aca..9fdb7a5e53 100644 --- a/backend/main.go +++ b/backend/main.go @@ -87,6 +87,16 @@ func run() error { // surfacing — so we hold the SM in a local until it does. sStack, err := startSession(ctx, cfg, lcStack, log) if err != nil { + // startSession is the first start* call after this point that can + // realistically fail while the cdc poller and the reaper are already + // running. Mirror the bottom-of-run shutdown sequence so both have + // drained before the deferred store.Close() fires. Defers would hit + // the LIFO trap (see comment after srv.Run), hence explicit. + stop() + lcStack.Stop() + if cdcErr := cdcPipe.Stop(); cdcErr != nil { + log.Error("cdc pipeline shutdown", "err", cdcErr) + } return err } _ = sStack From 9a10eacc3984387701e486f080ea505cd8e427f3 Mon Sep 17 00:00:00 2001 From: Vaibhaav Tiwari <155460282+Vaibhaav-Tiwari@users.noreply.github.com> Date: Sun, 31 May 2026 20:31:22 +0530 Subject: [PATCH 069/250] feat(api): implement project routes with mock manager/store (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(backend): HTTP daemon skeleton — config, health, runfile, graceful shutdown (#10) Phase 1a of the Go HTTP daemon lane (#10). Stands up the loopback-only sidecar skeleton the later REST/SSE/WS/static surfaces build on: - config: env-driven (AO_HOST/PORT/ENV/timeouts/run-file) with zero-config defaults; binds 127.0.0.1:3001; validates and fails fast on bad input. - httpd: chi router with the recoverer → request-id → logger → real-ip middleware stack and /healthz + /readyz probes. Per-request timeout is carried in config but intentionally not global — it scopes to /api/v1 in Phase 1b so it never throttles SSE/WS/health. - runfile: atomic PID + port handshake (running.json) for the Electron supervisor, with a dead-PID stale check so a crashed predecessor doesn't block startup while a live one fails fast. - server: bind-before-publish (port conflict fails fast), graceful shutdown on SIGINT/SIGTERM via signal.NotifyContext with a 10s hard timeout, and run-file cleanup on exit. Why: the daemon must be safely supervisable as a child process — the supervisor needs a discoverable PID/port and the daemon must not leave a half-started process or stale handshake behind. Locking the lifecycle down now keeps the future port split a small change rather than a rewrite. Tests cover config defaults/overrides/validation, run-file round-trip and live/dead PID detection, health probes, full Run lifecycle, and port-conflict fail-fast. Co-Authored-By: Claude Opus 4.7 * refactor(backend): drop Env config field — not needed yet (#10) Per review on #14: AO_ENV / Config.Env / IsProduction() weren't load-bearing for Phase 1a — they only switched the slog handler. Removing them now keeps the surface minimal; the env knob can come back later when a real consumer needs it. - config: remove Env field, AO_ENV parsing, and IsProduction helper. - main: collapse newLogger to a single text-handler path. - httpd: drop the env field from the listening log line. - tests: drop the env assertions and AO_ENV fixture. Co-Authored-By: Claude Opus 4.7 * docs: add backend run + config quick-start to README (#10) Co-Authored-By: Claude Opus 4.7 * fix(backend): address Phase 1a review comments (#10) - config: drop AO_HOST entirely — the daemon is loopback-only by design, so making the bind host env-configurable was a security footgun - config: use net.JoinHostPort in Addr() so IPv6 literals stay valid - config: reject zero/negative AO_REQUEST_TIMEOUT and AO_SHUTDOWN_TIMEOUT (time.ParseDuration accepts both; either would silently break the daemon — instant request expiry / no graceful drain) - runfile: split processAlive into unix/windows build-tagged files so liveness detection is reliable on both platforms (Windows uses OpenProcess; POSIX keeps signal 0) - runfile: document os.Rename overwrite semantics (atomic on POSIX, REPLACE_EXISTING on Windows) so the temp-then-rename pattern's cross-platform behaviour is explicit - httpd tests: give probe/waitForHealth clients an explicit per-request timeout so a stalled connect can't hang the test on the outer deadline * fix(backend): strip trailing blank line from runfile.go (#10) gofmt CI was failing because removing the orphan processAlive doc comment left an extra newline at EOF. * fix(backend): cross-platform run-file replace + AO_HOST rationale (#10) - runfile: introduce build-tagged atomicReplace — POSIX rename(2) on Unix, MoveFileEx with MOVEFILE_REPLACE_EXISTING on Windows. The Go runtime happens to do the Windows call internally already, but invoking it directly makes the cross-platform contract explicit instead of a runtime implementation detail - runfile: tighten process_unix.go build tag from `!windows` to `unix` so plan9/js/wasm fail to build rather than silently using a broken signal-0 probe - runfile: add TestWriteOverwritesExisting covering the stale run-file replace path that none of the previous tests exercised - config: anchor the loopback-only decision in the LoopbackHost doc so the next contributor doesn't reintroduce AO_HOST without the security rationale * fix(backend): route chi access logs through slog/stderr (#10) chi's middleware.Logger writes via stdlib log to stdout, but the daemon's slog logger writes to stderr — so REST traffic and daemon logs landed on different streams in different formats. Replace it with a small slog-backed requestLogger that: - Wraps the response writer via middleware.NewWrapResponseWriter so status/bytes are accurate even when handlers return without an explicit WriteHeader. - Reads the request id off the context set by middleware.RequestID (kept mounted just before this middleware so the id is available). - Emits one structured Info line per request with method, path, status, bytes, duration, and remote — same key=value shape as the rest of the daemon, one stream for the Electron supervisor to capture. * feat(api): projects route shell (7 routes, REST-corrected) — #20 Mounts the /api/v1 surface on the skeleton router (#10·1a) and registers the 7 canonical project routes as 501 stubs that emit a structured PlannedRoute body documenting the future contract. Shared scaffolding landed here (api.go, errors.go, stubs/, controllers/) so #21/#22 plug in without re-touching the wiring. WHY: opens the route-shell PRs in the Go HTTP daemon lane. Doing it interface-first lets the dashboard team build against the contract before any handler logic exists; the locked APIError envelope and PlannedRoute shape become #19's OpenAPI source-of-truth. REST audit corrections vs the legacy TS surface: R3 PUT /projects/:id alias of PATCH: PUT not registered → 405. R4 POST /projects/:id repair overload: canonical /repair; legacy 405. R5 degraded GET returns 200 with error field: discriminator status. R6 ok/success flag flips: drop on 2xx; return affected resource. R9 bare {error: msg}: locked {error,code,message,requestId,details?}. Legacy paths are deliberately NOT registered; each canonical handler carries PlannedRoute.Legacy so consumers can discover the migration. Zod schemas (TrackerConfig, SCMConfig, AgentConfig, ReactionConfig, LocalProjectConfig, RoleAgentConfig) ported to typed Go structs with an Extra map reserved for .passthrough() round-tripping in later PRs. Closes part of #18; targets feat/issue-10 until #14 merges. * refactor(api): collapse ProjectService → ProjectManager — #20 Controllers now depend on ONE inbound interface per resource — ports.ProjectManager — mirroring the existing ports.SessionManager + LifecycleManager pattern. Whether the manager impl reaches into the registry, the LCM, an outbound port, or all three is its own concern; the HTTP layer no longer has to know any of that. WHY: the original split named the boundary type "ProjectService" and put it in a sibling services.go. That implied a second category of port distinct from inbound.go's *Manager interfaces, even though they play the same role (things HTTP/CLI call into the core). Per review feedback, collapse them onto one Manager-per-resource pattern. Mechanical changes: - ports/inbound.go gains ProjectManager next to SessionManager. - ports/services.go renamed to projects.go; keeps only the DTOs the ProjectManager methods take/return. - ProjectsController.Svc renamed to Mgr; APIDeps.Projects type bumped to ports.ProjectManager. All tests pass unchanged; no behavioural change. * refactor(api): replace stubs/ with OpenAPI-as-source-of-truth — #20 The first cut of the route shell duplicated each route's contract twice: once as a Go literal (stubs.PlannedRoute{...}) in the controller, and implicitly in the PR description. The Go literal was ~230 LoC of pure throwaway that would be deleted in handler-impl PRs. This commit eliminates the duplication: - backend/internal/httpd/apispec/openapi.yaml: full OpenAPI 3.1 doc covering the 7 project routes + shared schemas (Project, APIError, config types). x-replaces records the legacy → canonical mapping REST-audit corrections produced. - apispec/apispec.go: //go:embed the YAML, expose Operation(method, path) → the spec slice as a map, NotImplemented(w, r, method, path) → 501 with that slice embedded as `spec`. - controllers/projects.go: each of 7 handlers is now a one-liner: apispec.NotImplemented(w, r, "GET", "/api/v1/projects"). - /api/v1/openapi.yaml serves the embedded document so tooling (SDK gen, the validator slated for #19, dashboard dev tools) can fetch the whole spec from the same origin as the routes. - stubs/ package deleted. When a real handler lands, only the apispec.NotImplemented line goes away — nothing else does. The spec stays as documentation; consumers never had to know it was throwaway. #19 (OpenAPI follow-up) is now half-folded into this PR; the validation middleware remains its own follow-up. Tests reshaped: assert envelope + spec.operationId + spec.x-replaces (replaces the old planned.legacy assertion); add TestOpenAPIYAMLServed to cover the static spec serve; add apispec_test.go for embed/lookup behaviour. * refactor(api): move projects contract to internal/project package — #20 Pilots the feature-package layout the backend is migrating toward: a resource's inbound interface and its DTOs live with the resource, not in a central ports/ catch-all. WHY: review flagged ports/ as vague. It conflates three jobs — the outbound capability seam (legit), single-impl inbound interfaces (Go idiom wants these consumer-side), and DTOs that aren't ports at all. This moves the projects contract out as the reference shape #21/#22 follow; the merged session/lifecycle/outbound contracts are left untouched and migrated separately. Scope: INTERFACE ONLY. No implementation — handlers still answer via apispec.NotImplemented and the injected project.Manager stays nil. The impl lands in a later handler-impl PR. Changes: - new internal/project: project.go (Manager interface, 7 endpoints) + dto.go (AddInput/GetResult/UpdateConfigInput/RemoveResult/ReloadResult, moved verbatim from ports/projects.go, Project-prefix dropped). - ports/projects.go deleted; ProjectManager removed from ports/inbound.go. outbound.go and facts.go untouched. - controllers/projects.go and httpd/api.go depend on project.Manager. Domain entities (Project, ProjectSummary, DegradedProject, config types) stay in domain/ as shared vocabulary. go build/vet/test/gofmt all clean; no behavioural change. * refactor(api): consolidate project types into internal/project — #20 Addresses PR review: (1) "why are config_types required at the moment?" and (2) "project objects already defined in project/ — how do we differentiate?" Both had the same root cause: project types were split across domain/ and project/. Fix — keep ALL project types in the project package; only domain.ProjectID (shared with sessions/lifecycle/workspace) stays in domain. - domain/project.go → project/types.go: Project, Summary, Degraded (renamed from ProjectSummary/DegradedProject; the package name carries the "Project" prefix now). - domain/config_types.go deleted. Kept only the 4 shapes the projects API actually exposes — TrackerConfig, SCMConfig, SCMWebhookConfig, ReactionConfig — moved into project/types.go. Dropped AgentConfig, AgentPermission, RoleAgentConfig, LocalProjectConfig (zero references) and the speculative `Extra map[string]any` passthrough fields (no marshaller existed, so they silently dropped data — premature). - project/dto.go + project/project.go reference the local types; ids stay domain.ProjectID. Net: one home for project types, no dead code. go build/vet/test/gofmt clean; no behavioural change (handlers still 501 via apispec). * feat(api): implement project routes with mock manager/store * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * merge: resolve conflicts with origin/main * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor(httpd): share JSON/API error envelope helpers * fix(api): align project mock store with sqlite schema * fix(api): address project API review semantics * canonicalize both paths with filepath.EvalSymlinks before comparing * style(project): gofmt git repo validation --------- Co-authored-by: Aditi Chauhan Co-authored-by: Claude Opus 4.7 Co-authored-by: Vaibhaav Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/httpd/api.go | 95 ++++ backend/internal/httpd/apispec/apispec.go | 157 ++++++ .../internal/httpd/apispec/apispec_test.go | 70 +++ backend/internal/httpd/apispec/openapi.yaml | 446 ++++++++++++++++++ .../internal/httpd/controllers/projects.go | 219 +++++++++ .../httpd/controllers/projects_test.go | 311 ++++++++++++ backend/internal/httpd/envelope/envelope.go | 35 ++ backend/internal/httpd/errors.go | 22 + backend/internal/httpd/json.go | 9 +- backend/internal/httpd/router.go | 14 + backend/internal/project/dto.go | 55 +++ backend/internal/project/errors.go | 41 ++ backend/internal/project/manager.go | 264 +++++++++++ backend/internal/project/memory_store.go | 108 +++++ backend/internal/project/project.go | 44 ++ backend/internal/project/types.go | 96 ++++ backend/internal/runfile/rename_windows.go | 17 +- 19 files changed, 1998 insertions(+), 8 deletions(-) create mode 100644 backend/internal/httpd/api.go create mode 100644 backend/internal/httpd/apispec/apispec.go create mode 100644 backend/internal/httpd/apispec/apispec_test.go create mode 100644 backend/internal/httpd/apispec/openapi.yaml create mode 100644 backend/internal/httpd/controllers/projects.go create mode 100644 backend/internal/httpd/controllers/projects_test.go create mode 100644 backend/internal/httpd/envelope/envelope.go create mode 100644 backend/internal/httpd/errors.go create mode 100644 backend/internal/project/dto.go create mode 100644 backend/internal/project/errors.go create mode 100644 backend/internal/project/manager.go create mode 100644 backend/internal/project/memory_store.go create mode 100644 backend/internal/project/project.go create mode 100644 backend/internal/project/types.go diff --git a/backend/go.mod b/backend/go.mod index 88ca590cfb..403a77e4e9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,7 @@ go 1.25.7 require ( github.com/go-chi/chi/v5 v5.1.0 github.com/pressly/goose/v3 v3.27.1 + gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.51.0 ) diff --git a/backend/go.sum b/backend/go.sum index 89f839295e..2418747617 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -36,6 +36,8 @@ golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go new file mode 100644 index 0000000000..124a8d788f --- /dev/null +++ b/backend/internal/httpd/api.go @@ -0,0 +1,95 @@ +package httpd + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" + "github.com/aoagents/agent-orchestrator/backend/internal/project" +) + +// APIDeps bundles every Manager the API layer's controllers depend on. There +// is exactly one Manager per resource, defined in that resource's own package +// (project.Manager, later session.Manager, ...), and the controllers see ONLY +// that interface — they don't reach past it to the LCM, adapters, or stores. +// Whether a Manager impl talks to the registry, the LCM, or an outbound port +// is its own concern. +// +// The route-shell PR (#20) leaves every field nil — handlers answer via +// apispec.NotImplemented and don't dereference them yet. The handler-impl PR +// wires real Managers and flips stubs to real logic one route at a time. +type APIDeps struct { + Projects project.Manager +} + +// API owns one controller per resource and is the single Register call the +// router invokes to mount the /api/v1 surface. Splitting per-resource means +// later PRs can land a controller's real handlers without touching the +// surrounding wiring. +type API struct { + cfg config.Config + projects *controllers.ProjectsController +} + +// NewAPI constructs the API surface from its dependencies. cfg carries the +// per-request timeout so the REST group can apply it without re-reading the +// environment. +func NewAPI(cfg config.Config, deps APIDeps) *API { + return &API{ + cfg: cfg, + projects: &controllers.ProjectsController{ + Mgr: deps.Projects, + }, + } +} + +// Register mounts the API surface on root. /api/v1 hosts the REST group with +// the per-request Timeout that the skeleton router (router.go) deliberately +// kept off the global stack — REST routes are bounded, but long-lived surfaces +// (/events SSE, /mux WS) live outside this group when they land. +// +// /mux is mounted outside /api/v1 for parity with the legacy TS surface; it is +// a phase-4 placeholder and stays unregistered here until that lane starts. +func (a *API) Register(root chi.Router) { + timeout := a.cfg.RequestTimeout + if timeout <= 0 { + timeout = config.DefaultRequestTimeout + } + + root.Route("/api/v1", func(r chi.Router) { + // The OpenAPI document is the source of truth for every contract on + // this surface; serve it so tooling (SDK generators, the OpenAPI + // validator in #19, the dashboard's developer tools) can fetch the + // whole spec from the same origin as the routes it describes. + apispec.RegisterServe(r, "/openapi.yaml") + + r.Group(func(r chi.Router) { + r.Use(middleware.Timeout(timeout)) + a.projects.Register(r) + // Sibling controllers (sessions, issues, prs, ...) plug in here in + // follow-up PRs #21 / #22 without touching the timeout group. + }) + // Surfaces that intentionally bypass the REST timeout (SSE, future WS) + // register at this level — none exist in the route-shell PR. + }) +} + +// notFoundJSON returns the locked envelope for unmatched routes. Chi's default +// 404 is a text/plain body; the API surface must answer JSON so consumers can +// parse it uniformly. +func notFoundJSON(w http.ResponseWriter, r *http.Request) { + writeAPIError(w, r, http.StatusNotFound, "not_found", "ROUTE_NOT_FOUND", + r.Method+" "+r.URL.Path+" has no handler", nil) +} + +// methodNotAllowedJSON returns the locked envelope when a method probes a +// known path without a matching verb (e.g. PUT /projects/{id} after we drop +// the legacy PUT alias). +func methodNotAllowedJSON(w http.ResponseWriter, r *http.Request) { + writeAPIError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED", + r.Method+" not allowed on "+r.URL.Path, nil) +} diff --git a/backend/internal/httpd/apispec/apispec.go b/backend/internal/httpd/apispec/apispec.go new file mode 100644 index 0000000000..627ad5dbed --- /dev/null +++ b/backend/internal/httpd/apispec/apispec.go @@ -0,0 +1,157 @@ +// Package apispec embeds the OpenAPI document, looks up per-operation +// slices, and writes the locked 501 envelope. The 501 body carries the +// operation's slice of the OpenAPI document so consumers discover the +// contract from the endpoint itself — no duplicate planned/contract +// metadata lives in code. +// +// The same document is served verbatim at /api/v1/openapi.yaml so +// tooling that wants the whole spec can fetch it once. +package apispec + +import ( + _ "embed" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + yaml "gopkg.in/yaml.v3" +) + +//go:embed openapi.yaml +var openapiYAML []byte + +// Spec is the parsed, in-memory view of the embedded OpenAPI document. It +// preserves the YAML shape verbatim so the JSON we emit on 501 responses +// matches the on-disk source. +type Spec struct { + doc map[string]any +} + +var ( + defaultOnce sync.Once + defaultSpec *Spec + defaultErr error +) + +// Default returns the process-wide spec parsed from the embedded YAML. It +// panics on a malformed embed — that is a build-time bug, not a runtime +// one, so failing fast at first use is the right call. +func Default() *Spec { + defaultOnce.Do(func() { + s, err := New(openapiYAML) + defaultSpec = s + defaultErr = err + }) + if defaultErr != nil { + panic(fmt.Sprintf("apispec: embedded openapi.yaml failed to parse: %v", defaultErr)) + } + return defaultSpec +} + +// New parses the supplied YAML bytes. Exposed so tests can construct an +// independent spec without touching the embedded default. +func New(yamlBytes []byte) (*Spec, error) { + var doc map[string]any + if err := yaml.Unmarshal(yamlBytes, &doc); err != nil { + return nil, fmt.Errorf("parse openapi: %w", err) + } + if doc == nil { + return nil, fmt.Errorf("parse openapi: empty document") + } + return &Spec{doc: doc}, nil +} + +// YAML returns the raw embedded document bytes. Used by the /openapi.yaml +// handler. +func (s *Spec) YAML() []byte { return openapiYAML } + +// Operation returns the spec slice for a single (method, path) pair, ready +// to be JSON-serialised. The slice is the OpenAPI Operation object (the +// inner block under e.g. paths./projects.get), with parent path-level +// parameters merged in for completeness. +// +// Returns nil if the path or method is not in the spec; that is treated as +// a developer error (route registered without spec coverage) — callers +// log/fail loudly rather than silently writing a partial 501 body. +func (s *Spec) Operation(method, path string) map[string]any { + paths, _ := s.doc["paths"].(map[string]any) + if paths == nil { + return nil + } + pathItem, _ := paths[path].(map[string]any) + if pathItem == nil { + return nil + } + op, _ := pathItem[strings.ToLower(method)].(map[string]any) + if op == nil { + return nil + } + + // Path-level parameters apply to every method on that path; merge them + // in so the slice is self-contained. + out := make(map[string]any, len(op)+1) + for k, v := range op { + out[k] = v + } + if params, ok := pathItem["parameters"]; ok { + // Prefer the operation's own parameters when both are present; + // otherwise inherit from the path level. + if _, exists := out["parameters"]; !exists { + out["parameters"] = params + } + } + return out +} + +// notImplementedResponse is the wire shape for 501 — APIError envelope +// plus a `spec` field carrying the operation slice. Mirrors the +// NotImplementedResponse schema in openapi.yaml. +type notImplementedResponse struct { + Error string `json:"error"` + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"requestId,omitempty"` + Spec map[string]any `json:"spec"` +} + +// NotImplemented writes the locked 501 envelope, embedding the OpenAPI +// Operation slice that documents what this route WILL do. Replaces the +// throwaway PlannedRoute literals that the first cut of the route shell +// duplicated in controller code. +func NotImplemented(w http.ResponseWriter, r *http.Request, method, path string) { + op := Default().Operation(method, path) + if op == nil { + panic(fmt.Sprintf("apispec: missing operation for %s %s", method, path)) + } + body := notImplementedResponse{ + Error: "not_implemented", + Code: "NOT_IMPLEMENTED", + Message: method + " " + path + " is registered but not yet implemented", + RequestID: middleware.GetReqID(r.Context()), + Spec: op, + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusNotImplemented) + // A write error here means the client went away mid-response. + _ = json.NewEncoder(w).Encode(body) +} + +// ServeYAML serves the embedded openapi.yaml document. Mounted at +// /api/v1/openapi.yaml so spec-consuming tooling (#19's validator, +// SDK generators, the dashboard's developer tools) can fetch the +// whole document in one request. +func ServeYAML(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/yaml; charset=utf-8") + _, _ = w.Write(openapiYAML) +} + +// RegisterServe mounts ServeYAML on the supplied router. Kept as a +// helper so the router code only references one symbol from apispec +// for the static serve path. +func RegisterServe(r chi.Router, path string) { + r.Get(path, ServeYAML) +} diff --git a/backend/internal/httpd/apispec/apispec_test.go b/backend/internal/httpd/apispec/apispec_test.go new file mode 100644 index 0000000000..b5bde56273 --- /dev/null +++ b/backend/internal/httpd/apispec/apispec_test.go @@ -0,0 +1,70 @@ +package apispec_test + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" +) + +// TestDefaultLoadsEmbeddedSpec is the smoke test for //go:embed wiring: +// the default Spec must parse the embedded YAML without panicking and +// recognise a known operation. +func TestDefaultLoadsEmbeddedSpec(t *testing.T) { + op := apispec.Default().Operation("GET", "/api/v1/projects") + if op == nil { + t.Fatal("Default().Operation(GET, /api/v1/projects) = nil; embed broken or path missing") + } + if got, _ := op["operationId"].(string); got != "listProjects" { + t.Errorf("operationId = %q, want listProjects", got) + } +} + +// TestOperation_MissingPath returns nil for unknown paths — that's how the +// controller-side test catches "route registered without spec coverage". +func TestOperation_MissingPath(t *testing.T) { + if op := apispec.Default().Operation("GET", "/api/v1/no-such-route"); op != nil { + t.Errorf("unknown path returned %v, want nil", op) + } +} + +// TestOperation_MissingMethod returns nil for known path / unknown method. +func TestOperation_MissingMethod(t *testing.T) { + if op := apispec.Default().Operation("HEAD", "/api/v1/projects"); op != nil { + t.Errorf("HEAD on a GET-only path returned %v, want nil", op) + } +} + +// TestOperation_InheritsPathParameters covers the bit of behaviour that +// would silently rot otherwise: parameters declared at the path level +// (e.g. the {id} path param shared by GET/PATCH/DELETE) must show up on +// every operation's slice so the 501 response is self-contained. +func TestOperation_InheritsPathParameters(t *testing.T) { + op := apispec.Default().Operation("GET", "/api/v1/projects/{id}") + if op == nil { + t.Fatal("expected operation slice") + } + params, ok := op["parameters"].([]any) + if !ok || len(params) == 0 { + t.Fatalf("expected inherited path-level parameters, got %#v", op["parameters"]) + } +} + +// TestServeYAML serves the raw embedded document; tooling fetches it +// whole rather than reconstructing it from per-operation slices. +func TestServeYAML(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/openapi.yaml", nil) + apispec.ServeYAML(rec, req) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { + t.Errorf("Content-Type = %q, want application/yaml*", ct) + } + if !strings.Contains(rec.Body.String(), "openapi: 3.1.0") { + t.Errorf("body did not begin with an OpenAPI 3.1 doc") + } +} diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml new file mode 100644 index 0000000000..2b60a3a537 --- /dev/null +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -0,0 +1,446 @@ +openapi: 3.1.0 +info: + title: Agent Orchestrator HTTP daemon + version: 0.1.0-route-shell + description: | + Loopback-only HTTP surface served by the Go daemon. This spec is the + source of truth for every route's contract — the 501 stubs in the + route-shell phase return the matching Operation slice as a `spec` + field, so consumers discover the contract by calling the endpoint + they care about. Real handlers in later PRs satisfy this same spec. + +servers: + - url: http://127.0.0.1:3001 + description: Local daemon (loopback only) + +tags: + - name: projects + description: Project registry, configuration, and lifecycle administration + +paths: + /api/v1/projects: + get: + operationId: listProjects + tags: [projects] + summary: List active registered projects + responses: + "200": + description: Projects listed + content: + application/json: + schema: + type: object + required: [projects] + properties: + projects: + type: array + items: { $ref: "#/components/schemas/ProjectSummary" } + "500": + description: Failed to load projects + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: internal, code: PROJECTS_LIST_FAILED, message: "Failed to load projects" } + "501": { $ref: "#/components/responses/NotImplemented" } + + post: + operationId: addProject + tags: [projects] + summary: Register a new project from a git repository path + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AddProjectRequest" } + responses: + "201": + description: Project registered + content: + application/json: + schema: + type: object + required: [project] + properties: + project: { $ref: "#/components/schemas/Project" } + "400": + description: Bad request + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + examples: + invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } + pathRequired: { value: { error: bad_request, code: PATH_REQUIRED, message: "Repository path is required" } } + notAGitRepo: { value: { error: bad_request, code: NOT_A_GIT_REPO, message: "Repository path must point to a git repository" } } + "409": + description: Conflict with an already-registered project + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + examples: + pathAlready: + value: + error: conflict + code: PATH_ALREADY_REGISTERED + message: "A project at this path is already registered" + details: + existingProjectId: existing-project-id + suggestedProjectId: suggested-project-id + idAlready: + value: + error: conflict + code: ID_ALREADY_REGISTERED + message: "A project with this id is already registered for a different path" + details: + existingProjectId: existing-project-id + suggestedProjectId: suggested-project-id + "501": { $ref: "#/components/responses/NotImplemented" } + + /api/v1/projects/reload: + post: + operationId: reloadProjects + tags: [projects] + summary: Invalidate cached config and re-scan the global registry + responses: + "200": + description: Reload complete + content: + application/json: + schema: { $ref: "#/components/schemas/ReloadResult" } + "500": + description: Reload failed + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: internal, code: RELOAD_FAILED, message: "Failed to reload projects" } + "501": { $ref: "#/components/responses/NotImplemented" } + + /api/v1/projects/{id}: + parameters: + - $ref: "#/components/parameters/ProjectIDPath" + get: + operationId: getProject + tags: [projects] + summary: Fetch one project; discriminates ok vs degraded + responses: + "200": + description: Project resolved (status discriminates ok vs degraded) + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectGetResponse" } + "404": { $ref: "#/components/responses/ProjectNotFound" } + "500": + description: Failed to load project + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: internal, code: PROJECT_LOAD_FAILED, message: "Failed to load project" } + "501": { $ref: "#/components/responses/NotImplemented" } + x-rest-audit-notes: | + R5: degraded projects return 200 with a `status` discriminator + instead of 200 with an `error` field (as the legacy TS surface did). + Archived projects are hidden from list responses but still resolve by + id so historical sessions can keep their project_id reference. + + patch: + operationId: updateProjectConfig + tags: [projects] + summary: Patch behaviour-only fields (not implemented until config persistence lands) + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/UpdateProjectConfigRequest" } + responses: + "400": + description: Bad request + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + examples: + invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } + identityFrozen: + value: + error: bad_request + code: IDENTITY_FROZEN + message: "Identity fields cannot be patched" + details: { fields: [projectId, path, repo, defaultBranch] } + invalidConfig: { value: { error: bad_request, code: INVALID_LOCAL_CONFIG, message: "Local project config failed validation" } } + "404": { $ref: "#/components/responses/ProjectNotFound" } + "409": + description: Project not in a patchable state + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + examples: + degraded: { value: { error: conflict, code: PROJECT_DEGRADED, message: "Project config is degraded; repair before patching" } } + missingPath: { value: { error: conflict, code: PROJECT_MISSING_PATH, message: "Project registry entry is missing a path" } } + "501": + description: Behaviour config persistence is not wired yet + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: not_implemented, code: PROJECT_CONFIG_NOT_IMPLEMENTED, message: "Project config patching is not available until config persistence is wired" } + x-rest-audit-notes: | + R3: legacy `PUT /projects/{id}` (a TS alias of PATCH) is NOT + registered. PUT returns 405 Method Not Allowed. + R6: when config persistence lands this route returns { project }, not + { ok: true }. Until then, config patches return 501 instead of + pretending to persist fields the current project store cannot store. + + delete: + operationId: removeProject + tags: [projects] + summary: Archive a project; hides it from active lists while preserving id references + responses: + "200": + description: Project archived + content: + application/json: + schema: { $ref: "#/components/schemas/RemoveProjectResult" } + "400": + description: Invalid project id + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: bad_request, code: INVALID_PROJECT_ID, message: "Project id failed storage-path validation" } + "404": { $ref: "#/components/responses/ProjectNotFound" } + "500": + description: Removal failed + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: internal, code: PROJECT_REMOVE_FAILED, message: "Failed to remove project" } + "501": { $ref: "#/components/responses/NotImplemented" } + + /api/v1/projects/{id}/repair: + parameters: + - $ref: "#/components/parameters/ProjectIDPath" + post: + operationId: repairProject + tags: [projects] + summary: Repair a degraded project where automatic recovery is available + x-replaces: + - "POST /api/v1/projects/{id}" + x-rest-audit-notes: | + R4: this canonical path replaces the overloaded + `POST /api/v1/projects/{id}` from the legacy TS surface. + The legacy path is NOT registered; consumers must use /repair. + responses: + "200": + description: Project repaired + content: + application/json: + schema: + type: object + required: [project] + properties: + project: { $ref: "#/components/schemas/Project" } + "400": + description: Bad request + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + examples: + notDegraded: { value: { error: bad_request, code: PROJECT_NOT_DEGRADED, message: "Project does not need repair" } } + notAvailable: { value: { error: bad_request, code: REPAIR_NOT_AVAILABLE, message: "Automatic repair is not available for this degraded config" } } + "404": { $ref: "#/components/responses/ProjectNotFound" } + "501": { $ref: "#/components/responses/NotImplemented" } + +components: + parameters: + ProjectIDPath: + name: id + in: path + required: true + schema: { type: string, minLength: 1 } + description: Project identifier (registry key). + + responses: + NotImplemented: + description: | + Route is registered but the handler has not been implemented yet. + The body carries the locked APIError envelope plus a `spec` field + containing this operation's slice of the OpenAPI document so + callers can discover the contract from the endpoint itself. + content: + application/json: + schema: { $ref: "#/components/schemas/NotImplementedResponse" } + + ProjectNotFound: + description: Project not found + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: not_found, code: PROJECT_NOT_FOUND, message: "Unknown project" } + + schemas: + APIError: + type: object + required: [error, code, message] + properties: + error: { type: string, description: "Short kind, e.g. not_found" } + code: { type: string, description: "SCREAMING_SNAKE machine code" } + message: { type: string, description: "Human-readable detail" } + requestId: { type: string } + details: + type: object + additionalProperties: true + + NotImplementedResponse: + allOf: + - $ref: "#/components/schemas/APIError" + - type: object + required: [spec] + properties: + spec: + type: object + description: | + The OpenAPI Operation object for this method+path, served + inline so consumers discover the contract from the 501 + response without fetching the full spec. Mirrors the YAML + shape — see /api/v1/openapi.yaml for the full document. + + ProjectSummary: + type: object + required: [id, name, sessionPrefix] + properties: + id: { type: string } + name: { type: string } + sessionPrefix: { type: string } + resolveError: + type: string + description: Present iff the project is degraded. + + Project: + type: object + required: [id, name, path, repo, defaultBranch] + properties: + id: { type: string } + name: { type: string } + path: { type: string } + repo: + type: string + description: "\"owner/name\" or empty string when unset" + defaultBranch: { type: string, default: main } + agent: { type: string } + runtime: { type: string } + tracker: { $ref: "#/components/schemas/TrackerConfig" } + scm: { $ref: "#/components/schemas/SCMConfig" } + reactions: + type: object + additionalProperties: { $ref: "#/components/schemas/ReactionConfig" } + + DegradedProject: + type: object + required: [id, name, path, resolveError] + properties: + id: { type: string } + name: { type: string } + path: { type: string } + resolveError: { type: string } + + ProjectGetResponse: + type: object + required: [status, project] + properties: + status: + type: string + enum: [ok, degraded] + project: + oneOf: + - $ref: "#/components/schemas/Project" + - $ref: "#/components/schemas/DegradedProject" + AddProjectRequest: + type: object + required: [path] + properties: + path: + type: string + description: Repository path; supports ~ home-expansion. Must be a git repo. + projectId: + type: string + description: Optional override; defaults to basename(path). + name: + type: string + description: Optional display name; defaults to projectId. + + UpdateProjectConfigRequest: + type: object + description: | + Behaviour-only patch. Identity fields (projectId, path, repo, + defaultBranch) are rejected with 400 IDENTITY_FROZEN. The current Go + handler returns 501 PROJECT_CONFIG_NOT_IMPLEMENTED until config + persistence exists. + properties: + agent: { type: string } + runtime: { type: string } + tracker: { $ref: "#/components/schemas/TrackerConfig" } + scm: { $ref: "#/components/schemas/SCMConfig" } + reactions: + type: object + additionalProperties: { $ref: "#/components/schemas/ReactionConfig" } + + RemoveProjectResult: + type: object + required: [projectId, removedStorageDir] + properties: + projectId: { type: string } + removedStorageDir: { type: boolean } + + ReloadResult: + type: object + required: [reloaded, projectCount, degradedCount] + properties: + reloaded: { type: boolean } + projectCount: { type: integer } + degradedCount: { type: integer } + + # ---- Behaviour config blobs (ported from the TS Zod schemas) ---- + # These are the known config shapes only. The current Go handler does not + # preserve unknown passthrough keys until config persistence is implemented. + + TrackerConfig: + type: object + additionalProperties: true + properties: + plugin: { type: string } + package: { type: string } + path: { type: string } + + SCMConfig: + type: object + additionalProperties: true + properties: + plugin: { type: string } + package: { type: string } + path: { type: string } + webhook: + type: object + properties: + enabled: { type: boolean } + path: { type: string } + secretEnvVar: { type: string } + signatureHeader: { type: string } + eventHeader: { type: string } + deliveryHeader: { type: string } + maxBodyBytes: { type: integer } + + ReactionConfig: + type: object + properties: + auto: { type: boolean } + action: + type: string + enum: [send-to-agent, notify, auto-merge] + message: { type: string } + priority: + type: string + enum: [urgent, action, warning, info] + retries: { type: integer } + escalateAfter: + oneOf: + - { type: number } + - { type: string } + description: Either ms (number) or duration string ("30m"). + threshold: { type: string } + includeSummary: { type: boolean } diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go new file mode 100644 index 0000000000..8fa9db1f74 --- /dev/null +++ b/backend/internal/httpd/controllers/projects.go @@ -0,0 +1,219 @@ +// Package controllers holds the HTTP-facing controllers for the /api/v1 +// surface. Each controller groups one resource's routes, exposes a Register +// method that wires them on a chi.Router, and depends on exactly one +// *Manager interface from ports/inbound.go — never on a store, the LCM, an +// adapter, or any other port. Whether the Manager impl reaches past that +// boundary is its own concern. +// +// In the route-shell PR (#20) every handler is a one-line apispec.NotImplemented +// call: the contract lives in the OpenAPI document (apispec/openapi.yaml), and +// the 501 body returns that document's slice for the route so consumers can +// discover the contract from the endpoint itself. When real handlers land, +// the stub one-liner is replaced with the impl; no per-route planned +// metadata in code ever has to be deleted. +package controllers + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + "github.com/aoagents/agent-orchestrator/backend/internal/project" +) + +// ProjectsController owns the 7 canonical /projects routes. The controller +// depends ONLY on project.Manager — it doesn't know whether the impl reaches +// into the registry, the LCM, an adapter, or all three. Mgr is nil while +// handlers are stubs; the handler-impl PR supplies a real project.Manager. +type ProjectsController struct { + Mgr project.Manager +} + +// Register mounts the project routes on the supplied router. Route order +// matters: /projects/reload must register before /projects/{id} for the POST +// verb, otherwise chi would treat "reload" as an {id} match for repair. +// +// Legacy paths that the REST audit dropped are deliberately NOT registered +// here. They surface as 405 (sibling method exists, e.g. PUT /projects/{id}) +// or 404 (no sibling). The mapping lives in apispec/openapi.yaml as +// `x-replaces` on the canonical operation so consumers discover the +// migration without leaving the spec. +func (c *ProjectsController) Register(r chi.Router) { + r.Get("/projects", c.list) + r.Post("/projects", c.add) + r.Post("/projects/reload", c.reload) // BEFORE /projects/{id} + r.Get("/projects/{id}", c.get) + r.Patch("/projects/{id}", c.updateConfig) + r.Delete("/projects/{id}", c.remove) + r.Post("/projects/{id}/repair", c.repair) +} + +func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/projects") + return + } + projects, err := c.Mgr.List(r.Context()) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"projects": projects}) +} + +func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/projects") + return + } + var in project.AddInput + if err := decodeJSON(r, &in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + p, err := c.Mgr.Add(r.Context(), in) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusCreated, map[string]any{"project": p}) +} + +func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/projects/{id}") + return + } + got, err := c.Mgr.Get(r.Context(), projectID(r)) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + if got.Status == "degraded" { + envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Degraded}) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Project}) +} + +func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "PATCH", "/api/v1/projects/{id}") + return + } + if frozen, err := containsFrozenIdentityField(r); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } else if len(frozen) > 0 { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "IDENTITY_FROZEN", "Identity fields cannot be patched", map[string]any{"fields": frozen}) + return + } + + var patch project.UpdateConfigInput + if err := decodeJSON(r, &patch); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + p, err := c.Mgr.UpdateConfig(r.Context(), projectID(r), patch) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) +} + +func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "DELETE", "/api/v1/projects/{id}") + return + } + result, err := c.Mgr.Remove(r.Context(), projectID(r)) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusOK, result) +} + +func (c *ProjectsController) repair(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/projects/{id}/repair") + return + } + p, err := c.Mgr.Repair(r.Context(), projectID(r)) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) +} + +func (c *ProjectsController) reload(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/projects/reload") + return + } + result, err := c.Mgr.Reload(r.Context()) + if err != nil { + writeProjectError(w, r, err, http.StatusInternalServerError) + return + } + envelope.WriteJSON(w, http.StatusOK, result) +} + +func projectID(r *http.Request) domain.ProjectID { + return domain.ProjectID(chi.URLParam(r, "id")) +} + +func decodeJSON(r *http.Request, out any) error { + return json.NewDecoder(r.Body).Decode(out) +} + +func containsFrozenIdentityField(r *http.Request) ([]string, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return nil, err + } + var frozen []string + for _, field := range []string{"projectId", "path", "repo", "defaultBranch"} { + if _, ok := raw[field]; ok { + frozen = append(frozen, field) + } + } + return frozen, nil +} + +func writeProjectError(w http.ResponseWriter, r *http.Request, err error, fallbackStatus int) { + var pe *project.Error + if errors.As(err, &pe) { + status := fallbackStatus + switch pe.Kind { + case "bad_request": + status = http.StatusBadRequest + case "not_found": + status = http.StatusNotFound + case "conflict": + status = http.StatusConflict + case "not_implemented": + status = http.StatusNotImplemented + case "internal": + status = http.StatusInternalServerError + } + envelope.WriteAPIError(w, r, status, pe.Kind, pe.Code, pe.Message, pe.Details) + return + } + envelope.WriteAPIError(w, r, fallbackStatus, "internal", "INTERNAL_ERROR", "Internal server error", nil) +} diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go new file mode 100644 index 0000000000..4a6d19e153 --- /dev/null +++ b/backend/internal/httpd/controllers/projects_test.go @@ -0,0 +1,311 @@ +package controllers_test + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/project" +) + +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, httpd.APIDeps{ + Projects: project.NewMemoryManager(), + })) + t.Cleanup(srv.Close) + return srv +} + +func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log)) + t.Cleanup(srv.Close) + + body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") +} + +func TestProjectsAPI_ListAddGetReload(t *testing.T) { + srv := newTestServer(t) + repo := gitRepo(t, "agent-orchestrator") + + body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") + if status != http.StatusOK { + t.Fatalf("GET projects = %d, want 200; body=%s", status, body) + } + assertJSON(t, headers) + var list struct { + Projects []projectSummary `json:"projects"` + } + mustJSON(t, body, &list) + if len(list.Projects) != 0 { + t.Fatalf("initial project count = %d, want 0", len(list.Projects)) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"ao","name":"Agent Orchestrator"}`) + if status != http.StatusCreated { + t.Fatalf("POST project = %d, want 201; body=%s", status, body) + } + var add struct { + Project projectBody `json:"project"` + } + mustJSON(t, body, &add) + if add.Project.ID != "ao" || add.Project.Name != "Agent Orchestrator" || add.Project.DefaultBranch != "main" { + t.Fatalf("created project = %#v", add.Project) + } + + body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/ao", "") + if status != http.StatusOK { + t.Fatalf("GET project = %d, want 200; body=%s", status, body) + } + var get struct { + Status string `json:"status"` + Project projectBody `json:"project"` + } + mustJSON(t, body, &get) + if get.Status != "ok" || get.Project.ID != "ao" { + t.Fatalf("get response = %#v", get) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/reload", "") + if status != http.StatusOK { + t.Fatalf("reload = %d, want 200; body=%s", status, body) + } + var reload struct { + Reloaded bool `json:"reloaded"` + ProjectCount int `json:"projectCount"` + DegradedCount int `json:"degradedCount"` + } + mustJSON(t, body, &reload) + if !reload.Reloaded || reload.ProjectCount != 1 || reload.DegradedCount != 0 { + t.Fatalf("reload response = %#v", reload) + } +} + +func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { + srv := newTestServer(t) + repoA := gitRepo(t, "repo-a") + repoB := gitRepo(t, "repo-b") + notRepo := t.TempDir() + + cases := []struct { + name, body, wantCode string + wantStatus int + }{ + {name: "invalid json", body: `{`, wantStatus: 400, wantCode: "INVALID_JSON"}, + {name: "missing path", body: `{}`, wantStatus: 400, wantCode: "PATH_REQUIRED"}, + {name: "not git", body: `{"path":` + quote(notRepo) + `}`, wantStatus: 400, wantCode: "NOT_A_GIT_REPO"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", tc.body) + assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode) + }) + } + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"shared"}`) + if status != http.StatusCreated { + t.Fatalf("seed create = %d, want 201; body=%s", status, body) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"other"}`) + assertErrorCode(t, body, status, http.StatusConflict, "PATH_ALREADY_REGISTERED") + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoB)+`,"projectId":"shared"}`) + assertErrorCode(t, body, status, http.StatusConflict, "ID_ALREADY_REGISTERED") +} + +func TestProjectsAPI_UpdateDeleteRepair(t *testing.T) { + srv := newTestServer(t) + repo := gitRepo(t, "repo") + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"proj"}`) + if status != http.StatusCreated { + t.Fatalf("seed create = %d, want 201; body=%s", status, body) + } + + body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"agent":"claude","runtime":"tmux"}`) + assertErrorCode(t, body, status, http.StatusNotImplemented, "PROJECT_CONFIG_NOT_IMPLEMENTED") + + body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"path":"elsewhere"}`) + assertErrorCode(t, body, status, http.StatusBadRequest, "IDENTITY_FROZEN") + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/proj/repair", "") + assertErrorCode(t, body, status, http.StatusBadRequest, "REPAIR_NOT_AVAILABLE") + + body, status, _ = doRequest(t, srv, "DELETE", "/api/v1/projects/proj", "") + if status != http.StatusOK { + t.Fatalf("DELETE = %d, want 200; body=%s", status, body) + } + var removed struct { + ProjectID string `json:"projectId"` + RemovedStorageDir bool `json:"removedStorageDir"` + } + mustJSON(t, body, &removed) + if removed.ProjectID != "proj" || removed.RemovedStorageDir { + t.Fatalf("delete response = %#v", removed) + } + + body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/proj", "") + if status != http.StatusOK { + t.Fatalf("GET archived project = %d, want 200; body=%s", status, body) + } + + body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "") + if status != http.StatusOK { + t.Fatalf("GET projects after archive = %d, want 200; body=%s", status, body) + } + var list struct { + Projects []projectSummary `json:"projects"` + } + mustJSON(t, body, &list) + if len(list.Projects) != 0 { + t.Fatalf("active projects after archive = %d, want 0", len(list.Projects)) + } +} + +func TestProjectsRoutes_LegacyUnregistered(t *testing.T) { + srv := newTestServer(t) + + cases := []struct { + method, path, wantCode, why string + wantStatus int + }{ + {method: "PUT", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R3 PUT not registered"}, + {method: "POST", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R4 repair moved to /repair"}, + } + + for _, tc := range cases { + t.Run(tc.why, func(t *testing.T) { + body, status, _ := doRequest(t, srv, tc.method, tc.path, "") + assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode) + }) + } +} + +func TestProjectsRoutes_MissingRoute(t *testing.T) { + srv := newTestServer(t) + body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/p1/does-not-exist", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotFound, "ROUTE_NOT_FOUND") +} + +func TestOpenAPIYAMLServed(t *testing.T) { + srv := newTestServer(t) + body, status, headers := doRequest(t, srv, "GET", "/api/v1/openapi.yaml", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { + t.Errorf("Content-Type = %q, want application/yaml*", ct) + } + if !strings.Contains(string(body), "openapi: 3.1.0") { + t.Errorf("served body did not start with an OpenAPI 3.1 doc") + } +} + +type projectSummary struct { + ID string `json:"id"` + Name string `json:"name"` + SessionPrefix string `json:"sessionPrefix"` +} + +type projectBody struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Repo string `json:"repo"` + DefaultBranch string `json:"defaultBranch"` + Agent string `json:"agent"` + Runtime string `json:"runtime"` +} + +type errorBody struct { + Error string `json:"error"` + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` +} + +func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([]byte, int, http.Header) { + t.Helper() + var req *http.Request + var err error + if body != "" { + req, err = http.NewRequest(method, srv.URL+path, strings.NewReader(body)) + } else { + req, err = http.NewRequest(method, srv.URL+path, nil) + } + if err != nil { + t.Fatalf("new request: %v", err) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + buf, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return buf, resp.StatusCode, resp.Header +} + +func gitRepo(t *testing.T, name string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create git repo fixture: %v", err) + } + if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { + t.Fatalf("git init fixture: %v\n%s", err, out) + } + return dir +} + +func quote(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func mustJSON(t *testing.T, body []byte, out any) { + t.Helper() + if err := json.Unmarshal(body, out); err != nil { + t.Fatalf("unmarshal: %v\nbody=%s", err, body) + } +} + +func assertJSON(t *testing.T, headers http.Header) { + t.Helper() + if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("Content-Type = %q, want JSON", ct) + } +} + +func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode string) { + t.Helper() + if status != wantStatus { + t.Fatalf("status = %d, want %d\nbody=%s", status, wantStatus, body) + } + var got errorBody + mustJSON(t, body, &got) + if got.Code != wantCode { + t.Fatalf("code = %q, want %q\nbody=%s", got.Code, wantCode, body) + } +} diff --git a/backend/internal/httpd/envelope/envelope.go b/backend/internal/httpd/envelope/envelope.go new file mode 100644 index 0000000000..3e1b2ade65 --- /dev/null +++ b/backend/internal/httpd/envelope/envelope.go @@ -0,0 +1,35 @@ +package envelope + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5/middleware" +) + +// APIError is the locked wire shape for every non-2xx response. +type APIError struct { + Error string `json:"error"` + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"requestId,omitempty"` + Details map[string]any `json:"details,omitempty"` +} + +// WriteJSON serialises v as JSON with the given status. +func WriteJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// WriteAPIError emits the locked envelope for any non-2xx response. +func WriteAPIError(w http.ResponseWriter, r *http.Request, status int, kind, code, message string, details map[string]any) { + WriteJSON(w, status, APIError{ + Error: kind, + Code: code, + Message: message, + RequestID: middleware.GetReqID(r.Context()), + Details: details, + }) +} diff --git a/backend/internal/httpd/errors.go b/backend/internal/httpd/errors.go new file mode 100644 index 0000000000..8b41c99f66 --- /dev/null +++ b/backend/internal/httpd/errors.go @@ -0,0 +1,22 @@ +package httpd + +import ( + "net/http" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" +) + +// APIError is the locked wire shape for every non-2xx response. It supersedes +// the legacy TS `{error: "msg"}` bag with a machine-readable Code and a +// RequestID for log correlation (sourced from chi's RequestID middleware). +// +// Details is open so collision-style errors can carry typed sub-fields +// (e.g. existingProjectId, suggestedProjectId on POST /projects 409s). +type APIError = envelope.APIError + +// writeAPIError emits the locked envelope for any non-2xx response. The +// request id falls back to empty when the chi middleware hasn't tagged the +// request (e.g. in tests that bypass NewRouter). +func writeAPIError(w http.ResponseWriter, r *http.Request, status int, kind, code, message string, details map[string]any) { + envelope.WriteAPIError(w, r, status, kind, code, message, details) +} diff --git a/backend/internal/httpd/json.go b/backend/internal/httpd/json.go index 9b87461fbc..64ccb340d6 100644 --- a/backend/internal/httpd/json.go +++ b/backend/internal/httpd/json.go @@ -1,17 +1,14 @@ package httpd import ( - "encoding/json" "net/http" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" ) // writeJSON serialises v as JSON with the given status. It is the single JSON // writer for the skeleton; the typed error envelope (open item Q1.3) will build // on this in a later phase. func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - // A write error here means the client went away mid-response; there is - // nothing useful to do but stop. - _ = json.NewEncoder(w).Encode(v) + envelope.WriteJSON(w, status, v) } diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 6e078b8df0..1a076c617d 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -30,6 +30,13 @@ import ( // probes. It is therefore applied per-surface when those subrouters are mounted // in Phase 1b; cfg.RequestTimeout carries the value through to that point. func NewRouter(cfg config.Config, log *slog.Logger) chi.Router { + return NewRouterWithAPI(cfg, log, APIDeps{}) +} + +// NewRouterWithAPI is the dependency-injected variant. main.go calls it with +// real Managers when they exist; tests/dev wiring inject mocks explicitly. +// Missing Managers intentionally keep the route-shell 501 behavior. +func NewRouterWithAPI(cfg config.Config, log *slog.Logger, deps APIDeps) chi.Router { r := chi.NewRouter() r.Use(middleware.Recoverer) @@ -37,7 +44,14 @@ func NewRouter(cfg config.Config, log *slog.Logger) chi.Router { r.Use(requestLogger(log)) r.Use(middleware.RealIP) + // JSON envelopes for unmatched routes / methods — chi's defaults are + // text/plain, which would break consumers that parse every response as + // the locked APIError shape. + r.NotFound(notFoundJSON) + r.MethodNotAllowed(methodNotAllowedJSON) + mountHealth(r) + NewAPI(cfg, deps).Register(r) return r } diff --git a/backend/internal/project/dto.go b/backend/internal/project/dto.go new file mode 100644 index 0000000000..0e6f5ee5ed --- /dev/null +++ b/backend/internal/project/dto.go @@ -0,0 +1,55 @@ +package project + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// Request/response shapes for Manager. They carry the data across the service +// boundary; the entities they reference (Project, Summary, Degraded) live in +// types.go in this same package. Named without a "Project" prefix because the +// package name already supplies it (project.AddInput, project.GetResult). + +// GetResult is the discriminated union returned by Manager.Get. Exactly one of +// Project / Degraded is non-nil; Status mirrors the discriminator on the wire +// so consumers branch on it without nil-checking both fields. +type GetResult struct { + Status string // "ok" | "degraded" + Project *Project // populated when Status == "ok" + Degraded *Degraded // populated when Status == "degraded" +} + +// AddInput is the body shape for POST /api/v1/projects. Path is required; +// ProjectID and Name default to basename(path) at the manager. Pointer fields +// preserve the "field absent" vs "field present empty" distinction so the +// manager can decide what to default and what to reject. +type AddInput struct { + Path string `json:"path"` + ProjectID *string `json:"projectId,omitempty"` + Name *string `json:"name,omitempty"` +} + +// UpdateConfigInput is the body shape for PATCH /api/v1/projects/{id}. Only +// behaviour fields are mutable; identity fields (projectId, path, repo, +// defaultBranch) are rejected by the handler with a 400 IDENTITY_FROZEN. +type UpdateConfigInput struct { + Agent *string `json:"agent,omitempty"` + Runtime *string `json:"runtime,omitempty"` + Tracker *TrackerConfig `json:"tracker,omitempty"` + SCM *SCMConfig `json:"scm,omitempty"` + Reactions *map[string]*ReactionConfig `json:"reactions,omitempty"` +} + +// RemoveResult reports what DELETE /api/v1/projects/{id} actually did. +// RemovedStorageDir is false when the project was registry-only (no on-disk +// session/workspace directory existed). +type RemoveResult struct { + ProjectID domain.ProjectID `json:"projectId"` + RemovedStorageDir bool `json:"removedStorageDir"` +} + +// ReloadResult is the response body of POST /api/v1/projects/reload — the +// manager invalidates its cached config and re-scans the registry; the counts +// help the dashboard show "loaded N projects, M degraded" feedback. +type ReloadResult struct { + Reloaded bool `json:"reloaded"` + ProjectCount int `json:"projectCount"` + DegradedCount int `json:"degradedCount"` +} diff --git a/backend/internal/project/errors.go b/backend/internal/project/errors.go new file mode 100644 index 0000000000..f6687e1f70 --- /dev/null +++ b/backend/internal/project/errors.go @@ -0,0 +1,41 @@ +package project + +// Error is the manager-level error shape controllers can translate into the +// locked HTTP APIError envelope without knowing store internals. +type Error struct { + Kind string + Code string + Message string + Details map[string]any +} + +func (e *Error) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func newError(kind, code, message string, details map[string]any) *Error { + return &Error{Kind: kind, Code: code, Message: message, Details: details} +} + +func badRequest(code, message string, details map[string]any) *Error { + return newError("bad_request", code, message, details) +} + +func notFound(code, message string) *Error { + return newError("not_found", code, message, nil) +} + +func conflict(code, message string, details map[string]any) *Error { + return newError("conflict", code, message, details) +} + +func notImplemented(code, message string) *Error { + return newError("not_implemented", code, message, nil) +} + +func internal(code, message string) *Error { + return newError("internal", code, message, nil) +} diff --git a/backend/internal/project/manager.go b/backend/internal/project/manager.go new file mode 100644 index 0000000000..93ca84d9c2 --- /dev/null +++ b/backend/internal/project/manager.go @@ -0,0 +1,264 @@ +package project + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +type manager struct { + store Store +} + +var _ Manager = (*manager)(nil) + +func NewManager(store Store) Manager { + if store == nil { + store = NewMemoryStore() + } + return &manager{store: store} +} + +func NewMemoryManager() Manager { + return NewManager(NewMemoryStore()) +} + +func (m *manager) List(ctx context.Context) ([]Summary, error) { + projects, err := m.store.List(ctx) + if err != nil { + return nil, internal("PROJECTS_LIST_FAILED", "Failed to load projects") + } + out := make([]Summary, 0, len(projects)) + for _, row := range projects { + out = append(out, Summary{ + ID: domain.ProjectID(row.ID), + Name: displayName(row), + SessionPrefix: sessionPrefix(row.ID), + }) + } + return out, nil +} + +func (m *manager) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) { + if err := validateProjectID(id); err != nil { + return GetResult{}, err + } + row, ok, err := m.store.Get(ctx, string(id)) + if err != nil { + return GetResult{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + } + if !ok { + return GetResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + } + p := projectFromRow(row) + return GetResult{Status: "ok", Project: &p}, nil +} + +func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { + path, err := normalizePath(in.Path) + if err != nil { + return Project{}, err + } + if !isGitRepo(path) { + return Project{}, badRequest("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil) + } + + id := defaultProjectID(path) + if in.ProjectID != nil { + id = domain.ProjectID(strings.TrimSpace(*in.ProjectID)) + } + if err := validateProjectID(id); err != nil { + return Project{}, err + } + + name := string(id) + if in.Name != nil { + name = strings.TrimSpace(*in.Name) + } + if name == "" { + name = string(id) + } + + if existing, ok, err := m.store.FindByPath(ctx, path); err != nil { + return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + } else if ok { + return Project{}, conflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{ + "existingProjectId": existing.ID, + "suggestedProjectId": string(m.suggestID(ctx, id)), + }) + } + if existing, ok, err := m.store.Get(ctx, string(id)); err != nil { + return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + } else if ok && existing.Path != path { + return Project{}, conflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{ + "existingProjectId": existing.ID, + "suggestedProjectId": string(m.suggestID(ctx, id)), + }) + } + + row := ProjectRow{ + ID: string(id), + Path: path, + DisplayName: name, + RegisteredAt: time.Now(), + } + if err := m.store.Upsert(ctx, row); err != nil { + return Project{}, err + } + return projectFromRow(row), nil +} + +func (m *manager) UpdateConfig(ctx context.Context, id domain.ProjectID, _ UpdateConfigInput) (Project, error) { + if err := validateProjectID(id); err != nil { + return Project{}, err + } + _, ok, err := m.store.Get(ctx, string(id)) + if err != nil { + return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + } + if !ok { + return Project{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + } + + return Project{}, notImplemented("PROJECT_CONFIG_NOT_IMPLEMENTED", "Project config patching is not available until config persistence is wired") +} + +func (m *manager) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) { + if err := validateProjectID(id); err != nil { + return RemoveResult{}, err + } + ok, err := m.store.Archive(ctx, string(id), time.Now()) + if err != nil { + return RemoveResult{}, internal("PROJECT_REMOVE_FAILED", "Failed to remove project") + } + if !ok { + return RemoveResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + } + return RemoveResult{ProjectID: id, RemovedStorageDir: false}, nil +} + +func (m *manager) Repair(ctx context.Context, id domain.ProjectID) (Project, error) { + if err := validateProjectID(id); err != nil { + return Project{}, err + } + if _, ok, err := m.store.Get(ctx, string(id)); err != nil { + return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + } else if !ok { + return Project{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + } + return Project{}, badRequest("REPAIR_NOT_AVAILABLE", "Automatic repair is not available for this degraded config", nil) +} + +func (m *manager) Reload(ctx context.Context) (ReloadResult, error) { + projects, err := m.store.List(ctx) + if err != nil { + return ReloadResult{}, internal("RELOAD_FAILED", "Failed to reload projects") + } + return ReloadResult{Reloaded: true, ProjectCount: len(projects), DegradedCount: 0}, nil +} + +func (m *manager) suggestID(ctx context.Context, base domain.ProjectID) domain.ProjectID { + for i := 1; ; i++ { + candidate := domain.ProjectID(string(base) + strconv.Itoa(i)) + if _, ok, _ := m.store.Get(ctx, string(candidate)); !ok { + return candidate + } + } +} + +func projectFromRow(row ProjectRow) Project { + return Project{ + ID: domain.ProjectID(row.ID), + Name: displayName(row), + Path: row.Path, + Repo: row.RepoOriginURL, + DefaultBranch: "main", + } +} + +func displayName(row ProjectRow) string { + if strings.TrimSpace(row.DisplayName) != "" { + return row.DisplayName + } + return row.ID +} + +func normalizePath(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", badRequest("PATH_REQUIRED", "Repository path is required", nil) + } + if strings.HasPrefix(raw, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", badRequest("INVALID_PATH", "Repository path could not be expanded", nil) + } + if raw == "~" { + raw = home + } else if strings.HasPrefix(raw, "~/") || strings.HasPrefix(raw, `~\`) { + raw = filepath.Join(home, raw[2:]) + } + } + abs, err := filepath.Abs(raw) + if err != nil { + return "", badRequest("INVALID_PATH", "Repository path is invalid", nil) + } + return filepath.Clean(abs), nil +} + +func isGitRepo(path string) bool { + cmd := exec.Command("git", "-C", path, "rev-parse", "--show-toplevel") + out, err := cmd.Output() + if err != nil { + return false + } + top := filepath.Clean(strings.TrimSpace(string(out))) + path = filepath.Clean(path) + top, err = filepath.EvalSymlinks(top) + if err != nil { + return false + } + path, err = filepath.EvalSymlinks(path) + if err != nil { + return false + } + + if strings.EqualFold(top, path) { + return true + } + return top == path +} + +func defaultProjectID(path string) domain.ProjectID { + id := strings.ToLower(filepath.Base(path)) + id = strings.TrimSpace(id) + id = strings.ReplaceAll(id, " ", "-") + return domain.ProjectID(id) +} + +var projectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +func validateProjectID(id domain.ProjectID) error { + raw := string(id) + if raw == "" || raw == "." || raw == ".." || strings.ContainsAny(raw, `/\`) || !projectIDPattern.MatchString(raw) { + return badRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil) + } + return nil +} + +func sessionPrefix(id string) string { + if id == "" { + return "ao" + } + if len(id) <= 12 { + return id + } + return id[:12] +} diff --git a/backend/internal/project/memory_store.go b/backend/internal/project/memory_store.go new file mode 100644 index 0000000000..945a78268b --- /dev/null +++ b/backend/internal/project/memory_store.go @@ -0,0 +1,108 @@ +package project + +import ( + "context" + "sync" + "time" +) + +// ProjectRow mirrors the project table shape from the sqlite storage PR. The +// memory store is intentionally row-based so the API layer does not depend on a +// richer mock model than the real DB will provide. +type ProjectRow struct { + ID string + Path string + RepoOriginURL string + DisplayName string + RegisteredAt time.Time + ArchivedAt time.Time +} + +type Store interface { + List(ctx context.Context) ([]ProjectRow, error) + Get(ctx context.Context, id string) (ProjectRow, bool, error) + FindByPath(ctx context.Context, path string) (ProjectRow, bool, error) + Upsert(ctx context.Context, row ProjectRow) error + Archive(ctx context.Context, id string, at time.Time) (bool, error) +} + +// MemoryStore is the mocked DB layer for the project API implementation. It is +// process-local and intentionally small, but concurrency-safe for HTTP tests. +type MemoryStore struct { + mu sync.Mutex + projects map[string]ProjectRow + paths map[string]string +} + +var _ Store = (*MemoryStore)(nil) + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + projects: map[string]ProjectRow{}, + paths: map[string]string{}, + } +} + +func (s *MemoryStore) List(context.Context) ([]ProjectRow, error) { + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]ProjectRow, 0, len(s.projects)) + for _, row := range s.projects { + if row.ArchivedAt.IsZero() { + out = append(out, row) + } + } + return out, nil +} + +func (s *MemoryStore) Get(_ context.Context, id string) (ProjectRow, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + row, ok := s.projects[id] + if !ok { + return ProjectRow{}, false, nil + } + return row, true, nil +} + +func (s *MemoryStore) FindByPath(_ context.Context, path string) (ProjectRow, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + id, ok := s.paths[path] + if !ok { + return ProjectRow{}, false, nil + } + row, ok := s.projects[id] + if !ok { + return ProjectRow{}, false, nil + } + return row, true, nil +} + +func (s *MemoryStore) Upsert(_ context.Context, row ProjectRow) error { + s.mu.Lock() + defer s.mu.Unlock() + + if existing, ok := s.projects[row.ID]; ok && existing.Path != row.Path { + delete(s.paths, existing.Path) + } + s.projects[row.ID] = row + s.paths[row.Path] = row.ID + return nil +} + +func (s *MemoryStore) Archive(_ context.Context, id string, at time.Time) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + row, ok := s.projects[id] + if !ok { + return false, nil + } + row.ArchivedAt = at + s.projects[id] = row + return true, nil +} diff --git a/backend/internal/project/project.go b/backend/internal/project/project.go new file mode 100644 index 0000000000..a997519dff --- /dev/null +++ b/backend/internal/project/project.go @@ -0,0 +1,44 @@ +// Package project owns the projects service contract: the Manager interface +// the HTTP layer calls and the request/response DTOs that cross it (dto.go). +// +// This is the pilot for the feature-package layout the backend is migrating +// toward: a resource's interface and DTOs live with the resource, not in a +// central catch-all. Controllers depend on project.Manager and nothing +// beneath it — whether the implementation reaches into the config registry, +// the lifecycle manager (to stop sessions on remove), or a workspace adapter +// (to destroy worktrees) is a private concern of the impl, which lands in a +// later handler-impl PR. This PR defines only the contract. +package project + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// Manager is the inbound contract for the /api/v1/projects surface. One +// implementation (this package, later); the HTTP controller is the consumer. +type Manager interface { + // List returns every registered project, including degraded entries + // (those whose config failed to load but whose registry entry survives). + List(ctx context.Context) ([]Summary, error) + + // Get returns one project, discriminating ok vs degraded via GetResult. + Get(ctx context.Context, id domain.ProjectID) (GetResult, error) + + // Add registers a new project from a git repository path. + Add(ctx context.Context, in AddInput) (Project, error) + + // UpdateConfig patches behaviour-only fields; identity fields are frozen. + UpdateConfig(ctx context.Context, id domain.ProjectID, patch UpdateConfigInput) (Project, error) + + // Remove unregisters a project, stopping its sessions and reclaiming + // managed workspaces. + Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) + + // Repair recovers a degraded project where automatic repair is available. + Repair(ctx context.Context, id domain.ProjectID) (Project, error) + + // Reload invalidates cached config and re-scans the global registry. + Reload(ctx context.Context) (ReloadResult, error) +} diff --git a/backend/internal/project/types.go b/backend/internal/project/types.go new file mode 100644 index 0000000000..65e5daa290 --- /dev/null +++ b/backend/internal/project/types.go @@ -0,0 +1,96 @@ +package project + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// Project entities and the behaviour-config shapes they expose. These live in +// the project package (not domain/) because they are owned solely by the +// projects surface — only project identity (domain.ProjectID) is shared +// vocabulary with sessions/lifecycle/workspace, so that one type stays in +// domain. Keeping the entities, the Manager interface (project.go), and the +// transport DTOs (dto.go) together is the feature-package layout the backend +// is migrating toward. + +// Summary is the row shape returned by GET /api/v1/projects. It mirrors the TS +// ProjectInfo (packages/web/src/lib/project-name.ts) so the existing dashboard +// list view reads the Go daemon's response unchanged. ResolveError is set only +// for degraded projects (registry entry survives but config failed to load), +// so the list shows them with a warning instead of dropping them silently. +type Summary struct { + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + SessionPrefix string `json:"sessionPrefix"` + ResolveError string `json:"resolveError,omitempty"` +} + +// Project is the full read-model returned by GET /api/v1/projects/{id} when the +// project resolves cleanly. It joins the registry identity fields with the +// project's behaviour config. +type Project struct { + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Repo string `json:"repo"` // "owner/name" or "" + DefaultBranch string `json:"defaultBranch"` + Agent string `json:"agent,omitempty"` + Runtime string `json:"runtime,omitempty"` + Tracker *TrackerConfig `json:"tracker,omitempty"` + SCM *SCMConfig `json:"scm,omitempty"` + Reactions map[string]*ReactionConfig `json:"reactions,omitempty"` +} + +// Degraded is returned in place of Project when the project's config failed to +// load. The frontend uses ResolveError to render a recovery UI; the +// /projects/{id}/repair endpoint fixes a recoverable subset (e.g. legacy +// wrapped-config format). +type Degraded struct { + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + ResolveError string `json:"resolveError"` +} + +// Behaviour-config shapes ported from the TS Zod schemas (packages/core/src/ +// config.ts). Only the fields the projects API actually exposes are modelled; +// the passthrough/unknown-key round-trip the legacy schemas allowed lands with +// the handler implementation (and the SQLite persistence work), not in this +// interface-only PR. + +// TrackerConfig mirrors TrackerConfigSchema. +type TrackerConfig struct { + Plugin string `json:"plugin,omitempty"` + Package string `json:"package,omitempty"` + Path string `json:"path,omitempty"` +} + +// SCMConfig mirrors SCMConfigSchema; Webhook nests its own optional block. +type SCMConfig struct { + Plugin string `json:"plugin,omitempty"` + Package string `json:"package,omitempty"` + Path string `json:"path,omitempty"` + Webhook *SCMWebhookConfig `json:"webhook,omitempty"` +} + +// SCMWebhookConfig — pointer Enabled distinguishes unset from explicit false. +type SCMWebhookConfig struct { + Enabled *bool `json:"enabled,omitempty"` + Path string `json:"path,omitempty"` + SecretEnvVar string `json:"secretEnvVar,omitempty"` + SignatureHeader string `json:"signatureHeader,omitempty"` + EventHeader string `json:"eventHeader,omitempty"` + DeliveryHeader string `json:"deliveryHeader,omitempty"` + MaxBodyBytes int `json:"maxBodyBytes,omitempty"` +} + +// ReactionConfig mirrors ReactionConfigSchema. EscalateAfter is either ms +// (number) or a duration string ("30m") in the TS schema, so it stays open as +// `any` until handler validation lands. +type ReactionConfig struct { + Auto *bool `json:"auto,omitempty"` + Action string `json:"action,omitempty"` // send-to-agent | notify | auto-merge + Message string `json:"message,omitempty"` + Priority string `json:"priority,omitempty"` // urgent | action | warning | info + Retries *int `json:"retries,omitempty"` + EscalateAfter any `json:"escalateAfter,omitempty"` + Threshold string `json:"threshold,omitempty"` + IncludeSummary *bool `json:"includeSummary,omitempty"` +} diff --git a/backend/internal/runfile/rename_windows.go b/backend/internal/runfile/rename_windows.go index 031411eeed..70f5d1dedb 100644 --- a/backend/internal/runfile/rename_windows.go +++ b/backend/internal/runfile/rename_windows.go @@ -2,13 +2,18 @@ package runfile -import "syscall" +import ( + "syscall" + "unsafe" +) // movefileReplaceExisting tells MoveFileEx to overwrite dst if it already // exists. Mirrors MOVEFILE_REPLACE_EXISTING from the Win32 API; declared // locally so we don't pull in golang.org/x/sys for a single constant. const movefileReplaceExisting = 0x1 +var moveFileExW = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW") + // atomicReplace renames src to dst, replacing dst if it exists. Go's // os.Rename on Windows happens to do the same MoveFileEx call internally, // but calling it directly makes the cross-platform contract explicit instead @@ -24,5 +29,13 @@ func atomicReplace(src, dst string) error { if err != nil { return err } - return syscall.MoveFileEx(srcPtr, dstPtr, movefileReplaceExisting) + ret, _, err := moveFileExW.Call( + uintptr(unsafe.Pointer(srcPtr)), + uintptr(unsafe.Pointer(dstPtr)), + uintptr(movefileReplaceExisting), + ) + if ret == 0 { + return err + } + return nil } From 67f42150b40cce8710f4f09308b16d6569aaffb4 Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Sun, 31 May 2026 20:46:12 +0530 Subject: [PATCH 070/250] fix(terminal): make creackPTY.Close idempotent to avoid shutdown deadlock The session run loop closes the PTY after copyOut returns, and session.close (via Manager.Close) closes the same PTY again. creackPTY.Close called cmd.Wait each time, and a second concurrent Wait on the same process blocks forever, so daemon shutdown deadlocked whenever a terminal was still attached. fakePTY is idempotent via sync.Once, so the unit suite never exercised this; a real tmux attach surfaced it. Guard close+kill+wait with a sync.Once so Wait runs exactly once. Add a regression test that double-closes a real PTY under a watchdog. Co-Authored-By: Claude Opus 4.7 --- backend/internal/terminal/pty_unix.go | 26 +++++++++++------ backend/internal/terminal/pty_unix_test.go | 33 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 backend/internal/terminal/pty_unix_test.go diff --git a/backend/internal/terminal/pty_unix.go b/backend/internal/terminal/pty_unix.go index 4849215bec..e5ca6f34ba 100644 --- a/backend/internal/terminal/pty_unix.go +++ b/backend/internal/terminal/pty_unix.go @@ -7,6 +7,7 @@ import ( "errors" "os" "os/exec" + "sync" "github.com/creack/pty" ) @@ -27,8 +28,10 @@ func defaultSpawn(ctx context.Context, argv []string) (ptyProcess, error) { } type creackPTY struct { - f *os.File - cmd *exec.Cmd + f *os.File + cmd *exec.Cmd + closeOnce sync.Once + closeErr error } func (p *creackPTY) Read(b []byte) (int, error) { return p.f.Read(b) } @@ -42,11 +45,18 @@ func (p *creackPTY) Wait() error { return p.cmd.Wait() } // Close stops the attach process and releases the PTY. tmux attach exits cleanly // when the master closes, but kill the process to be sure it does not linger. +// +// It is idempotent: both the session run loop (after copyOut returns) and +// session.close (via Manager.Close) call Close on the same PTY, and cmd.Wait +// must run exactly once. A second concurrent Wait on the same process blocks +// forever, deadlocking daemon shutdown when a terminal is still attached. func (p *creackPTY) Close() error { - closeErr := p.f.Close() - if p.cmd.Process != nil { - _ = p.cmd.Process.Kill() - } - _ = p.cmd.Wait() - return closeErr + p.closeOnce.Do(func() { + p.closeErr = p.f.Close() + if p.cmd.Process != nil { + _ = p.cmd.Process.Kill() + } + _ = p.cmd.Wait() + }) + return p.closeErr } diff --git a/backend/internal/terminal/pty_unix_test.go b/backend/internal/terminal/pty_unix_test.go new file mode 100644 index 0000000000..7d8c04ff5d --- /dev/null +++ b/backend/internal/terminal/pty_unix_test.go @@ -0,0 +1,33 @@ +//go:build !windows + +package terminal + +import ( + "context" + "testing" + "time" +) + +// TestCreackPTYCloseIsIdempotent guards the shutdown deadlock: the session run +// loop and session.close both call Close on the same PTY, so cmd.Wait must run +// exactly once. Without the sync.Once a second Wait blocks forever, so this test +// would hang (caught by the watchdog) rather than fail. +func TestCreackPTYCloseIsIdempotent(t *testing.T) { + p, err := defaultSpawn(context.Background(), []string{"/bin/sh", "-c", "sleep 30"}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + + done := make(chan struct{}) + go func() { + _ = p.Close() + _ = p.Close() // second close must not block on a second cmd.Wait + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("creackPTY.Close did not return: double Close deadlocked on cmd.Wait") + } +} From a766a80f76b48076cda26ed00e56f783ddf10905 Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Sun, 31 May 2026 23:04:15 +0530 Subject: [PATCH 071/250] fix(terminal): keep open re-servable after a pane exits Opening a terminal whose session has exited left c.terms[id] set to a no-op (already-exited path) or to a never-cleared unsubscribe (exit after open), so the open guard silently dropped every later open for that id on the connection until close/reconnect. Clients also saw exited/data before the opened ack. Ack opened before subscribe so it always precedes replay/data/exited; have subscribe report whether the pane was already terminal and skip registering in that case; and clear the connection entry from the exit callback for panes that exit after open. Co-Authored-By: Claude Opus 4.7 --- backend/internal/terminal/manager.go | 23 ++++++- backend/internal/terminal/manager_test.go | 75 +++++++++++++++++++++++ backend/internal/terminal/session.go | 10 +-- 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/backend/internal/terminal/manager.go b/backend/internal/terminal/manager.go index 986d9d921f..6f0133e3c9 100644 --- a/backend/internal/terminal/manager.go +++ b/backend/internal/terminal/manager.go @@ -225,7 +225,14 @@ func (c *connState) openTerminal(_ context.Context, id string) { return } - unsub := s.subscribe( + // Ack before subscribing so opened always precedes the replay and any + // data/exited frames subscribe delivers (the single out channel preserves + // this order). Reversing it would let a reconnecting client with buffered + // content, or one opening an already-dead pane, see data/exited before the + // open acknowledgement. + c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgOpened}) + + unsub, exited := s.subscribe( func(data []byte) { c.enqueue(serverMsg{ Ch: chTerminal, @@ -236,12 +243,24 @@ func (c *connState) openTerminal(_ context.Context, id string) { }, func() { c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgExited}) + // Drop the connection's entry for this id when the pane exits so a + // later open is served instead of being dropped by the open guard. + // markExited fires this without s.mu held, so taking c.mu is safe. + c.mu.Lock() + delete(c.terms, id) + c.mu.Unlock() }, ) + // An already-exited session sent its exited frame from subscribe and has + // nothing to unsubscribe. Don't register it: leaving c.terms[id] set would + // trip the open guard above and silently drop every later open for this id + // on this connection (e.g. after the pane respawns) until close/reconnect. + if exited { + return + } c.mu.Lock() c.terms[id] = unsub c.mu.Unlock() - c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgOpened}) } func (c *connState) closeTerminal(id string) { diff --git a/backend/internal/terminal/manager_test.go b/backend/internal/terminal/manager_test.go index 71187ed7b1..b1d832f13c 100644 --- a/backend/internal/terminal/manager_test.go +++ b/backend/internal/terminal/manager_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) // fakeConn is an in-memory wsConn driven by channels. @@ -99,6 +100,80 @@ func TestServeOpenStreamsAndWritesTerminal(t *testing.T) { }) } +// nextTerminal returns the next frame on conn.out (no skipping), so callers can +// assert frame ordering rather than just presence. +func nextTerminal(t *testing.T, c *fakeConn) serverMsg { + t.Helper() + select { + case m := <-c.out: + return m + case <-time.After(time.Second): + t.Fatal("no frame within 1s") + return serverMsg{} + } +} + +// Opening a terminal whose session has already exited but is not yet reaped from +// m.sessions must (1) send opened before exited and (2) not register the noop +// unsubscribe, so a later open for the same id on this connection is still +// served instead of being silently dropped by the already-open guard. +func TestServeOpenAlreadyExitedSessionDoesNotBlockReopen(t *testing.T) { + src := &fakeSource{} + sp := &fakeSpawner{} + mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) + defer mgr.Close() + + exited := newSession("t1", ports.RuntimeHandle{ID: "t1"}, src, sp.spawn, testLogger()) + exited.markExited() + mgr.mu.Lock() + mgr.sessions["t1"] = exited + mgr.mu.Unlock() + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + if m := nextTerminal(t, conn); m.Type != msgOpened { + t.Fatalf("first frame = %q, want opened", m.Type) + } + if m := nextTerminal(t, conn); m.Type != msgExited { + t.Fatalf("second frame = %q, want exited", m.Type) + } + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + if m := nextTerminal(t, conn); m.Type != msgOpened { + t.Fatalf("re-open frame = %q, want opened (open was dropped, entry stuck)", m.Type) + } +} + +// A session that exits after being opened must clear its connection entry on +// exit, so a later open for the same id is served rather than dropped by the +// already-open guard. +func TestServeExitAfterOpenClearsEntryAllowingReopen(t *testing.T) { + src := &fakeSource{} + src.setAlive(false) // a dropped pty must not re-attach -> session exits + p := newFakePTY() + sp := &fakeSpawner{ptys: []*fakePTY{p}} + mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) + defer mgr.Close() + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go mgr.Serve(ctx, conn) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + recv(t, conn, chTerminal, msgOpened, time.Second) + + p.Close() // drop the pty; IsAlive false => session exits, no re-attach + recv(t, conn, chTerminal, msgExited, time.Second) + + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + recv(t, conn, chTerminal, msgOpened, 2*time.Second) +} + func TestServeRejectsOpenWithoutID(t *testing.T) { mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) defer mgr.Close() diff --git a/backend/internal/terminal/session.go b/backend/internal/terminal/session.go index d29d2f82b6..77fb514747 100644 --- a/backend/internal/terminal/session.go +++ b/backend/internal/terminal/session.go @@ -180,15 +180,17 @@ func reattachBackoff(failures int) time.Duration { // subscribe registers an output callback and an exit callback, replays the ring // buffer to the new subscriber, and returns an unsubscribe func. If the pane has -// already exited, onExit fires immediately. -func (s *session) subscribe(onData subscriber, onExit func()) (unsubscribe func()) { +// already exited, onExit fires immediately and exited is true; the caller must +// not treat the returned no-op unsubscribe as a live registration (there is +// nothing to track and re-opening must stay possible). +func (s *session) subscribe(onData subscriber, onExit func()) (unsubscribe func(), exited bool) { s.mu.Lock() if s.exited { s.mu.Unlock() if onExit != nil { onExit() } - return func() {} + return func() {}, true } id := s.nextSub s.nextSub++ @@ -215,7 +217,7 @@ func (s *session) subscribe(onData subscriber, onExit func()) (unsubscribe func( delete(s.subs, id) delete(s.exitSubs, id) s.mu.Unlock() - } + }, false } // deliver appends a chunk to the ring and fans it out to current subscribers as From 4f77062aedc0e387593fa6e5b18fbe8f47f7df56 Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Sun, 31 May 2026 23:09:16 +0530 Subject: [PATCH 072/250] fix(terminal): clear conn entry before sending exited frame The exit callback enqueued the exited frame before deleting c.terms[id], so a client reopening on receipt of exited could hit the open guard while the entry was still set and have its open dropped. Delete first so the cleared entry is visible by the time the client sees exited. Co-Authored-By: Claude Opus 4.7 --- backend/internal/terminal/manager.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/internal/terminal/manager.go b/backend/internal/terminal/manager.go index 6f0133e3c9..cda0a51fcd 100644 --- a/backend/internal/terminal/manager.go +++ b/backend/internal/terminal/manager.go @@ -242,13 +242,15 @@ func (c *connState) openTerminal(_ context.Context, id string) { }) }, func() { - c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgExited}) - // Drop the connection's entry for this id when the pane exits so a - // later open is served instead of being dropped by the open guard. - // markExited fires this without s.mu held, so taking c.mu is safe. + // Clear the connection's entry for this id before sending exited so + // a client that reopens the moment it sees exited finds no stale + // entry and is served instead of dropped by the open guard. Ordering + // the delete after the frame would race that reopen. markExited fires + // this without s.mu held, so taking c.mu is safe. c.mu.Lock() delete(c.terms, id) c.mu.Unlock() + c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgExited}) }, ) // An already-exited session sent its exited frame from subscribe and has From eda39a156a35e90a4b08f504cfcbf2f4356555c3 Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Sun, 31 May 2026 23:42:59 +0530 Subject: [PATCH 073/250] fix(terminal): guard subscribe-to-assign window in openTerminal A session can exit and run onExit (which deletes c.terms[id]) in the gap between subscribe returning exited=false and openTerminal assigning c.terms[id]. The delete is a no-op there since the key isn't set yet, so the later assign resurrects a stale entry for a dead pane, trapping every future open for that id on the connection. Re-apply the delete after the assign when onExit fired in the window, tracked by a c.mu-guarded flag. Add a stress regression test that races the exit against the assign. Co-Authored-By: Claude Opus 4.7 --- backend/internal/terminal/manager.go | 12 +++++++ backend/internal/terminal/manager_test.go | 40 +++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/backend/internal/terminal/manager.go b/backend/internal/terminal/manager.go index cda0a51fcd..895edb6f82 100644 --- a/backend/internal/terminal/manager.go +++ b/backend/internal/terminal/manager.go @@ -232,6 +232,11 @@ func (c *connState) openTerminal(_ context.Context, id string) { // open acknowledgement. c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgOpened}) + // exitFired guards the subscribe-to-assign window: the session can exit (and + // run onExit) at any point after subscribe returns exited=false, including + // before c.terms[id] is assigned below. onExit and the assign both read/write + // this flag and the map only under c.mu, so no atomic is needed. + var exitFired bool unsub, exited := s.subscribe( func(data []byte) { c.enqueue(serverMsg{ @@ -248,6 +253,7 @@ func (c *connState) openTerminal(_ context.Context, id string) { // the delete after the frame would race that reopen. markExited fires // this without s.mu held, so taking c.mu is safe. c.mu.Lock() + exitFired = true delete(c.terms, id) c.mu.Unlock() c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgExited}) @@ -262,6 +268,12 @@ func (c *connState) openTerminal(_ context.Context, id string) { } c.mu.Lock() c.terms[id] = unsub + // If onExit already ran in the subscribe-to-assign window its delete was a + // no-op (the key did not exist yet), so the assign above just resurrected a + // stale entry for a dead pane. Re-apply the delete while still holding c.mu. + if exitFired { + delete(c.terms, id) + } c.mu.Unlock() } diff --git a/backend/internal/terminal/manager_test.go b/backend/internal/terminal/manager_test.go index b1d832f13c..cd957ca723 100644 --- a/backend/internal/terminal/manager_test.go +++ b/backend/internal/terminal/manager_test.go @@ -174,6 +174,46 @@ func TestServeExitAfterOpenClearsEntryAllowingReopen(t *testing.T) { recv(t, conn, chTerminal, msgOpened, 2*time.Second) } +// The subscribe-to-assign window: a session can exit (running onExit, which +// deletes c.terms[id]) between subscribe returning exited=false and openTerminal +// assigning c.terms[id] = unsub. If the assign resurrects that entry without +// re-checking, the stale entry traps every later open for the id on this +// connection. Close the pty concurrently with the open (IsAlive false => no +// re-attach) so the exit races the assign across many iterations; every reopen +// must be served (opened), never silently dropped by the open guard. +func TestServeReopenAfterImmediateExitNeverStuck(t *testing.T) { + for i := 0; i < 400; i++ { + src := &fakeSource{} + src.setAlive(false) // dropped pty must not re-attach -> session exits + p := newFakePTY() // alive at subscribe; closed below to race the assign + sp := &fakeSpawner{ptys: []*fakePTY{p}} + mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0)) + + conn := newFakeConn() + ctx, cancel := context.WithCancel(context.Background()) + go mgr.Serve(ctx, conn) + + // Send the open and close the pty concurrently: the session's exit + // (onExit -> delete c.terms[id]) then races openTerminal's assign of + // c.terms[id] = unsub. On the iterations where exit lands in the + // subscribe-to-assign window, an unguarded assign resurrects a stale + // entry for the dead pane, trapping every later open for this id. + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + go p.Close() + + recv(t, conn, chTerminal, msgOpened, time.Second) + recv(t, conn, chTerminal, msgExited, time.Second) + + // The reopen must be served even when the first open's session exited in + // the subscribe-to-assign window. + conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen} + recv(t, conn, chTerminal, msgOpened, time.Second) + + cancel() + mgr.Close() + } +} + func TestServeRejectsOpenWithoutID(t *testing.T) { mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0)) defer mgr.Close() From 5303c51d29406d21fb6196373b5602483a2fb882 Mon Sep 17 00:00:00 2001 From: whoisasx Date: Mon, 1 Jun 2026 00:05:40 +0530 Subject: [PATCH 074/250] feat: add durable notification foundation --- backend/internal/cdc/event.go | 12 +- backend/internal/domain/notification.go | 44 ++ .../integration/lifecycle_sqlite_test.go | 139 ++++++ backend/internal/lifecycle/manager.go | 2 +- backend/internal/lifecycle/reactions.go | 36 +- backend/internal/notification/dedupe.go | 74 +++ backend/internal/notification/dedupe_test.go | 63 +++ backend/internal/notification/enqueuer.go | 50 ++ .../internal/notification/enqueuer_test.go | 38 ++ backend/internal/notification/payload.go | 65 +++ backend/internal/notification/renderer.go | 198 ++++++++ .../internal/notification/renderer_test.go | 133 +++++ backend/internal/ports/outbound.go | 38 +- backend/internal/storage/sqlite/db.go | 4 +- backend/internal/storage/sqlite/gen/models.go | 20 + .../storage/sqlite/gen/notifications.sql.go | 464 ++++++++++++++++++ .../internal/storage/sqlite/gen/querier.go | 10 + .../sqlite/migrations/0002_notifications.sql | 81 +++ .../storage/sqlite/notification_store.go | 242 +++++++++ .../storage/sqlite/notification_store_test.go | 232 +++++++++ backend/internal/storage/sqlite/pr_facts.go | 45 ++ .../storage/sqlite/queries/notifications.sql | 70 +++ backend/lifecycle_wiring.go | 16 +- backend/wiring_test.go | 6 +- 24 files changed, 2045 insertions(+), 37 deletions(-) create mode 100644 backend/internal/domain/notification.go create mode 100644 backend/internal/notification/dedupe.go create mode 100644 backend/internal/notification/dedupe_test.go create mode 100644 backend/internal/notification/enqueuer.go create mode 100644 backend/internal/notification/enqueuer_test.go create mode 100644 backend/internal/notification/payload.go create mode 100644 backend/internal/notification/renderer.go create mode 100644 backend/internal/notification/renderer_test.go create mode 100644 backend/internal/storage/sqlite/gen/notifications.sql.go create mode 100644 backend/internal/storage/sqlite/migrations/0002_notifications.sql create mode 100644 backend/internal/storage/sqlite/notification_store.go create mode 100644 backend/internal/storage/sqlite/notification_store_test.go create mode 100644 backend/internal/storage/sqlite/pr_facts.go create mode 100644 backend/internal/storage/sqlite/queries/notifications.sql diff --git a/backend/internal/cdc/event.go b/backend/internal/cdc/event.go index 04f52648f3..5d37f47e26 100644 --- a/backend/internal/cdc/event.go +++ b/backend/internal/cdc/event.go @@ -18,11 +18,13 @@ import ( type EventType string const ( - EventSessionCreated EventType = "session_created" - EventSessionUpdated EventType = "session_updated" - EventPRCreated EventType = "pr_created" - EventPRUpdated EventType = "pr_updated" - EventPRCheckRecorded EventType = "pr_check_recorded" + EventSessionCreated EventType = "session_created" + EventSessionUpdated EventType = "session_updated" + EventPRCreated EventType = "pr_created" + EventPRUpdated EventType = "pr_updated" + EventPRCheckRecorded EventType = "pr_check_recorded" + EventNotificationCreated EventType = "notification_created" + EventNotificationUpdated EventType = "notification_updated" ) // Event is one CDC change read from change_log. Seq is the monotonic ordering + diff --git a/backend/internal/domain/notification.go b/backend/internal/domain/notification.go new file mode 100644 index 0000000000..8c64c9bcde --- /dev/null +++ b/backend/internal/domain/notification.go @@ -0,0 +1,44 @@ +package domain + +import ( + "encoding/json" + "time" +) + +// NotificationID is the stable public identifier for a persisted notification. +type NotificationID string + +// Notification is the provider-neutral durable notification read model. It is +// sink-agnostic: desktop, dashboard, Slack, webhooks, etc. all consume the same +// semantic payload and actions later. +type Notification struct { + Seq int64 + ID NotificationID + ProjectID ProjectID + SessionID SessionID + Source string + EventType string + SemanticType string + Priority string + Message string + Payload json.RawMessage + Actions []NotificationAction + DedupeKey string + CauseKey string + ReadAt time.Time + ArchivedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// NotificationAction is a provider-neutral action descriptor. Renderers may use +// Route for app-local navigation, URL for external navigation, or Method for a +// future command/action endpoint. +type NotificationAction struct { + ID string `json:"id"` + Kind string `json:"kind"` + Label string `json:"label"` + Route string `json:"route,omitempty"` + URL string `json:"url,omitempty"` + Method string `json:"method,omitempty"` +} diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index 4774550801..c353bc6dc8 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -7,6 +7,8 @@ package integration import ( "context" + "io" + "log/slog" "path/filepath" "strings" "sync" @@ -16,6 +18,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/cdc" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/notification" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/session" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" @@ -268,6 +271,34 @@ func seedProject(t *testing.T, store *sqlite.Store, id string) { } } +func durableLifecycle(store *sqlite.Store, messenger ports.AgentMessenger) *lifecycle.Manager { + adapter := storeAdapter{store} + renderer := notification.NewRenderer(store) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + notifier := notification.NewEnqueuer(store, renderer, logger) + return lifecycle.New(adapter, adapter, notifier, messenger) +} + +func durableRecord(project, issue, branch string) domain.SessionRecord { + now := time.Now().UTC().Truncate(time.Second) + return domain.SessionRecord{ + ProjectID: domain.ProjectID(project), + IssueID: domain.IssueID(issue), + Kind: domain.KindWorker, + Lifecycle: domain.CanonicalSessionLifecycle{ + Version: domain.LifecycleVersion, + Session: domain.SessionSubstate{State: domain.SessionWorking}, + IsAlive: true, + Activity: domain.ActivitySubstate{ + State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceHook, + }, + }, + Metadata: domain.SessionMetadata{Branch: branch, WorkspacePath: "/workspace/" + branch}, + CreatedAt: now, + UpdatedAt: now, + } +} + // ---- tests ---- // TestHappyPath drives Spawn -> SCM PR observation (open + CI passing) -> Kill, @@ -653,6 +684,114 @@ func TestCDCPollerReceivesAllStages(t *testing.T) { } } +func TestLifecycleDurableNotification_NeedsInput(t *testing.T) { + t.Parallel() + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer store.Close() + seedProject(t, store, "mer") + rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-1", "feat/input")) + if err != nil { + t.Fatalf("create session: %v", err) + } + lcm := durableLifecycle(store, &captureMessenger{}) + startSeq, _ := store.MaxChangeLogSeq(ctx) + + if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{ + Valid: true, State: domain.ActivityWaitingInput, Source: domain.SourceHook, Timestamp: time.Now(), + }); err != nil { + t.Fatalf("activity: %v", err) + } + + notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10}) + if err != nil { + t.Fatalf("list notifications: %v", err) + } + if len(notifications) != 1 || notifications[0].SemanticType != "session.needs_input" || notifications[0].DedupeKey == "" { + t.Fatalf("needs_input notification missing: %+v", notifications) + } + assertNotificationCreatedCDC(t, store, startSeq) +} + +func TestLifecycleDurableNotification_ApprovedAndGreen(t *testing.T) { + t.Parallel() + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer store.Close() + seedProject(t, store, "mer") + rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-2", "feat/green")) + if err != nil { + t.Fatalf("create session: %v", err) + } + lcm := durableLifecycle(store, &captureMessenger{}) + + if err := lcm.ApplyPRObservation(ctx, rec.ID, ports.PRObservation{ + Fetched: true, URL: "https://github.com/org/repo/pull/2", Number: 2, + CI: domain.CIPassing, Review: domain.ReviewApproved, Mergeability: domain.MergeMergeable, + }); err != nil { + t.Fatalf("apply pr: %v", err) + } + notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10}) + if err != nil { + t.Fatalf("list notifications: %v", err) + } + if len(notifications) != 1 || notifications[0].SemanticType != "merge.ready" { + t.Fatalf("approved-and-green notification missing: %+v", notifications) + } +} + +func TestLifecycleDurableNotification_PRMerged(t *testing.T) { + t.Parallel() + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer store.Close() + seedProject(t, store, "mer") + rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-3", "feat/merge")) + if err != nil { + t.Fatalf("create session: %v", err) + } + lcm := durableLifecycle(store, &captureMessenger{}) + startSeq, _ := store.MaxChangeLogSeq(ctx) + + if err := lcm.ApplyPRObservation(ctx, rec.ID, ports.PRObservation{ + Fetched: true, URL: "https://github.com/org/repo/pull/3", Number: 3, Merged: true, + CI: domain.CIPassing, Review: domain.ReviewApproved, Mergeability: domain.MergeMergeable, + }); err != nil { + t.Fatalf("apply pr: %v", err) + } + notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10}) + if err != nil { + t.Fatalf("list notifications: %v", err) + } + if len(notifications) != 1 || notifications[0].SemanticType != "pr.merged" { + t.Fatalf("pr_merged notification missing: %+v", notifications) + } + assertNotificationCreatedCDC(t, store, startSeq) +} + +func assertNotificationCreatedCDC(t *testing.T, store *sqlite.Store, after int64) { + t.Helper() + evs, err := store.ReadChangeLogAfter(context.Background(), after, 20) + if err != nil { + t.Fatalf("read change_log: %v", err) + } + for _, e := range evs { + if e.EventType == string(cdc.EventNotificationCreated) { + return + } + } + t.Fatalf("missing notification_created CDC after %d: %+v", after, evs) +} + // ---- small helpers ---- type pollerSource struct{ *sqlite.Store } diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index f61d38b450..438a76c6f6 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -168,7 +168,7 @@ func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o } if changed { m.clearReactions(id) - return m.fireNotify(ctx, id, rec.ProjectID, reactions[rxMerged]) + return m.fireNotify(ctx, id, rec.ProjectID, rxMerged, reactions[rxMerged]) } return nil } diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index 94f149f4dc..44419aa651 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -208,7 +208,7 @@ func (m *Manager) dispatch(ctx context.Context, id domain.SessionID, project dom if cfg.toAgent { return m.fireAgentEntry(ctx, id, project, key, cfg) } - return m.fireNotify(ctx, id, project, cfg) + return m.fireNotify(ctx, id, project, key, cfg) } // reactionFor maps (session state, PR facts) to the reaction to enter. CI failure @@ -312,7 +312,11 @@ func (m *Manager) fireFeedback(ctx context.Context, id domain.SessionID, project m.react.mu.Lock() t.escalated = true m.react.mu.Unlock() - return m.escalate(ctx, id, pid, key) + cause := "max_attempts" + if key == rxCIFailed { + cause = "max_retries" + } + return m.escalate(ctx, id, pid, key, ports.EscalationEvent{Attempts: attempts, Cause: cause}) } return m.messenger.Send(ctx, id, message) } @@ -339,18 +343,28 @@ func (m *Manager) fireAgentEntry(ctx context.Context, id domain.SessionID, proje return m.messenger.Send(ctx, id, cfg.message) } -func (m *Manager) fireNotify(ctx context.Context, id domain.SessionID, project domain.ProjectID, cfg reactionConfig) error { +func (m *Manager) fireNotify(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, cfg reactionConfig) error { return m.notifier.Notify(ctx, ports.Event{ Type: cfg.eventType, Priority: cfg.priority, SessionID: id, ProjectID: project, Message: cfg.message, + Reaction: &ports.ReactionEvent{Key: string(key), Action: "notify"}, + CauseKey: string(key), + OccurredAt: m.clock(), }) } -func (m *Manager) escalate(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey) error { +func (m *Manager) escalate(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, esc ports.EscalationEvent) error { + if esc.Cause == "" { + esc.Cause = "max_attempts" + } return m.notifier.Notify(ctx, ports.Event{ Type: "reaction.escalated", Priority: ports.PriorityUrgent, SessionID: id, ProjectID: project, - Message: fmt.Sprintf("Automatic handling of %q is exhausted — needs a human.", key), + Message: fmt.Sprintf("Automatic handling of %q is exhausted — needs a human.", key), + Reaction: &ports.ReactionEvent{Key: string(key), Action: "escalated"}, + Escalation: &esc, + CauseKey: string(key) + ":" + esc.Cause, + OccurredAt: m.clock(), }) } @@ -358,9 +372,11 @@ func (m *Manager) escalate(ctx context.Context, id domain.SessionID, project dom // cannot wake itself for. The reaper calls it on a timer. func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { type due struct { - id domain.SessionID - project domain.ProjectID - key reactionKey + id domain.SessionID + project domain.ProjectID + key reactionKey + attempts int + durationMs int64 } var fire []due m.react.mu.Lock() @@ -371,13 +387,13 @@ func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { cfg := reactions[k.key] if cfg.escalateAfter > 0 && !t.firstAt.IsZero() && now.Sub(t.firstAt) >= cfg.escalateAfter { t.escalated = true - fire = append(fire, due{k.id, t.projectID, k.key}) + fire = append(fire, due{k.id, t.projectID, k.key, t.attempts, now.Sub(t.firstAt).Milliseconds()}) } } m.react.mu.Unlock() for _, d := range fire { - if err := m.escalate(ctx, d.id, d.project, d.key); err != nil { + if err := m.escalate(ctx, d.id, d.project, d.key, ports.EscalationEvent{Attempts: d.attempts, Cause: "max_duration", DurationMs: d.durationMs}); err != nil { return err } } diff --git a/backend/internal/notification/dedupe.go b/backend/internal/notification/dedupe.go new file mode 100644 index 0000000000..a4eaf32634 --- /dev/null +++ b/backend/internal/notification/dedupe.go @@ -0,0 +1,74 @@ +package notification + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// ConditionHash returns a deterministic, compact hash over a condition vector. +func ConditionHash(parts ...string) string { + b, _ := json.Marshal(parts) + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:16]) +} + +// DedupeKey returns the stable durable notification idempotency key. +func DedupeKey(projectID domain.ProjectID, sessionID domain.SessionID, reactionKey, conditionHash string) string { + return fmt.Sprintf("v1:lifecycle:%s:%s:%s:%s", projectID, sessionID, reactionKey, conditionHash) +} + +// ComputeDedupeKey derives a restart-safe dedupe key from the lifecycle event +// plus current persisted state. It avoids PR updated_at because re-polling the +// same facts after daemon restart would otherwise create duplicate notifications. +func ComputeDedupeKey(event ports.Event, rec domain.SessionRecord, pr domain.PRFacts) string { + projectID := event.ProjectID + if projectID == "" { + projectID = rec.ProjectID + } + reactionKey := reactionKeyForEvent(event) + condition := []string{ + "session_state", string(rec.Lifecycle.Session.State), + "termination", string(rec.Lifecycle.TerminationReason), + "session_updated", timeKey(rec.UpdatedAt), + } + if pr.Exists { + condition = append(condition, + "pr_url", pr.URL, + "pr_number", fmt.Sprint(pr.Number), + "pr_draft", fmt.Sprint(pr.Draft), + "pr_merged", fmt.Sprint(pr.Merged), + "pr_closed", fmt.Sprint(pr.Closed), + "ci", string(pr.CI), + "review", string(pr.Review), + "mergeability", string(pr.Mergeability), + "review_comments", fmt.Sprint(pr.ReviewComments), + ) + } + if event.CauseKey != "" { + condition = append(condition, "cause_key", event.CauseKey) + } + if event.Escalation != nil { + condition = append(condition, "escalation_cause", event.Escalation.Cause) + } + return DedupeKey(projectID, event.SessionID, reactionKey, ConditionHash(condition...)) +} + +func reactionKeyForEvent(event ports.Event) string { + if event.Reaction != nil && event.Reaction.Key != "" { + return event.Reaction.Key + } + return reactionKeyFromType(event.Type) +} + +func timeKey(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339Nano) +} diff --git a/backend/internal/notification/dedupe_test.go b/backend/internal/notification/dedupe_test.go new file mode 100644 index 0000000000..2730bc10ac --- /dev/null +++ b/backend/internal/notification/dedupe_test.go @@ -0,0 +1,63 @@ +package notification + +import ( + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestDedupeSameReactionConditionProducesSameKey(t *testing.T) { + rec := dedupeRecord("working", time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) + e := ports.Event{SessionID: "ao-1", Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"}} + + k1 := ComputeDedupeKey(e, rec, domain.PRFacts{}) + k2 := ComputeDedupeKey(e, rec, domain.PRFacts{}) + if k1 != k2 { + t.Fatalf("dedupe key unstable: %q != %q", k1, k2) + } +} + +func TestDedupeChangedConditionProducesNewKey(t *testing.T) { + e := ports.Event{SessionID: "ao-1", Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"}} + r1 := dedupeRecord("needs_input", time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) + r2 := dedupeRecord("needs_input", time.Date(2026, 1, 2, 3, 4, 6, 0, time.UTC)) + + if ComputeDedupeKey(e, r1, domain.PRFacts{}) == ComputeDedupeKey(e, r2, domain.PRFacts{}) { + t.Fatal("changed session updated timestamp should change dedupe key") + } +} + +func TestDedupeEscalationIncludesCauseAndDoesNotCollideWithBase(t *testing.T) { + rec := dedupeRecord("working", time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) + base := ports.Event{SessionID: "ao-1", Reaction: &ports.ReactionEvent{Key: "ci-failed", Action: "notify"}} + esc := ports.Event{ + SessionID: "ao-1", + Reaction: &ports.ReactionEvent{Key: "ci-failed", Action: "escalated"}, + Escalation: &ports.EscalationEvent{Attempts: 3, Cause: "max_retries"}, + } + otherCause := esc + otherCause.Escalation = &ports.EscalationEvent{Attempts: 3, Cause: "max_duration"} + + baseKey := ComputeDedupeKey(base, rec, domain.PRFacts{Exists: true, URL: "pr", CI: domain.CIFailing}) + escKey := ComputeDedupeKey(esc, rec, domain.PRFacts{Exists: true, URL: "pr", CI: domain.CIFailing}) + otherKey := ComputeDedupeKey(otherCause, rec, domain.PRFacts{Exists: true, URL: "pr", CI: domain.CIFailing}) + if baseKey == escKey { + t.Fatal("escalation dedupe key should not collide with base reaction") + } + if escKey == otherKey { + t.Fatal("escalation cause should affect dedupe key") + } +} + +func dedupeRecord(state domain.SessionState, updated time.Time) domain.SessionRecord { + return domain.SessionRecord{ + ID: "ao-1", + ProjectID: "ao", + Lifecycle: domain.CanonicalSessionLifecycle{ + Session: domain.SessionSubstate{State: state}, + }, + UpdatedAt: updated, + } +} diff --git a/backend/internal/notification/enqueuer.go b/backend/internal/notification/enqueuer.go new file mode 100644 index 0000000000..79e902bf84 --- /dev/null +++ b/backend/internal/notification/enqueuer.go @@ -0,0 +1,50 @@ +package notification + +import ( + "context" + "log/slog" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Store is the durable write-side used by the enqueuer. *sqlite.Store satisfies +// this interface. +type Store interface { + EnqueueNotification(ctx context.Context, row domain.Notification) (domain.Notification, bool, error) +} + +// Enqueuer is a store-backed ports.Notifier. It does not deliver to external +// sinks; it renders and persists the notification for later dashboard/app sinks. +type Enqueuer struct { + store Store + renderer *Renderer + logger *slog.Logger +} + +var _ ports.Notifier = (*Enqueuer)(nil) + +func NewEnqueuer(store Store, renderer *Renderer, logger *slog.Logger) *Enqueuer { + if logger == nil { + logger = slog.Default() + } + return &Enqueuer{store: store, renderer: renderer, logger: logger} +} + +func (e *Enqueuer) Notify(ctx context.Context, event ports.Event) error { + row, err := e.renderer.Render(ctx, event) + if err != nil { + return err + } + saved, created, err := e.store.EnqueueNotification(ctx, row) + if err != nil { + return err + } + e.logger.DebugContext(ctx, "notification enqueued", + "id", saved.ID, + "session", saved.SessionID, + "semantic_type", saved.SemanticType, + "created", created, + ) + return nil +} diff --git a/backend/internal/notification/enqueuer_test.go b/backend/internal/notification/enqueuer_test.go new file mode 100644 index 0000000000..1ed1446174 --- /dev/null +++ b/backend/internal/notification/enqueuer_test.go @@ -0,0 +1,38 @@ +package notification + +import ( + "context" + "io" + "log/slog" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type fakeNotificationStore struct { + row domain.Notification + created bool +} + +func (f *fakeNotificationStore) EnqueueNotification(_ context.Context, row domain.Notification) (domain.Notification, bool, error) { + f.row = row + f.created = true + return row, true, nil +} + +func TestEnqueuerRendersAndPersists(t *testing.T) { + store := &fakeNotificationStore{} + renderer := NewRenderer(fakeReader{rec: renderRecord()}) + enq := NewEnqueuer(store, renderer, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err := enq.Notify(context.Background(), ports.Event{ + Type: "reaction.agent-needs-input", Priority: ports.PriorityUrgent, + ProjectID: "ao", SessionID: "ao-7", Message: "needs input", + Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"}, + }); err != nil { + t.Fatal(err) + } + if !store.created || store.row.SemanticType != "session.needs_input" || store.row.DedupeKey == "" { + t.Fatalf("store row not rendered: created=%v row=%+v", store.created, store.row) + } +} diff --git a/backend/internal/notification/payload.go b/backend/internal/notification/payload.go new file mode 100644 index 0000000000..5492c19ce6 --- /dev/null +++ b/backend/internal/notification/payload.go @@ -0,0 +1,65 @@ +package notification + +// PayloadSchemaVersion is the durable notification payload contract version. +const PayloadSchemaVersion = 3 + +// Payload is the provider-neutral, rich notification data shape persisted in +// SQLite. It intentionally mirrors legacy AO's NotificationData V3 while only +// filling fields the Go rewrite can source today. +type Payload struct { + SchemaVersion int `json:"schemaVersion"` + SemanticType string `json:"semanticType"` + Subject SubjectPayload `json:"subject"` + Reaction *ReactionPayload `json:"reaction,omitempty"` + Escalation *EscalationPayload `json:"escalation,omitempty"` + CI *CIPayload `json:"ci,omitempty"` + Review *ReviewPayload `json:"review,omitempty"` + Merge *MergePayload `json:"merge,omitempty"` +} + +type SubjectPayload struct { + Session *SessionSubjectPayload `json:"session,omitempty"` + PR *PRSubjectPayload `json:"pr,omitempty"` + Issue *IssueSubjectPayload `json:"issue,omitempty"` + Branch string `json:"branch,omitempty"` +} + +type SessionSubjectPayload struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` +} + +type PRSubjectPayload struct { + Number int `json:"number,omitempty"` + URL string `json:"url,omitempty"` + Draft bool `json:"draft,omitempty"` +} + +type IssueSubjectPayload struct { + ID string `json:"id,omitempty"` +} + +type ReactionPayload struct { + Key string `json:"key"` + Action string `json:"action"` +} + +type EscalationPayload struct { + Attempts int `json:"attempts"` + Cause string `json:"cause"` + DurationMs int64 `json:"durationMs"` +} + +type CIPayload struct { + Status string `json:"status"` +} + +type ReviewPayload struct { + Decision string `json:"decision"` +} + +type MergePayload struct { + Ready *bool `json:"ready,omitempty"` + Conflicts *bool `json:"conflicts,omitempty"` + IsBehind *bool `json:"isBehind,omitempty"` +} diff --git a/backend/internal/notification/renderer.go b/backend/internal/notification/renderer.go new file mode 100644 index 0000000000..21d41e3702 --- /dev/null +++ b/backend/internal/notification/renderer.go @@ -0,0 +1,198 @@ +package notification + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Reader is the subset of durable state the renderer rehydrates. *sqlite.Store +// satisfies it directly. +type Reader interface { + GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) + PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) +} + +// Renderer converts lifecycle notification events into durable notification rows. +type Renderer struct { + reader Reader + clock func() time.Time +} + +func NewRenderer(reader Reader) *Renderer { + return &Renderer{reader: reader, clock: time.Now} +} + +func (r *Renderer) Render(ctx context.Context, event ports.Event) (domain.Notification, error) { + if event.SessionID == "" { + return domain.Notification{}, fmt.Errorf("render notification: missing session id") + } + rec, ok, err := r.reader.GetSession(ctx, event.SessionID) + if err != nil { + return domain.Notification{}, fmt.Errorf("render notification: get session %s: %w", event.SessionID, err) + } + if !ok { + return domain.Notification{}, fmt.Errorf("render notification: session %s not found", event.SessionID) + } + pr, err := r.reader.PRFactsForSession(ctx, event.SessionID) + if err != nil { + return domain.Notification{}, fmt.Errorf("render notification: pr facts for %s: %w", event.SessionID, err) + } + + projectID := event.ProjectID + if projectID == "" { + projectID = rec.ProjectID + } + reaction := reactionPayload(event) + semanticType := SemanticTypeForReaction(reaction.Key) + if semanticType == "" { + semanticType = event.Type + } + payload := Payload{ + SchemaVersion: PayloadSchemaVersion, + SemanticType: semanticType, + Subject: SubjectPayload{ + Session: &SessionSubjectPayload{ID: string(event.SessionID), ProjectID: string(projectID)}, + Branch: rec.Metadata.Branch, + }, + Reaction: &reaction, + } + if rec.IssueID != "" { + payload.Subject.Issue = &IssueSubjectPayload{ID: string(rec.IssueID)} + } + if pr.Exists { + payload.Subject.PR = &PRSubjectPayload{Number: pr.Number, URL: pr.URL, Draft: pr.Draft} + if pr.CI != "" { + payload.CI = &CIPayload{Status: string(pr.CI)} + } + if pr.Review != "" { + payload.Review = &ReviewPayload{Decision: string(pr.Review)} + } + payload.Merge = mergePayload(pr.Mergeability) + } + if event.Escalation != nil { + payload.Escalation = &EscalationPayload{ + Attempts: event.Escalation.Attempts, + Cause: event.Escalation.Cause, + DurationMs: event.Escalation.DurationMs, + } + } + + payloadJSON, err := json.Marshal(payload) + if err != nil { + return domain.Notification{}, fmt.Errorf("render notification payload: %w", err) + } + + occurredAt := event.OccurredAt + if occurredAt.IsZero() { + occurredAt = r.clock().UTC() + } + priority := string(event.Priority) + if priority == "" { + priority = string(ports.PriorityInfo) + } + dedupeKey := event.DedupeKey + if dedupeKey == "" { + dedupeKey = ComputeDedupeKey(event, rec, pr) + } + causeKey := event.CauseKey + if causeKey == "" { + causeKey = reaction.Key + if event.Escalation != nil && event.Escalation.Cause != "" { + causeKey += ":" + event.Escalation.Cause + } + } + + return domain.Notification{ + ProjectID: projectID, + SessionID: event.SessionID, + Source: "lifecycle", + EventType: event.Type, + SemanticType: semanticType, + Priority: priority, + Message: event.Message, + Payload: payloadJSON, + Actions: actionsFor(projectID, event.SessionID, pr), + DedupeKey: dedupeKey, + CauseKey: causeKey, + CreatedAt: occurredAt, + UpdatedAt: occurredAt, + }, nil +} + +func reactionPayload(event ports.Event) ReactionPayload { + key := reactionKeyFromType(event.Type) + action := "notify" + if event.Reaction != nil { + if event.Reaction.Key != "" { + key = event.Reaction.Key + } + if event.Reaction.Action != "" { + action = event.Reaction.Action + } + } + if event.Escalation != nil && event.Reaction == nil { + action = "escalated" + } + return ReactionPayload{Key: key, Action: action} +} + +func reactionKeyFromType(t string) string { + if strings.HasPrefix(t, "reaction.") { + return strings.TrimPrefix(t, "reaction.") + } + return t +} + +func mergePayload(m domain.Mergeability) *MergePayload { + if m == "" { + return nil + } + ready := m == domain.MergeMergeable + conflicts := m == domain.MergeConflicting + return &MergePayload{Ready: &ready, Conflicts: &conflicts} +} + +func actionsFor(projectID domain.ProjectID, sessionID domain.SessionID, pr domain.PRFacts) []domain.NotificationAction { + actions := []domain.NotificationAction{{ + ID: "open-session", + Kind: "route", + Label: "Open session", + Route: fmt.Sprintf("/projects/%s/sessions/%s", projectID, sessionID), + }} + if pr.Exists && pr.URL != "" { + actions = append(actions, domain.NotificationAction{ID: "open-pr", Kind: "url", Label: "Open PR", URL: pr.URL}) + } + return actions +} + +// SemanticTypeForReaction maps internal reaction keys to public semantic types. +func SemanticTypeForReaction(key string) string { + switch key { + case "approved-and-green": + return "merge.ready" + case "agent-stuck": + return "session.stuck" + case "agent-needs-input": + return "session.needs_input" + case "agent-exited": + return "session.exited" + case "pr-closed": + return "pr.closed" + case "pr-merged": + return "pr.merged" + case "ci-failed": + return "ci.failing" + case "review-comments": + return "review.changes_requested" + case "merge-conflicts": + return "merge.conflicts" + default: + return "" + } +} diff --git a/backend/internal/notification/renderer_test.go b/backend/internal/notification/renderer_test.go new file mode 100644 index 0000000000..4cf70c9722 --- /dev/null +++ b/backend/internal/notification/renderer_test.go @@ -0,0 +1,133 @@ +package notification + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type fakeReader struct { + rec domain.SessionRecord + pr domain.PRFacts +} + +func (f fakeReader) GetSession(context.Context, domain.SessionID) (domain.SessionRecord, bool, error) { + return f.rec, true, nil +} +func (f fakeReader) PRFactsForSession(context.Context, domain.SessionID) (domain.PRFacts, error) { + return f.pr, nil +} + +func TestSemanticTypeMapping(t *testing.T) { + cases := map[string]string{ + "approved-and-green": "merge.ready", + "agent-stuck": "session.stuck", + "agent-needs-input": "session.needs_input", + "agent-exited": "session.exited", + "pr-closed": "pr.closed", + "pr-merged": "pr.merged", + "ci-failed": "ci.failing", + "review-comments": "review.changes_requested", + "merge-conflicts": "merge.conflicts", + } + for key, want := range cases { + if got := SemanticTypeForReaction(key); got != want { + t.Fatalf("SemanticTypeForReaction(%q) = %q, want %q", key, got, want) + } + } +} + +func TestRendererPayloadIncludesSessionProjectIssueAndBranch(t *testing.T) { + r := NewRenderer(fakeReader{rec: renderRecord()}) + row, err := r.Render(context.Background(), ports.Event{ + Type: "reaction.agent-needs-input", Priority: ports.PriorityUrgent, + ProjectID: "ao", SessionID: "ao-7", Message: "needs input", + Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"}, + OccurredAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + }) + if err != nil { + t.Fatal(err) + } + var p Payload + if err := json.Unmarshal(row.Payload, &p); err != nil { + t.Fatal(err) + } + if p.SchemaVersion != 3 || p.SemanticType != "session.needs_input" { + t.Fatalf("payload header = %+v", p) + } + if p.Subject.Session == nil || p.Subject.Session.ID != "ao-7" || p.Subject.Session.ProjectID != "ao" { + t.Fatalf("session subject missing: %+v", p.Subject.Session) + } + if p.Subject.Issue == nil || p.Subject.Issue.ID != "AO-12" || p.Subject.Branch != "feat/example" { + t.Fatalf("issue/branch missing: %+v", p.Subject) + } +} + +func TestRendererPRPayloadIncludesFacts(t *testing.T) { + r := NewRenderer(fakeReader{rec: renderRecord(), pr: domain.PRFacts{ + Exists: true, URL: "https://github.com/org/repo/pull/12", Number: 12, + CI: domain.CIFailing, Review: domain.ReviewChangesRequest, Mergeability: domain.MergeConflicting, + }}) + row, err := r.Render(context.Background(), ports.Event{ + Type: "reaction.review-comments", Priority: ports.PriorityAction, + ProjectID: "ao", SessionID: "ao-7", Message: "review", + Reaction: &ports.ReactionEvent{Key: "review-comments", Action: "notify"}, + }) + if err != nil { + t.Fatal(err) + } + var p Payload + if err := json.Unmarshal(row.Payload, &p); err != nil { + t.Fatal(err) + } + if p.Subject.PR == nil || p.Subject.PR.URL != "https://github.com/org/repo/pull/12" || p.Subject.PR.Number != 12 { + t.Fatalf("pr subject missing: %+v", p.Subject.PR) + } + if p.CI == nil || p.CI.Status != "failing" { + t.Fatalf("ci missing: %+v", p.CI) + } + if p.Review == nil || p.Review.Decision != "changes_requested" { + t.Fatalf("review missing: %+v", p.Review) + } + if p.Merge == nil || p.Merge.Conflicts == nil || *p.Merge.Conflicts != true || p.Merge.Ready == nil || *p.Merge.Ready != false { + t.Fatalf("merge missing: %+v", p.Merge) + } +} + +func TestRendererEscalationPayloadIncludesDetails(t *testing.T) { + r := NewRenderer(fakeReader{rec: renderRecord()}) + row, err := r.Render(context.Background(), ports.Event{ + Type: "reaction.escalated", Priority: ports.PriorityUrgent, + ProjectID: "ao", SessionID: "ao-7", Message: "escalated", + Reaction: &ports.ReactionEvent{Key: "ci-failed", Action: "escalated"}, + Escalation: &ports.EscalationEvent{Attempts: 3, Cause: "max_retries", DurationMs: 42}, + }) + if err != nil { + t.Fatal(err) + } + var p Payload + if err := json.Unmarshal(row.Payload, &p); err != nil { + t.Fatal(err) + } + if p.Reaction == nil || p.Reaction.Key != "ci-failed" || p.Reaction.Action != "escalated" { + t.Fatalf("reaction missing: %+v", p.Reaction) + } + if p.Escalation == nil || p.Escalation.Attempts != 3 || p.Escalation.Cause != "max_retries" || p.Escalation.DurationMs != 42 { + t.Fatalf("escalation missing: %+v", p.Escalation) + } +} + +func renderRecord() domain.SessionRecord { + return domain.SessionRecord{ + ID: "ao-7", + ProjectID: "ao", + IssueID: "AO-12", + Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionNeedsInput}}, + Metadata: domain.SessionMetadata{Branch: "feat/example"}, + UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + } +} diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 75a24bf070..79c2042307 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -2,6 +2,7 @@ package ports import ( "context" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) @@ -46,18 +47,37 @@ type AgentMessenger interface { type Priority string const ( - PriorityUrgent Priority = "urgent" - PriorityAction Priority = "action" - PriorityInfo Priority = "info" + PriorityUrgent Priority = "urgent" + PriorityAction Priority = "action" + PriorityWarning Priority = "warning" + PriorityInfo Priority = "info" ) -// Event is a human-facing notification produced by a reaction. +// Event is a human-facing notification produced by a reaction. It carries the +// stable reaction/escalation context a durable notification renderer needs, +// while lifecycle remains responsible for deciding what should notify. type Event struct { - Type string - Priority Priority - SessionID domain.SessionID - ProjectID domain.ProjectID - Message string + Type string + Priority Priority + SessionID domain.SessionID + ProjectID domain.ProjectID + Message string + Reaction *ReactionEvent + Escalation *EscalationEvent + DedupeKey string + CauseKey string + OccurredAt time.Time +} + +type ReactionEvent struct { + Key string // agent-needs-input, approved-and-green, ci-failed, etc. + Action string // notify | escalated +} + +type EscalationEvent struct { + Attempts int + Cause string // max_retries | max_attempts | max_duration + DurationMs int64 } // ---- runtime / agent / workspace plugin ports (used by the Session Manager) ---- diff --git a/backend/internal/storage/sqlite/db.go b/backend/internal/storage/sqlite/db.go index 926d08d3e1..7f8535bfa7 100644 --- a/backend/internal/storage/sqlite/db.go +++ b/backend/internal/storage/sqlite/db.go @@ -1,5 +1,5 @@ -// Package sqlite is the durable persistence adapter: the 6-table schema (goose -// migrations), typed CRUD over sqlc-generated queries, and the read side of the +// Package sqlite is the durable persistence adapter: the goose-managed schema, +// typed CRUD over sqlc-generated queries, and the read side of the // trigger-driven CDC (it reads change_log; the DB triggers write it). package sqlite diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 0c5b5c913f..992c0ca03b 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -18,6 +18,26 @@ type ChangeLog struct { CreatedAt time.Time } +type Notification struct { + Seq int64 + ID string + ProjectID string + SessionID string + Source string + EventType string + SemanticType string + Priority string + Message string + PayloadJson string + ActionsJson string + DedupeKey string + CauseKey string + ReadAt sql.NullTime + ArchivedAt sql.NullTime + CreatedAt time.Time + UpdatedAt time.Time +} + type Pr struct { Url string SessionID string diff --git a/backend/internal/storage/sqlite/gen/notifications.sql.go b/backend/internal/storage/sqlite/gen/notifications.sql.go new file mode 100644 index 0000000000..7b2b5493d9 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/notifications.sql.go @@ -0,0 +1,464 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: notifications.sql + +package gen + +import ( + "context" + "database/sql" + "time" +) + +const archiveNotification = `-- name: ArchiveNotification :one +UPDATE notifications +SET archived_at = ?, updated_at = ? +WHERE id = ? AND archived_at IS NULL +RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +` + +type ArchiveNotificationParams struct { + ArchivedAt sql.NullTime + UpdatedAt time.Time + ID string +} + +func (q *Queries) ArchiveNotification(ctx context.Context, arg ArchiveNotificationParams) (Notification, error) { + row := q.db.QueryRowContext(ctx, archiveNotification, arg.ArchivedAt, arg.UpdatedAt, arg.ID) + var i Notification + err := row.Scan( + &i.Seq, + &i.ID, + &i.ProjectID, + &i.SessionID, + &i.Source, + &i.EventType, + &i.SemanticType, + &i.Priority, + &i.Message, + &i.PayloadJson, + &i.ActionsJson, + &i.DedupeKey, + &i.CauseKey, + &i.ReadAt, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getNotification = `-- name: GetNotification :one +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications WHERE id = ? +` + +func (q *Queries) GetNotification(ctx context.Context, id string) (Notification, error) { + row := q.db.QueryRowContext(ctx, getNotification, id) + var i Notification + err := row.Scan( + &i.Seq, + &i.ID, + &i.ProjectID, + &i.SessionID, + &i.Source, + &i.EventType, + &i.SemanticType, + &i.Priority, + &i.Message, + &i.PayloadJson, + &i.ActionsJson, + &i.DedupeKey, + &i.CauseKey, + &i.ReadAt, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getNotificationByDedupeKey = `-- name: GetNotificationByDedupeKey :one +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications WHERE dedupe_key = ? +` + +func (q *Queries) GetNotificationByDedupeKey(ctx context.Context, dedupeKey string) (Notification, error) { + row := q.db.QueryRowContext(ctx, getNotificationByDedupeKey, dedupeKey) + var i Notification + err := row.Scan( + &i.Seq, + &i.ID, + &i.ProjectID, + &i.SessionID, + &i.Source, + &i.EventType, + &i.SemanticType, + &i.Priority, + &i.Message, + &i.PayloadJson, + &i.ActionsJson, + &i.DedupeKey, + &i.CauseKey, + &i.ReadAt, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertNotification = `-- name: InsertNotification :one +INSERT INTO notifications ( + project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (dedupe_key) DO NOTHING +RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +` + +type InsertNotificationParams struct { + ProjectID string + SessionID string + Source string + EventType string + SemanticType string + Priority string + Message string + PayloadJson string + ActionsJson string + DedupeKey string + CauseKey string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotificationParams) (Notification, error) { + row := q.db.QueryRowContext(ctx, insertNotification, + arg.ProjectID, + arg.SessionID, + arg.Source, + arg.EventType, + arg.SemanticType, + arg.Priority, + arg.Message, + arg.PayloadJson, + arg.ActionsJson, + arg.DedupeKey, + arg.CauseKey, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Notification + err := row.Scan( + &i.Seq, + &i.ID, + &i.ProjectID, + &i.SessionID, + &i.Source, + &i.EventType, + &i.SemanticType, + &i.Priority, + &i.Message, + &i.PayloadJson, + &i.ActionsJson, + &i.DedupeKey, + &i.CauseKey, + &i.ReadAt, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listNotifications = `-- name: ListNotifications :many +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications +ORDER BY seq DESC +LIMIT ? +` + +func (q *Queries) ListNotifications(ctx context.Context, limit int64) ([]Notification, error) { + rows, err := q.db.QueryContext(ctx, listNotifications, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Notification{} + for rows.Next() { + var i Notification + if err := rows.Scan( + &i.Seq, + &i.ID, + &i.ProjectID, + &i.SessionID, + &i.Source, + &i.EventType, + &i.SemanticType, + &i.Priority, + &i.Message, + &i.PayloadJson, + &i.ActionsJson, + &i.DedupeKey, + &i.CauseKey, + &i.ReadAt, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listNotificationsByProject = `-- name: ListNotificationsByProject :many +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications +WHERE project_id = ? +ORDER BY seq DESC +LIMIT ? +` + +type ListNotificationsByProjectParams struct { + ProjectID string + Limit int64 +} + +func (q *Queries) ListNotificationsByProject(ctx context.Context, arg ListNotificationsByProjectParams) ([]Notification, error) { + rows, err := q.db.QueryContext(ctx, listNotificationsByProject, arg.ProjectID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Notification{} + for rows.Next() { + var i Notification + if err := rows.Scan( + &i.Seq, + &i.ID, + &i.ProjectID, + &i.SessionID, + &i.Source, + &i.EventType, + &i.SemanticType, + &i.Priority, + &i.Message, + &i.PayloadJson, + &i.ActionsJson, + &i.DedupeKey, + &i.CauseKey, + &i.ReadAt, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listNotificationsBySession = `-- name: ListNotificationsBySession :many +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications +WHERE session_id = ? +ORDER BY seq DESC +LIMIT ? +` + +type ListNotificationsBySessionParams struct { + SessionID string + Limit int64 +} + +func (q *Queries) ListNotificationsBySession(ctx context.Context, arg ListNotificationsBySessionParams) ([]Notification, error) { + rows, err := q.db.QueryContext(ctx, listNotificationsBySession, arg.SessionID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Notification{} + for rows.Next() { + var i Notification + if err := rows.Scan( + &i.Seq, + &i.ID, + &i.ProjectID, + &i.SessionID, + &i.Source, + &i.EventType, + &i.SemanticType, + &i.Priority, + &i.Message, + &i.PayloadJson, + &i.ActionsJson, + &i.DedupeKey, + &i.CauseKey, + &i.ReadAt, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUnreadNotifications = `-- name: ListUnreadNotifications :many +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications +WHERE read_at IS NULL AND archived_at IS NULL +ORDER BY seq DESC +LIMIT ? +` + +func (q *Queries) ListUnreadNotifications(ctx context.Context, limit int64) ([]Notification, error) { + rows, err := q.db.QueryContext(ctx, listUnreadNotifications, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Notification{} + for rows.Next() { + var i Notification + if err := rows.Scan( + &i.Seq, + &i.ID, + &i.ProjectID, + &i.SessionID, + &i.Source, + &i.EventType, + &i.SemanticType, + &i.Priority, + &i.Message, + &i.PayloadJson, + &i.ActionsJson, + &i.DedupeKey, + &i.CauseKey, + &i.ReadAt, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markNotificationRead = `-- name: MarkNotificationRead :one +UPDATE notifications +SET read_at = ?, updated_at = ? +WHERE id = ? AND read_at IS NULL +RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +` + +type MarkNotificationReadParams struct { + ReadAt sql.NullTime + UpdatedAt time.Time + ID string +} + +func (q *Queries) MarkNotificationRead(ctx context.Context, arg MarkNotificationReadParams) (Notification, error) { + row := q.db.QueryRowContext(ctx, markNotificationRead, arg.ReadAt, arg.UpdatedAt, arg.ID) + var i Notification + err := row.Scan( + &i.Seq, + &i.ID, + &i.ProjectID, + &i.SessionID, + &i.Source, + &i.EventType, + &i.SemanticType, + &i.Priority, + &i.Message, + &i.PayloadJson, + &i.ActionsJson, + &i.DedupeKey, + &i.CauseKey, + &i.ReadAt, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const markNotificationUnread = `-- name: MarkNotificationUnread :one +UPDATE notifications +SET read_at = NULL, updated_at = ? +WHERE id = ? AND read_at IS NOT NULL +RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +` + +type MarkNotificationUnreadParams struct { + UpdatedAt time.Time + ID string +} + +func (q *Queries) MarkNotificationUnread(ctx context.Context, arg MarkNotificationUnreadParams) (Notification, error) { + row := q.db.QueryRowContext(ctx, markNotificationUnread, arg.UpdatedAt, arg.ID) + var i Notification + err := row.Scan( + &i.Seq, + &i.ID, + &i.ProjectID, + &i.SessionID, + &i.Source, + &i.EventType, + &i.SemanticType, + &i.Priority, + &i.Message, + &i.PayloadJson, + &i.ActionsJson, + &i.DedupeKey, + &i.CauseKey, + &i.ReadAt, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/backend/internal/storage/sqlite/gen/querier.go b/backend/internal/storage/sqlite/gen/querier.go index 365113b1e2..4f91a9d544 100644 --- a/backend/internal/storage/sqlite/gen/querier.go +++ b/backend/internal/storage/sqlite/gen/querier.go @@ -9,21 +9,31 @@ import ( ) type Querier interface { + ArchiveNotification(ctx context.Context, arg ArchiveNotificationParams) (Notification, error) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error DeletePR(ctx context.Context, url string) error DeletePRComments(ctx context.Context, prUrl string) error DeleteSession(ctx context.Context, id string) error + GetNotification(ctx context.Context, id string) (Notification, error) + GetNotificationByDedupeKey(ctx context.Context, dedupeKey string) (Notification, error) GetPR(ctx context.Context, url string) (Pr, error) GetProject(ctx context.Context, id string) (Project, error) GetSession(ctx context.Context, id string) (Session, error) + InsertNotification(ctx context.Context, arg InsertNotificationParams) (Notification, error) InsertSession(ctx context.Context, arg InsertSessionParams) error ListAllSessions(ctx context.Context) ([]Session, error) ListChecksByPR(ctx context.Context, prUrl string) ([]PrCheck, error) + ListNotifications(ctx context.Context, limit int64) ([]Notification, error) + ListNotificationsByProject(ctx context.Context, arg ListNotificationsByProjectParams) ([]Notification, error) + ListNotificationsBySession(ctx context.Context, arg ListNotificationsBySessionParams) ([]Notification, error) ListPRComments(ctx context.Context, prUrl string) ([]PrComment, error) ListPRsBySession(ctx context.Context, sessionID string) ([]Pr, error) ListProjects(ctx context.Context) ([]Project, error) ListRecentChecks(ctx context.Context, arg ListRecentChecksParams) ([]ListRecentChecksRow, error) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) + ListUnreadNotifications(ctx context.Context, limit int64) ([]Notification, error) + MarkNotificationRead(ctx context.Context, arg MarkNotificationReadParams) (Notification, error) + MarkNotificationUnread(ctx context.Context, arg MarkNotificationUnreadParams) (Notification, error) MaxChangeLogSeq(ctx context.Context) (interface{}, error) NextSessionNum(ctx context.Context, projectID string) (int64, error) ReadChangeLogAfter(ctx context.Context, arg ReadChangeLogAfterParams) ([]ChangeLog, error) diff --git a/backend/internal/storage/sqlite/migrations/0002_notifications.sql b/backend/internal/storage/sqlite/migrations/0002_notifications.sql new file mode 100644 index 0000000000..1ab12f5b7e --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0002_notifications.sql @@ -0,0 +1,81 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE notifications ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE DEFAULT ('ntf_' || lower(hex(randomblob(16)))), + project_id TEXT NOT NULL REFERENCES projects(id), + session_id TEXT NOT NULL REFERENCES sessions(id), + source TEXT NOT NULL DEFAULT 'lifecycle' CHECK (source IN ('lifecycle')), + event_type TEXT NOT NULL, + semantic_type TEXT NOT NULL, + priority TEXT NOT NULL CHECK (priority IN ('urgent','action','warning','info')), + message TEXT NOT NULL, + payload_json TEXT NOT NULL CHECK (json_valid(payload_json)), + actions_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(actions_json)), + dedupe_key TEXT NOT NULL UNIQUE, + cause_key TEXT NOT NULL DEFAULT '', + read_at TIMESTAMP, + archived_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_notifications_project_seq ON notifications(project_id, seq DESC); +CREATE INDEX idx_notifications_session_seq ON notifications(session_id, seq DESC); +CREATE INDEX idx_notifications_unread ON notifications(seq DESC) + WHERE read_at IS NULL AND archived_at IS NULL; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER notifications_cdc_insert +AFTER INSERT ON notifications +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + NEW.project_id, + NEW.session_id, + 'notification_created', + json_object( + 'seq', NEW.seq, + 'id', NEW.id, + 'type', NEW.semantic_type, + 'priority', NEW.priority, + 'message', NEW.message, + 'data', json(NEW.payload_json), + 'actions', json(NEW.actions_json), + 'readAt', NEW.read_at, + 'archivedAt', NEW.archived_at + ), + NEW.created_at + ); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER notifications_cdc_update +AFTER UPDATE ON notifications +WHEN OLD.read_at IS NOT NEW.read_at + OR OLD.archived_at IS NOT NEW.archived_at +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + NEW.project_id, + NEW.session_id, + 'notification_updated', + json_object( + 'seq', NEW.seq, + 'id', NEW.id, + 'readAt', NEW.read_at, + 'archivedAt', NEW.archived_at + ), + NEW.updated_at + ); +END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TRIGGER IF EXISTS notifications_cdc_update; +DROP TRIGGER IF EXISTS notifications_cdc_insert; +DROP TABLE IF EXISTS notifications; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/notification_store.go b/backend/internal/storage/sqlite/notification_store.go new file mode 100644 index 0000000000..90b84331c7 --- /dev/null +++ b/backend/internal/storage/sqlite/notification_store.go @@ -0,0 +1,242 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// NotificationRow is the storage-facing notification row. It aliases the +// provider-neutral domain type so callers do not depend on sqlc structs. +type NotificationRow = domain.Notification + +// NotificationFilter constrains ListNotifications. A zero filter returns the +// newest notifications across projects. +type NotificationFilter struct { + ProjectID string + SessionID string + UnreadOnly bool + Limit int +} + +const defaultNotificationLimit = 100 + +// EnqueueNotification inserts a notification exactly once per dedupe key. The +// returned bool is true when a new row was created; false means the existing row +// for the same dedupe key was returned. +func (s *Store) EnqueueNotification(ctx context.Context, row NotificationRow) (NotificationRow, bool, error) { + row = normalizeNotification(row) + actionsJSON, err := json.Marshal(row.Actions) + if err != nil { + return NotificationRow{}, false, fmt.Errorf("marshal notification actions: %w", err) + } + + s.writeMu.Lock() + defer s.writeMu.Unlock() + + got, err := s.qw.InsertNotification(ctx, gen.InsertNotificationParams{ + ProjectID: string(row.ProjectID), + SessionID: string(row.SessionID), + Source: row.Source, + EventType: row.EventType, + SemanticType: row.SemanticType, + Priority: row.Priority, + Message: row.Message, + PayloadJson: string(row.Payload), + ActionsJson: string(actionsJSON), + DedupeKey: row.DedupeKey, + CauseKey: row.CauseKey, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }) + if errors.Is(err, sql.ErrNoRows) { + existing, readErr := s.qw.GetNotificationByDedupeKey(ctx, row.DedupeKey) + if readErr != nil { + return NotificationRow{}, false, fmt.Errorf("get notification by dedupe %q: %w", row.DedupeKey, readErr) + } + mapped, mapErr := notificationFromGen(existing) + return mapped, false, mapErr + } + if err != nil { + return NotificationRow{}, false, fmt.Errorf("insert notification: %w", err) + } + mapped, err := notificationFromGen(got) + return mapped, true, err +} + +// GetNotification returns one notification by id, or ok=false if absent. +func (s *Store) GetNotification(ctx context.Context, id string) (NotificationRow, bool, error) { + row, err := s.qr.GetNotification(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return NotificationRow{}, false, nil + } + if err != nil { + return NotificationRow{}, false, fmt.Errorf("get notification %s: %w", id, err) + } + mapped, err := notificationFromGen(row) + return mapped, true, err +} + +// ListNotifications returns notifications in descending seq order. +func (s *Store) ListNotifications(ctx context.Context, filter NotificationFilter) ([]NotificationRow, error) { + limit := int64(filter.Limit) + if limit <= 0 { + limit = defaultNotificationLimit + } + + var ( + rows []gen.Notification + err error + ) + switch { + case filter.UnreadOnly: + rows, err = s.qr.ListUnreadNotifications(ctx, limit) + case filter.SessionID != "": + rows, err = s.qr.ListNotificationsBySession(ctx, gen.ListNotificationsBySessionParams{SessionID: filter.SessionID, Limit: limit}) + case filter.ProjectID != "": + rows, err = s.qr.ListNotificationsByProject(ctx, gen.ListNotificationsByProjectParams{ProjectID: filter.ProjectID, Limit: limit}) + default: + rows, err = s.qr.ListNotifications(ctx, limit) + } + if err != nil { + return nil, fmt.Errorf("list notifications: %w", err) + } + return notificationsFromGen(rows) +} + +// MarkNotificationRead marks an unread notification read. The returned bool is +// true only when the row changed; repeated calls return the existing row with +// changed=false and emit no CDC update. +func (s *Store) MarkNotificationRead(ctx context.Context, id string, at time.Time) (NotificationRow, bool, error) { + if at.IsZero() { + at = time.Now().UTC() + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + + row, err := s.qw.MarkNotificationRead(ctx, gen.MarkNotificationReadParams{ + ReadAt: sql.NullTime{Time: at, Valid: true}, + UpdatedAt: at, + ID: id, + }) + return s.changedNotificationResult(ctx, row, id, true, err) +} + +// MarkNotificationUnread clears read_at. Repeated calls are idempotent and emit +// no CDC update. +func (s *Store) MarkNotificationUnread(ctx context.Context, id string) (NotificationRow, bool, error) { + now := time.Now().UTC() + s.writeMu.Lock() + defer s.writeMu.Unlock() + + row, err := s.qw.MarkNotificationUnread(ctx, gen.MarkNotificationUnreadParams{UpdatedAt: now, ID: id}) + return s.changedNotificationResult(ctx, row, id, true, err) +} + +// ArchiveNotification marks a notification archived. Repeated calls are +// idempotent and emit no CDC update. +func (s *Store) ArchiveNotification(ctx context.Context, id string, at time.Time) (NotificationRow, bool, error) { + if at.IsZero() { + at = time.Now().UTC() + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + + row, err := s.qw.ArchiveNotification(ctx, gen.ArchiveNotificationParams{ + ArchivedAt: sql.NullTime{Time: at, Valid: true}, + UpdatedAt: at, + ID: id, + }) + return s.changedNotificationResult(ctx, row, id, true, err) +} + +func (s *Store) changedNotificationResult(ctx context.Context, row gen.Notification, id string, changed bool, err error) (NotificationRow, bool, error) { + if errors.Is(err, sql.ErrNoRows) { + existing, readErr := s.qw.GetNotification(ctx, id) + if errors.Is(readErr, sql.ErrNoRows) { + return NotificationRow{}, false, nil + } + if readErr != nil { + return NotificationRow{}, false, fmt.Errorf("get notification %s: %w", id, readErr) + } + mapped, mapErr := notificationFromGen(existing) + return mapped, false, mapErr + } + if err != nil { + return NotificationRow{}, false, err + } + mapped, mapErr := notificationFromGen(row) + return mapped, changed, mapErr +} + +func normalizeNotification(row NotificationRow) NotificationRow { + now := time.Now().UTC() + if row.Source == "" { + row.Source = "lifecycle" + } + if len(row.Payload) == 0 { + row.Payload = json.RawMessage(`{}`) + } + if row.Actions == nil { + row.Actions = []domain.NotificationAction{} + } + if row.CreatedAt.IsZero() { + row.CreatedAt = now + } + if row.UpdatedAt.IsZero() { + row.UpdatedAt = row.CreatedAt + } + return row +} + +func notificationsFromGen(rows []gen.Notification) ([]NotificationRow, error) { + out := make([]NotificationRow, 0, len(rows)) + for _, r := range rows { + row, err := notificationFromGen(r) + if err != nil { + return nil, err + } + out = append(out, row) + } + return out, nil +} + +func notificationFromGen(r gen.Notification) (NotificationRow, error) { + var actions []domain.NotificationAction + if r.ActionsJson == "" { + r.ActionsJson = "[]" + } + if err := json.Unmarshal([]byte(r.ActionsJson), &actions); err != nil { + return NotificationRow{}, fmt.Errorf("decode notification actions %s: %w", r.ID, err) + } + row := NotificationRow{ + Seq: r.Seq, + ID: domain.NotificationID(r.ID), + ProjectID: domain.ProjectID(r.ProjectID), + SessionID: domain.SessionID(r.SessionID), + Source: r.Source, + EventType: r.EventType, + SemanticType: r.SemanticType, + Priority: r.Priority, + Message: r.Message, + Payload: append(json.RawMessage(nil), []byte(r.PayloadJson)...), + Actions: actions, + DedupeKey: r.DedupeKey, + CauseKey: r.CauseKey, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } + if r.ReadAt.Valid { + row.ReadAt = r.ReadAt.Time + } + if r.ArchivedAt.Valid { + row.ArchivedAt = r.ArchivedAt.Time + } + return row, nil +} diff --git a/backend/internal/storage/sqlite/notification_store_test.go b/backend/internal/storage/sqlite/notification_store_test.go new file mode 100644 index 0000000000..cd5c44a9e4 --- /dev/null +++ b/backend/internal/storage/sqlite/notification_store_test.go @@ -0,0 +1,232 @@ +package sqlite + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +func TestNotificationInsertListGetAndDedupe(t *testing.T) { + s, rec := newNotificationTestSession(t) + ctx := context.Background() + + row, created, err := s.EnqueueNotification(ctx, sampleNotification(rec, "dedupe-1")) + if err != nil { + t.Fatal(err) + } + if !created || row.ID == "" || row.Seq == 0 { + t.Fatalf("enqueue created=%v row=%+v", created, row) + } + got, ok, err := s.GetNotification(ctx, string(row.ID)) + if err != nil || !ok { + t.Fatalf("get ok=%v err=%v", ok, err) + } + if got.DedupeKey != "dedupe-1" || got.Actions[0].ID != "open-session" { + t.Fatalf("get mismatch: %+v", got) + } + dup, created, err := s.EnqueueNotification(ctx, sampleNotification(rec, "dedupe-1")) + if err != nil { + t.Fatal(err) + } + if created || dup.ID != row.ID || dup.Seq != row.Seq { + t.Fatalf("duplicate should return existing row created=false: created=%v dup=%+v first=%+v", created, dup, row) + } + all, err := s.ListNotifications(ctx, NotificationFilter{Limit: 10}) + if err != nil || len(all) != 1 { + t.Fatalf("list all len=%d err=%v", len(all), err) + } + byProject, _ := s.ListNotifications(ctx, NotificationFilter{ProjectID: string(rec.ProjectID), Limit: 10}) + bySession, _ := s.ListNotifications(ctx, NotificationFilter{SessionID: string(rec.ID), Limit: 10}) + if len(byProject) != 1 || len(bySession) != 1 { + t.Fatalf("project/session lists = %d/%d", len(byProject), len(bySession)) + } +} + +func TestNotificationReadUnreadArchiveAndIdempotentCDC(t *testing.T) { + s, rec := newNotificationTestSession(t) + ctx := context.Background() + row, _, err := s.EnqueueNotification(ctx, sampleNotification(rec, "dedupe-read")) + if err != nil { + t.Fatal(err) + } + createdSeq, _ := s.MaxChangeLogSeq(ctx) + + readAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + read, changed, err := s.MarkNotificationRead(ctx, string(row.ID), readAt) + if err != nil || !changed { + t.Fatalf("mark read changed=%v err=%v", changed, err) + } + if read.ReadAt.IsZero() { + t.Fatal("read_at not set") + } + afterRead, _ := s.MaxChangeLogSeq(ctx) + if afterRead != createdSeq+1 { + t.Fatalf("read should emit one CDC event: before=%d after=%d", createdSeq, afterRead) + } + _, changed, err = s.MarkNotificationRead(ctx, string(row.ID), readAt.Add(time.Second)) + if err != nil || changed { + t.Fatalf("repeated mark read should be idempotent changed=false, got changed=%v err=%v", changed, err) + } + afterRepeat, _ := s.MaxChangeLogSeq(ctx) + if afterRepeat != afterRead { + t.Fatalf("repeated read emitted CDC: before=%d after=%d", afterRead, afterRepeat) + } + + unread, changed, err := s.MarkNotificationUnread(ctx, string(row.ID)) + if err != nil || !changed || !unread.ReadAt.IsZero() { + t.Fatalf("mark unread changed=%v err=%v row=%+v", changed, err, unread) + } + unreadList, err := s.ListNotifications(ctx, NotificationFilter{UnreadOnly: true, Limit: 10}) + if err != nil || len(unreadList) != 1 { + t.Fatalf("unread list len=%d err=%v", len(unreadList), err) + } + + archiveSeq, _ := s.MaxChangeLogSeq(ctx) + archived, changed, err := s.ArchiveNotification(ctx, string(row.ID), readAt.Add(2*time.Second)) + if err != nil || !changed || archived.ArchivedAt.IsZero() { + t.Fatalf("archive changed=%v err=%v row=%+v", changed, err, archived) + } + afterArchive, _ := s.MaxChangeLogSeq(ctx) + if afterArchive != archiveSeq+1 { + t.Fatalf("archive should emit one CDC event: before=%d after=%d", archiveSeq, afterArchive) + } + _, changed, err = s.ArchiveNotification(ctx, string(row.ID), readAt.Add(3*time.Second)) + if err != nil || changed { + t.Fatalf("repeated archive should be idempotent changed=false, got changed=%v err=%v", changed, err) + } + afterArchiveRepeat, _ := s.MaxChangeLogSeq(ctx) + if afterArchiveRepeat != afterArchive { + t.Fatalf("repeated archive emitted CDC: before=%d after=%d", afterArchive, afterArchiveRepeat) + } +} + +func TestNotificationJSONConstraintsRejectInvalidPayloadAndActions(t *testing.T) { + s, rec := newNotificationTestSession(t) + ctx := context.Background() + + badPayload := sampleNotification(rec, "bad-payload") + badPayload.Payload = json.RawMessage(`{"nope"`) + if _, _, err := s.EnqueueNotification(ctx, badPayload); err == nil { + t.Fatal("invalid payload JSON should be rejected") + } + + now := time.Now().UTC().Truncate(time.Second) + _, err := s.qw.InsertNotification(ctx, gen.InsertNotificationParams{ + ProjectID: string(rec.ProjectID), + SessionID: string(rec.ID), + Source: "lifecycle", + EventType: "reaction.agent-needs-input", + SemanticType: "session.needs_input", + Priority: "urgent", + Message: "bad actions", + PayloadJson: `{}`, + ActionsJson: `{not-json`, + DedupeKey: "bad-actions", + CauseKey: "agent-needs-input", + CreatedAt: now, + UpdatedAt: now, + }) + if err == nil { + t.Fatal("invalid actions JSON should be rejected") + } +} + +func TestNotificationCDCForCreateReadArchive(t *testing.T) { + s, rec := newNotificationTestSession(t) + ctx := context.Background() + startSeq, _ := s.MaxChangeLogSeq(ctx) + row, _, err := s.EnqueueNotification(ctx, sampleNotification(rec, "dedupe-cdc")) + if err != nil { + t.Fatal(err) + } + _, _, _ = s.MarkNotificationRead(ctx, string(row.ID), time.Now().UTC()) + _, _, _ = s.ArchiveNotification(ctx, string(row.ID), time.Now().UTC()) + + evs, err := s.ReadChangeLogAfter(ctx, startSeq, 10) + if err != nil { + t.Fatal(err) + } + var types []string + for _, e := range evs { + types = append(types, e.EventType) + if e.EventType == "notification_created" && !strings.Contains(e.Payload, `"data"`) { + t.Fatalf("notification_created payload missing data: %s", e.Payload) + } + } + want := []string{"notification_created", "notification_updated", "notification_updated"} + if fmt.Sprint(types) != fmt.Sprint(want) { + t.Fatalf("notification CDC types = %v, want %v", types, want) + } +} + +func TestConcurrentNotificationEnqueueSameDedupeCreatesOneRow(t *testing.T) { + s, rec := newNotificationTestSession(t) + ctx := context.Background() + const n = 20 + var wg sync.WaitGroup + ids := make(chan domain.NotificationID, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + row, _, err := s.EnqueueNotification(ctx, sampleNotification(rec, "dedupe-concurrent")) + if err != nil { + t.Errorf("enqueue: %v", err) + return + } + ids <- row.ID + }() + } + wg.Wait() + close(ids) + var first domain.NotificationID + for id := range ids { + if first == "" { + first = id + } + if id != first { + t.Fatalf("all callers should see same id, got %q and %q", first, id) + } + } + rows, err := s.ListNotifications(ctx, NotificationFilter{Limit: 10}) + if err != nil || len(rows) != 1 { + t.Fatalf("list len=%d err=%v", len(rows), err) + } +} + +func newNotificationTestSession(t *testing.T) (*Store, domain.SessionRecord) { + t.Helper() + s := newTestStore(t) + seedProject(t, s, "ao") + rec, err := s.CreateSession(context.Background(), sampleRecord("ao")) + if err != nil { + t.Fatalf("create session: %v", err) + } + return s, rec +} + +func sampleNotification(rec domain.SessionRecord, dedupe string) NotificationRow { + now := time.Now().UTC().Truncate(time.Second) + return NotificationRow{ + ProjectID: rec.ProjectID, + SessionID: rec.ID, + Source: "lifecycle", + EventType: "reaction.agent-needs-input", + SemanticType: "session.needs_input", + Priority: "urgent", + Message: "Agent needs input to continue.", + Payload: json.RawMessage(`{"schemaVersion":3,"semanticType":"session.needs_input"}`), + Actions: []domain.NotificationAction{{ID: "open-session", Kind: "route", Label: "Open session", Route: "/projects/ao/sessions/ao-1"}}, + DedupeKey: dedupe, + CauseKey: "agent-needs-input", + CreatedAt: now, + UpdatedAt: now, + } +} diff --git a/backend/internal/storage/sqlite/pr_facts.go b/backend/internal/storage/sqlite/pr_facts.go new file mode 100644 index 0000000000..d72f2978eb --- /dev/null +++ b/backend/internal/storage/sqlite/pr_facts.go @@ -0,0 +1,45 @@ +package sqlite + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// PRFactsForSession picks the PR that drives display/reaction status — the +// newest non-closed PR, else the newest PR — and folds in whether it has +// unresolved review comments. +func (s *Store) PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) { + rows, err := s.ListPRsBySession(ctx, string(id)) + if err != nil { + return domain.PRFacts{}, err + } + if len(rows) == 0 { + return domain.PRFacts{}, nil + } + pick := rows[0] + for _, r := range rows { + if r.State == "draft" || r.State == "open" { + pick = r + break + } + } + facts := domain.PRFacts{ + URL: pick.URL, Number: int(pick.Number), Exists: true, + Draft: pick.State == "draft", Merged: pick.State == "merged", Closed: pick.State == "closed", + CI: domain.CIState(pick.CIState), + Review: domain.ReviewDecision(pick.ReviewDecision), + Mergeability: domain.Mergeability(pick.Mergeability), + } + comments, err := s.ListPRComments(ctx, pick.URL) + if err != nil { + return domain.PRFacts{}, err + } + for _, c := range comments { + if !c.Resolved { + facts.ReviewComments = true + break + } + } + return facts, nil +} diff --git a/backend/internal/storage/sqlite/queries/notifications.sql b/backend/internal/storage/sqlite/queries/notifications.sql new file mode 100644 index 0000000000..a896b43c91 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/notifications.sql @@ -0,0 +1,70 @@ +-- name: InsertNotification :one +INSERT INTO notifications ( + project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (dedupe_key) DO NOTHING +RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; + +-- name: GetNotification :one +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications WHERE id = ?; + +-- name: GetNotificationByDedupeKey :one +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications WHERE dedupe_key = ?; + +-- name: ListNotifications :many +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications +ORDER BY seq DESC +LIMIT ?; + +-- name: ListNotificationsByProject :many +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications +WHERE project_id = ? +ORDER BY seq DESC +LIMIT ?; + +-- name: ListNotificationsBySession :many +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications +WHERE session_id = ? +ORDER BY seq DESC +LIMIT ?; + +-- name: ListUnreadNotifications :many +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at +FROM notifications +WHERE read_at IS NULL AND archived_at IS NULL +ORDER BY seq DESC +LIMIT ?; + +-- name: MarkNotificationRead :one +UPDATE notifications +SET read_at = ?, updated_at = ? +WHERE id = ? AND read_at IS NULL +RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; + +-- name: MarkNotificationUnread :one +UPDATE notifications +SET read_at = NULL, updated_at = ? +WHERE id = ? AND read_at IS NOT NULL +RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; + +-- name: ArchiveNotification :one +UPDATE notifications +SET archived_at = ?, updated_at = ? +WHERE id = ? AND archived_at IS NULL +RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; diff --git a/backend/lifecycle_wiring.go b/backend/lifecycle_wiring.go index 8aecd47057..35eac38552 100644 --- a/backend/lifecycle_wiring.go +++ b/backend/lifecycle_wiring.go @@ -11,6 +11,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/notification" "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/session" @@ -33,13 +34,14 @@ type lifecycleStack struct { // The goroutine stops when ctx is cancelled; Stop waits for it to drain. // // TEMPORARY STUBS (replace as the daemon lane lands the collaborators): -// - noopNotifier — swap for the notifier multiplexer (desktop/Slack/webhook). // - noopMessenger — swap for the runtime/agent-plugin-backed AgentMessenger. // - reaper.MapRegistry{} — empty runtime registry, so the reaper ticks // escalations but probes nothing until the runtime plugins exist. func startLifecycle(ctx context.Context, store *sqlite.Store, logger *slog.Logger) (*lifecycleStack, error) { a := wiring.Adapter{Store: store} - lcm := lifecycle.New(a, a, noopNotifier{}, noopMessenger{}) + renderer := notification.NewRenderer(store) + notifier := notification.NewEnqueuer(store, renderer, logger) + lcm := lifecycle.New(a, a, notifier, noopMessenger{}) rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Logger: logger}) return &lifecycleStack{LCM: lcm, Adapter: a, reaperDone: rp.Start(ctx)}, nil } @@ -94,13 +96,9 @@ func startSession(ctx context.Context, cfg config.Config, ls *lifecycleStack, lo return &sessionStack{SM: sm}, nil } -// noopNotifier / noopMessenger are TEMPORARY stubs (see startLifecycle): the -// write path and CDC work without them; only the human push / agent nudge are -// absent until the real plugins are wired. -type noopNotifier struct{} - -func (noopNotifier) Notify(context.Context, ports.Event) error { return nil } - +// noopMessenger is a TEMPORARY stub (see startLifecycle): the canonical write +// path and durable notifications work without it; only live agent nudges are +// absent until the real runtime/agent plugin is wired. type noopMessenger struct{} func (noopMessenger) Send(context.Context, domain.SessionID, string) error { return nil } diff --git a/backend/wiring_test.go b/backend/wiring_test.go index 14bb3b4cc3..6a372c6c86 100644 --- a/backend/wiring_test.go +++ b/backend/wiring_test.go @@ -14,6 +14,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" + "github.com/aoagents/agent-orchestrator/backend/internal/notification" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/session" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" @@ -32,7 +33,10 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { defer store.Close() a := wiring.Adapter{Store: store} - lcm := lifecycle.New(a, a, noopNotifier{}, noopMessenger{}) + renderer := notification.NewRenderer(store) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + notifier := notification.NewEnqueuer(store, renderer, logger) + lcm := lifecycle.New(a, a, notifier, noopMessenger{}) bcast := cdc.NewBroadcaster() poller := cdc.NewPoller(cdcSource{store}, bcast, cdc.PollerConfig{}) From 4671d27307f05184c643f3d68f482717b9fc936c Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Sun, 31 May 2026 21:10:04 +0530 Subject: [PATCH 075/250] feat(backend): add cobra cli foundation --- README.md | 21 +- backend/cmd/ao/main.go | 15 + backend/go.mod | 3 + backend/go.sum | 8 + backend/internal/cli/completion.go | 31 ++ backend/internal/cli/doctor.go | 115 +++++ backend/internal/cli/output.go | 12 + backend/internal/cli/process.go | 30 ++ backend/internal/cli/process_unix.go | 24 ++ backend/internal/cli/process_windows.go | 29 ++ backend/internal/cli/root.go | 136 ++++++ backend/internal/cli/root_test.go | 159 +++++++ backend/internal/cli/start.go | 138 ++++++ backend/internal/cli/status.go | 187 +++++++++ backend/internal/cli/stop.go | 106 +++++ backend/internal/cli/version.go | 37 ++ backend/{ => internal/daemon}/cdc_wiring.go | 2 +- backend/internal/daemon/daemon.go | 126 ++++++ .../{ => internal/daemon}/lifecycle_wiring.go | 2 +- backend/{ => internal/daemon}/wiring_test.go | 2 +- backend/main.go | 125 +----- docs/README.md | 1 + docs/cli/README.md | 393 ++++++++++++++++++ 23 files changed, 1571 insertions(+), 131 deletions(-) create mode 100644 backend/cmd/ao/main.go create mode 100644 backend/internal/cli/completion.go create mode 100644 backend/internal/cli/doctor.go create mode 100644 backend/internal/cli/output.go create mode 100644 backend/internal/cli/process.go create mode 100644 backend/internal/cli/process_unix.go create mode 100644 backend/internal/cli/process_windows.go create mode 100644 backend/internal/cli/root.go create mode 100644 backend/internal/cli/root_test.go create mode 100644 backend/internal/cli/start.go create mode 100644 backend/internal/cli/status.go create mode 100644 backend/internal/cli/stop.go create mode 100644 backend/internal/cli/version.go rename backend/{ => internal/daemon}/cdc_wiring.go (99%) create mode 100644 backend/internal/daemon/daemon.go rename backend/{ => internal/daemon}/lifecycle_wiring.go (99%) rename backend/{ => internal/daemon}/wiring_test.go (99%) create mode 100644 docs/cli/README.md diff --git a/README.md b/README.md index f5c17deb72..61a639d2a2 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,24 @@ Lifecycle Manager + Session Manager lane in [`docs/architecture.md`](docs/archit ## Backend daemon -The Go binary in [`backend/`](backend/) is the HTTP daemon — a loopback-only -sidecar the Electron supervisor will spawn (Phase 1c). Phase 1a landed the -skeleton: chi router, middleware stack (recoverer → request-id → logger → -real-ip), `/healthz` + `/readyz`, atomic `running.json` PID/port handshake, -graceful shutdown on SIGINT/SIGTERM. +The Go backend now has a Cobra-based `ao` CLI in [`backend/cmd/ao`](backend/cmd/ao). +The CLI controls the HTTP daemon — a loopback-only sidecar the Electron +supervisor will also use. The daemon skeleton includes the chi router, +middleware stack (recoverer → request-id → logger → real-ip), `/healthz` + +`/readyz`, atomic `running.json` PID/port handshake, graceful shutdown on +SIGINT/SIGTERM, SQLite storage, CDC polling, and lifecycle/reaper wiring. ### Run ```bash cd backend -go run . # binds 127.0.0.1:3001 with all defaults -AO_PORT=3019 go run . # override per invocation +go run ./cmd/ao start # start the daemon and wait for readiness +go run ./cmd/ao status # inspect PID/port/health/readiness +go run ./cmd/ao stop # gracefully stop the daemon +go run ./cmd/ao daemon # internal daemon entrypoint + +go run . # compatibility wrapper; starts the daemon +AO_PORT=3019 go run ./cmd/ao start # override per invocation ``` Health check: @@ -48,4 +54,3 @@ is intentionally not env-configurable. cd backend gofmt -l . && go build ./... && go vet ./... && go test -race ./... ``` - diff --git a/backend/cmd/ao/main.go b/backend/cmd/ao/main.go new file mode 100644 index 0000000000..1ee35d622d --- /dev/null +++ b/backend/cmd/ao/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/aoagents/agent-orchestrator/backend/internal/cli" +) + +func main() { + if err := cli.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/backend/go.mod b/backend/go.mod index f270a14ceb..adc5603942 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/creack/pty v1.1.24 github.com/go-chi/chi/v5 v5.1.0 github.com/pressly/goose/v3 v3.27.1 + github.com/spf13/cobra v1.10.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.51.0 ) @@ -14,11 +15,13 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 84823bbdb0..cf3f00291e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,6 @@ github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -14,6 +15,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -26,8 +29,13 @@ github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5s github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= diff --git a/backend/internal/cli/completion.go b/backend/internal/cli/completion.go new file mode 100644 index 0000000000..97970395a1 --- /dev/null +++ b/backend/internal/cli/completion.go @@ -0,0 +1,31 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newCompletionCommand() *cobra.Command { + return &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + root := cmd.Root() + out := cmd.OutOrStdout() + switch args[0] { + case "bash": + return root.GenBashCompletion(out) + case "zsh": + return root.GenZshCompletion(out) + case "fish": + return root.GenFishCompletion(out, true) + case "powershell": + return root.GenPowerShellCompletion(out) + default: + return fmt.Errorf("unsupported shell %q", args[0]) + } + }, + } +} diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go new file mode 100644 index 0000000000..3a452ac191 --- /dev/null +++ b/backend/internal/cli/doctor.go @@ -0,0 +1,115 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +type doctorLevel string + +const ( + doctorPass doctorLevel = "PASS" + doctorWarn doctorLevel = "WARN" + doctorFail doctorLevel = "FAIL" +) + +type doctorCheck struct { + Level doctorLevel + Name string + Message string +} + +func newDoctorCommand(ctx *commandContext) *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Run local AO health checks", + RunE: func(cmd *cobra.Command, args []string) error { + checks := ctx.runDoctor(cmd.Context()) + for _, check := range checks { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil { + return err + } + } + var failures int + for _, check := range checks { + if check.Level == doctorFail { + failures++ + } + } + if failures > 0 { + return fmt.Errorf("doctor found %d failing check(s)", failures) + } + return nil + }, + } +} + +func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { + checks := []doctorCheck{} + + cfg, err := config.Load() + if err != nil { + return append(checks, doctorCheck{Level: doctorFail, Name: "config", Message: err.Error()}) + } + checks = append(checks, doctorCheck{ + Level: doctorPass, Name: "config", + Message: fmt.Sprintf("runFile=%s dataDir=%s port=%d", cfg.RunFilePath, cfg.DataDir, cfg.Port), + }) + + if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { + checks = append(checks, doctorCheck{Level: doctorFail, Name: "data-dir", Message: err.Error()}) + } else { + checks = append(checks, doctorCheck{Level: doctorPass, Name: "data-dir", Message: cfg.DataDir}) + } + + store, err := sqlite.Open(cfg.DataDir) + if err != nil { + checks = append(checks, doctorCheck{Level: doctorFail, Name: "sqlite", Message: err.Error()}) + } else { + _ = store.Close() + checks = append(checks, doctorCheck{Level: doctorPass, Name: "sqlite", Message: "opened database and applied migrations"}) + } + + st, err := c.inspectDaemon(ctx) + if err != nil { + checks = append(checks, doctorCheck{Level: doctorFail, Name: "daemon", Message: err.Error()}) + } else { + level := doctorPass + switch st.State { + case "stale", "not_ready": + level = doctorWarn + case "unhealthy": + level = doctorFail + } + msg := st.State + if st.PID != 0 { + msg = fmt.Sprintf("%s pid=%d port=%d", msg, st.PID, st.Port) + } + if st.Error != "" { + msg += " (" + st.Error + ")" + } + checks = append(checks, doctorCheck{Level: level, Name: "daemon", Message: msg}) + } + + checks = append(checks, c.checkTool("git", true)) + checks = append(checks, c.checkTool("tmux", false)) + checks = append(checks, c.checkTool("zellij", false)) + return checks +} + +func (c *commandContext) checkTool(name string, required bool) doctorCheck { + path, err := c.deps.LookPath(name) + if err == nil { + return doctorCheck{Level: doctorPass, Name: name, Message: path} + } + if required { + return doctorCheck{Level: doctorFail, Name: name, Message: "not found in PATH"} + } + return doctorCheck{Level: doctorWarn, Name: name, Message: "not found in PATH"} +} diff --git a/backend/internal/cli/output.go b/backend/internal/cli/output.go new file mode 100644 index 0000000000..df76e23e36 --- /dev/null +++ b/backend/internal/cli/output.go @@ -0,0 +1,12 @@ +package cli + +import ( + "encoding/json" + "io" +) + +func writeJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/backend/internal/cli/process.go b/backend/internal/cli/process.go new file mode 100644 index 0000000000..3db4320909 --- /dev/null +++ b/backend/internal/cli/process.go @@ -0,0 +1,30 @@ +package cli + +import ( + "os" + "os/exec" +) + +type processStartConfig struct { + Path string + Args []string + Env []string + Stdout *os.File + Stderr *os.File +} + +type processHandle struct { + PID int +} + +func startProcess(cfg processStartConfig) (processHandle, error) { + cmd := exec.Command(cfg.Path, cfg.Args...) + cmd.Env = cfg.Env + cmd.Stdout = cfg.Stdout + cmd.Stderr = cfg.Stderr + if err := cmd.Start(); err != nil { + return processHandle{}, err + } + go func() { _ = cmd.Wait() }() + return processHandle{PID: cmd.Process.Pid}, nil +} diff --git a/backend/internal/cli/process_unix.go b/backend/internal/cli/process_unix.go new file mode 100644 index 0000000000..bd28047cdc --- /dev/null +++ b/backend/internal/cli/process_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package cli + +import ( + "errors" + "os" + "syscall" +) + +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} + +func signalTerm(pid int) error { + if pid <= 0 { + return os.ErrProcessDone + } + return syscall.Kill(pid, syscall.SIGTERM) +} diff --git a/backend/internal/cli/process_windows.go b/backend/internal/cli/process_windows.go new file mode 100644 index 0000000000..da109f1165 --- /dev/null +++ b/backend/internal/cli/process_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package cli + +import "os" + +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + p, err := os.FindProcess(pid) + if err != nil { + return false + } + _ = p.Release() + return true +} + +func signalTerm(pid int) error { + if pid <= 0 { + return os.ErrProcessDone + } + p, err := os.FindProcess(pid) + if err != nil { + return err + } + defer p.Release() + return p.Kill() +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go new file mode 100644 index 0000000000..cb40363f69 --- /dev/null +++ b/backend/internal/cli/root.go @@ -0,0 +1,136 @@ +// Package cli implements the user-facing ao command. It stays thin: commands +// discover the local daemon, call its loopback HTTP API, and format output. +package cli + +import ( + "io" + "net/http" + "os" + "os/exec" + "time" + + "github.com/spf13/cobra" + + "github.com/aoagents/agent-orchestrator/backend/internal/daemon" +) + +// Execute runs the ao CLI with process stdio. +func Execute() error { + return NewRootCommand(DefaultDeps()).Execute() +} + +// Deps holds the small set of side effects the CLI needs. Tests replace these +// functions without reaching into process-global state. +type Deps struct { + In io.Reader + Out io.Writer + Err io.Writer + + HTTPClient *http.Client + Executable func() (string, error) + StartProcess func(processStartConfig) (processHandle, error) + SignalTerm func(pid int) error + ProcessAlive func(pid int) bool + LookPath func(file string) (string, error) + Now func() time.Time + Sleep func(time.Duration) +} + +// DefaultDeps returns production dependencies. +func DefaultDeps() Deps { + return Deps{ + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + HTTPClient: &http.Client{Timeout: 2 * time.Second}, + Executable: os.Executable, + StartProcess: startProcess, + SignalTerm: signalTerm, + ProcessAlive: processAlive, + LookPath: exec.LookPath, + Now: time.Now, + Sleep: time.Sleep, + } +} + +func (d Deps) withDefaults() Deps { + def := DefaultDeps() + if d.In == nil { + d.In = def.In + } + if d.Out == nil { + d.Out = def.Out + } + if d.Err == nil { + d.Err = def.Err + } + if d.HTTPClient == nil { + d.HTTPClient = def.HTTPClient + } + if d.Executable == nil { + d.Executable = def.Executable + } + if d.StartProcess == nil { + d.StartProcess = def.StartProcess + } + if d.SignalTerm == nil { + d.SignalTerm = def.SignalTerm + } + if d.ProcessAlive == nil { + d.ProcessAlive = def.ProcessAlive + } + if d.LookPath == nil { + d.LookPath = def.LookPath + } + if d.Now == nil { + d.Now = def.Now + } + if d.Sleep == nil { + d.Sleep = def.Sleep + } + return d +} + +// NewRootCommand builds a testable root command. +func NewRootCommand(deps Deps) *cobra.Command { + deps = deps.withDefaults() + ctx := &commandContext{deps: deps} + + root := &cobra.Command{ + Use: "ao", + Short: "Agent Orchestrator", + Long: "Agent Orchestrator manages the local daemon that supervises parallel coding-agent sessions.", + Version: VersionString(), + SilenceUsage: true, + SilenceErrors: true, + } + root.SetIn(deps.In) + root.SetOut(deps.Out) + root.SetErr(deps.Err) + root.CompletionOptions.DisableDefaultCmd = true + + root.AddCommand(newDaemonCommand()) + root.AddCommand(newStartCommand(ctx)) + root.AddCommand(newStopCommand(ctx)) + root.AddCommand(newStatusCommand(ctx)) + root.AddCommand(newDoctorCommand(ctx)) + root.AddCommand(newCompletionCommand()) + root.AddCommand(newVersionCommand()) + + return root +} + +type commandContext struct { + deps Deps +} + +func newDaemonCommand() *cobra.Command { + return &cobra.Command{ + Use: "daemon", + Short: "Run the AO backend daemon", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + return daemon.Run() + }, + } +} diff --git a/backend/internal/cli/root_test.go b/backend/internal/cli/root_test.go new file mode 100644 index 0000000000..119bf16aa2 --- /dev/null +++ b/backend/internal/cli/root_test.go @@ -0,0 +1,159 @@ +package cli + +import ( + "bytes" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" +) + +func TestRootHelpDoesNotShowDaemon(t *testing.T) { + out, _, err := executeCLI(t, Deps{}, "--help") + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "\n daemon") { + t.Fatalf("hidden daemon command leaked into help:\n%s", out) + } + for _, want := range []string{"start", "stop", "status", "doctor", "completion", "version"} { + if !strings.Contains(out, want) { + t.Fatalf("help missing %q:\n%s", want, out) + } + } +} + +func TestStatusStoppedJSON(t *testing.T) { + setConfigEnv(t) + + out, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return false }}, "status", "--json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"state": "stopped"`) { + t.Fatalf("status did not report stopped:\n%s", out) + } + if strings.Contains(out, "startedAt") { + t.Fatalf("stopped JSON should omit startedAt:\n%s", out) + } +} + +func TestStartReturnsExistingReadyDaemon(t *testing.T) { + cfg := setConfigEnv(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + _, _ = w.Write([]byte(`{"status":"ok"}`)) + case "/readyz": + _, _ = w.Write([]byte(`{"status":"ready"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + port := serverPort(t, srv.URL) + if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + var started bool + out, _, err := executeCLI(t, Deps{ + ProcessAlive: func(pid int) bool { return pid == os.Getpid() }, + StartProcess: func(processStartConfig) (processHandle, error) { + started = true + return processHandle{}, nil + }, + Now: func() time.Time { return time.Unix(110, 0).UTC() }, + }, "start", "--json") + if err != nil { + t.Fatal(err) + } + if started { + t.Fatal("start should not spawn when daemon is already ready") + } + if !strings.Contains(out, `"state": "ready"`) { + t.Fatalf("start did not report ready:\n%s", out) + } +} + +func TestStopRemovesStaleRunFile(t *testing.T) { + cfg := setConfigEnv(t) + if err := runfile.Write(cfg.runFile, runfile.Info{PID: 999999, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + out, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return false }}, "stop", "--json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"state": "stopped"`) { + t.Fatalf("stop did not report stopped:\n%s", out) + } + info, err := runfile.Read(cfg.runFile) + if err != nil { + t.Fatal(err) + } + if info != nil { + t.Fatalf("stale run-file was not removed: %#v", info) + } +} + +type testConfig struct { + runFile string + dataDir string +} + +func setConfigEnv(t *testing.T) testConfig { + t.Helper() + dir := t.TempDir() + cfg := testConfig{ + runFile: filepath.Join(dir, "running.json"), + dataDir: filepath.Join(dir, "data"), + } + t.Setenv("AO_RUN_FILE", cfg.runFile) + t.Setenv("AO_DATA_DIR", cfg.dataDir) + t.Setenv("AO_PORT", "3001") + t.Setenv("AO_REQUEST_TIMEOUT", "") + t.Setenv("AO_SHUTDOWN_TIMEOUT", "") + return cfg +} + +func executeCLI(t *testing.T, deps Deps, args ...string) (string, string, error) { + t.Helper() + var out, errOut bytes.Buffer + deps.Out = &out + deps.Err = &errOut + if deps.Sleep == nil { + deps.Sleep = func(time.Duration) {} + } + cmd := NewRootCommand(deps) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), errOut.String(), err +} + +func serverPort(t *testing.T, raw string) int { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + _, portRaw, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portRaw) + if err != nil { + t.Fatal(err) + } + return port +} diff --git a/backend/internal/cli/start.go b/backend/internal/cli/start.go new file mode 100644 index 0000000000..9dc4a222ea --- /dev/null +++ b/backend/internal/cli/start.go @@ -0,0 +1,138 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" +) + +const defaultStartTimeout = 10 * time.Second + +type startOptions struct { + timeout time.Duration + logFile string + json bool +} + +func newStartCommand(ctx *commandContext) *cobra.Command { + opts := startOptions{timeout: defaultStartTimeout} + cmd := &cobra.Command{ + Use: "start", + Short: "Start the AO daemon", + RunE: func(cmd *cobra.Command, args []string) error { + st, err := ctx.startDaemon(cmd.Context(), opts) + if err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), st) + } + if st.State == "ready" { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "AO daemon ready (pid %d, port %d)\n", st.PID, st.Port) + return err + } + return writeStatus(cmd, st) + }, + } + cmd.Flags().DurationVar(&opts.timeout, "timeout", defaultStartTimeout, "How long to wait for daemon readiness") + cmd.Flags().StringVar(&opts.logFile, "log-file", "", "Daemon log file path") + cmd.Flags().BoolVar(&opts.json, "json", false, "Output start result as JSON") + return cmd +} + +func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (daemonStatus, error) { + cfg, err := config.Load() + if err != nil { + return daemonStatus{}, err + } + + st, err := c.inspectDaemon(ctx) + if err != nil { + return daemonStatus{}, err + } + if st.State == "ready" { + return st, nil + } + if st.State != "stopped" && st.State != "stale" { + ready, waitErr := c.waitForReady(ctx, opts.timeout) + if waitErr == nil { + return ready, nil + } + return daemonStatus{}, fmt.Errorf("daemon process exists but did not become ready: %w", waitErr) + } + + exe, err := c.deps.Executable() + if err != nil { + return daemonStatus{}, fmt.Errorf("resolve executable: %w", err) + } + + logPath := opts.logFile + if logPath == "" { + logPath = filepath.Join(filepath.Dir(cfg.RunFilePath), "daemon.log") + } + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + return daemonStatus{}, fmt.Errorf("create log dir: %w", err) + } + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return daemonStatus{}, fmt.Errorf("open daemon log: %w", err) + } + defer logFile.Close() + + if _, err := c.deps.StartProcess(processStartConfig{ + Path: exe, + Args: []string{"daemon"}, + Env: os.Environ(), + Stdout: logFile, + Stderr: logFile, + }); err != nil { + return daemonStatus{}, fmt.Errorf("start daemon: %w", err) + } + + ready, err := c.waitForReady(ctx, opts.timeout) + if err != nil { + return daemonStatus{}, fmt.Errorf("%w; see daemon log %s", err, logPath) + } + return ready, nil +} + +func (c *commandContext) waitForReady(ctx context.Context, timeout time.Duration) (daemonStatus, error) { + if timeout <= 0 { + timeout = defaultStartTimeout + } + deadline := c.deps.Now().Add(timeout) + var last daemonStatus + var lastErr error + + for { + select { + case <-ctx.Done(): + return daemonStatus{}, ctx.Err() + default: + } + + st, err := c.inspectDaemon(ctx) + if err != nil { + lastErr = err + } else { + last = st + if st.State == "ready" { + return st, nil + } + } + + if !c.deps.Now().Before(deadline) { + if lastErr != nil { + return daemonStatus{}, fmt.Errorf("daemon did not become ready within %s: %w", timeout, lastErr) + } + return daemonStatus{}, fmt.Errorf("daemon did not become ready within %s (last state: %s)", timeout, last.State) + } + c.deps.Sleep(100 * time.Millisecond) + } +} diff --git a/backend/internal/cli/status.go b/backend/internal/cli/status.go new file mode 100644 index 0000000000..04fd755b4f --- /dev/null +++ b/backend/internal/cli/status.go @@ -0,0 +1,187 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/spf13/cobra" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" +) + +const probeTimeout = 2 * time.Second + +type statusOptions struct { + json bool +} + +type daemonStatus struct { + State string `json:"state"` + PID int `json:"pid,omitempty"` + Port int `json:"port,omitempty"` + StartedAt *time.Time `json:"startedAt,omitempty"` + Uptime string `json:"uptime,omitempty"` + RunFile string `json:"runFile"` + DataDir string `json:"dataDir"` + Health string `json:"health,omitempty"` + Ready string `json:"ready,omitempty"` + Error string `json:"error,omitempty"` +} + +func newStatusCommand(ctx *commandContext) *cobra.Command { + var opts statusOptions + cmd := &cobra.Command{ + Use: "status", + Short: "Show AO daemon status", + RunE: func(cmd *cobra.Command, args []string) error { + st, err := ctx.inspectDaemon(cmd.Context()) + if err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), st) + } + return writeStatus(cmd, st) + }, + } + cmd.Flags().BoolVar(&opts.json, "json", false, "Output status as JSON") + return cmd +} + +func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error) { + cfg, err := config.Load() + if err != nil { + return daemonStatus{}, err + } + st := daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir} + + info, err := runfile.Read(cfg.RunFilePath) + if err != nil { + return daemonStatus{}, err + } + if info == nil { + return st, nil + } + + st.PID = info.PID + st.Port = info.Port + startedAt := info.StartedAt + st.StartedAt = &startedAt + st.Uptime = formatUptime(c.deps.Now().Sub(info.StartedAt)) + + if !c.deps.ProcessAlive(info.PID) { + st.State = "stale" + st.Error = "run-file points to a dead process" + return st, nil + } + + health, err := c.readProbe(ctx, info.Port, "healthz") + if err != nil { + st.State = "unhealthy" + st.Error = err.Error() + return st, nil + } + st.Health = health + + ready, err := c.readProbe(ctx, info.Port, "readyz") + if err != nil { + st.State = "not_ready" + st.Error = err.Error() + return st, nil + } + st.Ready = ready + if ready == "ready" { + st.State = "ready" + return st, nil + } + st.State = "not_ready" + return st, nil +} + +func (c *commandContext) readProbe(ctx context.Context, port int, path string) (string, error) { + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, fmt.Sprintf("http://%s:%d/%s", config.LoopbackHost, port, path), nil) + if err != nil { + return "", err + } + resp, err := c.deps.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("%s: %w", path, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("%s: HTTP %d", path, resp.StatusCode) + } + var body struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("%s: decode response: %w", path, err) + } + if body.Status == "" { + return "", fmt.Errorf("%s: missing status", path) + } + return body.Status, nil +} + +func writeStatus(cmd *cobra.Command, st daemonStatus) error { + out := cmd.OutOrStdout() + if _, err := fmt.Fprintf(out, "AO daemon: %s\n", st.State); err != nil { + return err + } + if st.PID != 0 { + if _, err := fmt.Fprintf(out, " pid: %d\n", st.PID); err != nil { + return err + } + } + if st.Port != 0 { + if _, err := fmt.Fprintf(out, " port: %d\n", st.Port); err != nil { + return err + } + } + if st.StartedAt != nil && !st.StartedAt.IsZero() { + if _, err := fmt.Fprintf(out, " started: %s\n", st.StartedAt.Format(time.RFC3339)); err != nil { + return err + } + } + if st.Uptime != "" { + if _, err := fmt.Fprintf(out, " uptime: %s\n", st.Uptime); err != nil { + return err + } + } + if _, err := fmt.Fprintf(out, " run file: %s\n", st.RunFile); err != nil { + return err + } + if _, err := fmt.Fprintf(out, " data dir: %s\n", st.DataDir); err != nil { + return err + } + if st.Health != "" { + if _, err := fmt.Fprintf(out, " healthz: %s\n", st.Health); err != nil { + return err + } + } + if st.Ready != "" { + if _, err := fmt.Fprintf(out, " readyz: %s\n", st.Ready); err != nil { + return err + } + } + if st.Error != "" { + if _, err := fmt.Fprintf(out, " error: %s\n", st.Error); err != nil { + return err + } + } + return nil +} + +func formatUptime(d time.Duration) string { + if d < 0 { + d = 0 + } + return d.Round(time.Second).String() +} diff --git a/backend/internal/cli/stop.go b/backend/internal/cli/stop.go new file mode 100644 index 0000000000..5ce43b4fc4 --- /dev/null +++ b/backend/internal/cli/stop.go @@ -0,0 +1,106 @@ +package cli + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" +) + +const defaultStopTimeout = 10 * time.Second + +type stopOptions struct { + timeout time.Duration + json bool +} + +func newStopCommand(ctx *commandContext) *cobra.Command { + opts := stopOptions{timeout: defaultStopTimeout} + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop the AO daemon", + RunE: func(cmd *cobra.Command, args []string) error { + st, err := ctx.stopDaemon(cmd.Context(), opts) + if err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), st) + } + if st.State == "stopped" { + _, err = fmt.Fprintln(cmd.OutOrStdout(), "AO daemon stopped") + return err + } + return writeStatus(cmd, st) + }, + } + cmd.Flags().DurationVar(&opts.timeout, "timeout", defaultStopTimeout, "How long to wait for daemon shutdown") + cmd.Flags().BoolVar(&opts.json, "json", false, "Output stop result as JSON") + return cmd +} + +func (c *commandContext) stopDaemon(ctx context.Context, opts stopOptions) (daemonStatus, error) { + cfg, err := config.Load() + if err != nil { + return daemonStatus{}, err + } + st, err := c.inspectDaemon(ctx) + if err != nil { + return daemonStatus{}, err + } + switch st.State { + case "stopped": + return st, nil + case "stale": + if err := runfile.Remove(cfg.RunFilePath); err != nil { + return daemonStatus{}, err + } + return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil + } + + if err := c.deps.SignalTerm(st.PID); err != nil { + if c.deps.ProcessAlive(st.PID) { + return daemonStatus{}, fmt.Errorf("signal daemon pid %d: %w", st.PID, err) + } + _ = runfile.Remove(cfg.RunFilePath) + return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil + } + return c.waitForStopped(ctx, st.PID, cfg.RunFilePath, cfg.DataDir, opts.timeout) +} + +func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePath, dataDir string, timeout time.Duration) (daemonStatus, error) { + if timeout <= 0 { + timeout = defaultStopTimeout + } + deadline := c.deps.Now().Add(timeout) + for { + select { + case <-ctx.Done(): + return daemonStatus{}, ctx.Err() + default: + } + + info, err := runfile.Read(runFilePath) + if err != nil { + return daemonStatus{}, err + } + alive := c.deps.ProcessAlive(pid) + if info == nil { + return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil + } + if !alive { + if err := runfile.Remove(runFilePath); err != nil { + return daemonStatus{}, err + } + return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil + } + if !c.deps.Now().Before(deadline) { + return daemonStatus{}, fmt.Errorf("daemon pid %d did not stop within %s", pid, timeout) + } + c.deps.Sleep(100 * time.Millisecond) + } +} diff --git a/backend/internal/cli/version.go b/backend/internal/cli/version.go new file mode 100644 index 0000000000..dd8a2598fe --- /dev/null +++ b/backend/internal/cli/version.go @@ -0,0 +1,37 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +// Build metadata. Release tooling can override these with -ldflags. +var ( + Version = "dev" + Commit = "" + Date = "" +) + +func VersionString() string { + parts := []string{Version} + if Commit != "" { + parts = append(parts, "commit "+Commit) + } + if Date != "" { + parts = append(parts, "built "+Date) + } + return strings.Join(parts, " ") +} + +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version information", + RunE: func(cmd *cobra.Command, args []string) error { + _, err := fmt.Fprintln(cmd.OutOrStdout(), VersionString()) + return err + }, + } +} diff --git a/backend/cdc_wiring.go b/backend/internal/daemon/cdc_wiring.go similarity index 99% rename from backend/cdc_wiring.go rename to backend/internal/daemon/cdc_wiring.go index d824cbab12..a76c5c78b6 100644 --- a/backend/cdc_wiring.go +++ b/backend/internal/daemon/cdc_wiring.go @@ -1,4 +1,4 @@ -package main +package daemon import ( "context" diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go new file mode 100644 index 0000000000..fd7dcc7ca2 --- /dev/null +++ b/backend/internal/daemon/daemon.go @@ -0,0 +1,126 @@ +// Package daemon owns the Agent Orchestrator backend process: config loading, +// loopback HTTP serving, durable storage, CDC fan-out, lifecycle wiring, and +// graceful shutdown. +package daemon + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" + "github.com/aoagents/agent-orchestrator/backend/internal/terminal" +) + +// Run starts the daemon and blocks until it exits. SIGINT/SIGTERM drive +// graceful shutdown through the HTTP server and background workers. +func Run() error { + cfg, err := config.Load() + if err != nil { + return err + } + + log := NewLogger() + + // Fail fast if a live daemon already owns the handshake file. A run-file + // left by a crashed predecessor (dead PID) is treated as stale and + // overwritten when the new server starts. + if live, err := runfile.CheckStale(cfg.RunFilePath); err != nil { + return fmt.Errorf("inspect run-file: %w", err) + } else if live != nil { + return fmt.Errorf("daemon already running (pid %d, port %d); refusing to start", live.PID, live.Port) + } + + // Open the durable store and bring up the CDC substrate: the DB triggers + // capture changes into change_log, the poller tails it, and the broadcaster + // fans events out to the SSE transport. The LCM/Session Manager and the HTTP + // API routes that drive and read this store are owned by the daemon lane and + // are wired there once their collaborators (Notifier, AgentMessenger, and the + // runtime/agent/workspace plugins) have production implementations; here we + // stand up the persistence + change-delivery foundation they build on. + store, err := sqlite.Open(cfg.DataDir) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer store.Close() + + // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the + // graceful shutdown inside Server.Run and stops the background goroutines. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + cdcPipe, err := startCDC(ctx, store, log) + if err != nil { + return err + } + + // Terminal streaming: the tmux runtime supplies the PTY-attach command and + // liveness; the CDC broadcaster feeds the session-state channel. The manager + // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow + // through the CDC change_log — only session-state events do. + runtimeAdapter := tmux.New(tmux.Options{}) + termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) + defer termMgr.Close() + + srv, err := httpd.New(cfg, log, termMgr) + if err != nil { + return err + } + + // Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE + // timer). This makes the write path live end-to-end: LCM write -> store -> DB + // trigger -> change_log -> poller -> broadcaster. + lcStack, err := startLifecycle(ctx, store, log) + if err != nil { + return err + } + + // Bring up the Session Manager. Runtime (tmux) and Workspace (gitworktree) + // are real on main; ports.Agent has no production adapter yet, so a loud + // stub returns a sentinel command that makes any Spawn fail at the runtime + // layer rather than start a broken session quietly. Notifier and + // AgentMessenger remain stubbed alongside the LCM until their multiplexers + // land. No HTTP routes wire to this yet — the daemon lane (#10) owns API + // surfacing — so we hold the SM in a local until it does. + sStack, err := startSession(ctx, cfg, lcStack, log) + if err != nil { + // startSession is the first start* call after this point that can + // realistically fail while the cdc poller and the reaper are already + // running. Mirror the bottom-of-run shutdown sequence so both have + // drained before the deferred store.Close() fires. Defers would hit + // the LIFO trap (see comment after srv.Run), hence explicit. + stop() + lcStack.Stop() + if cdcErr := cdcPipe.Stop(); cdcErr != nil { + log.Error("cdc pipeline shutdown", "err", cdcErr) + } + return err + } + _ = sStack + + runErr := srv.Run(ctx) + + // Shut the background goroutines down in order: cancel the context FIRST so + // their loops exit, then wait for them to drain. Doing this explicitly (not + // via defer) avoids the LIFO trap where a Stop() that blocks on ctx-cancel + // runs before the cancel — which would hang any non-signal exit path. + stop() + lcStack.Stop() + if err := cdcPipe.Stop(); err != nil { + log.Error("cdc pipeline shutdown", "err", err) + } + return runErr +} + +// NewLogger returns the daemon's slog logger. It writes to stderr so supervisors +// can capture it separately from any structured stdout protocol added later. +func NewLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) +} diff --git a/backend/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go similarity index 99% rename from backend/lifecycle_wiring.go rename to backend/internal/daemon/lifecycle_wiring.go index 35eac38552..5a79105452 100644 --- a/backend/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -1,4 +1,4 @@ -package main +package daemon import ( "context" diff --git a/backend/wiring_test.go b/backend/internal/daemon/wiring_test.go similarity index 99% rename from backend/wiring_test.go rename to backend/internal/daemon/wiring_test.go index 6a372c6c86..c2cfb72135 100644 --- a/backend/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -1,4 +1,4 @@ -package main +package daemon import ( "context" diff --git a/backend/main.go b/backend/main.go index e825039f11..5315d51054 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,133 +1,18 @@ -// Command backend is the Agent Orchestrator HTTP daemon: a loopback-only -// sidecar spawned and supervised by the Electron main process. Phase 1a brings -// up the server skeleton — config, 127.0.0.1 bind, middleware stack, health -// probes, the running.json handshake, and graceful shutdown. +// Command backend is a compatibility wrapper for the Agent Orchestrator daemon. +// The user-facing CLI lives at cmd/ao; keep this wrapper so existing `go run .` +// development workflows continue to start the daemon while scripts migrate. package main import ( - "context" "fmt" - "log/slog" "os" - "os/signal" - "syscall" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/httpd" - "github.com/aoagents/agent-orchestrator/backend/internal/runfile" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" - "github.com/aoagents/agent-orchestrator/backend/internal/terminal" + "github.com/aoagents/agent-orchestrator/backend/internal/daemon" ) func main() { - if err := run(); err != nil { + if err := daemon.Run(); err != nil { fmt.Fprintln(os.Stderr, "ao backend daemon: "+err.Error()) os.Exit(1) } } - -func run() error { - cfg, err := config.Load() - if err != nil { - return err - } - - log := newLogger() - - // Fail fast if a live daemon already owns the handshake file. A run-file - // left by a crashed predecessor (dead PID) is treated as stale and - // overwritten when the new server starts. - if live, err := runfile.CheckStale(cfg.RunFilePath); err != nil { - return fmt.Errorf("inspect run-file: %w", err) - } else if live != nil { - return fmt.Errorf("daemon already running (pid %d, port %d); refusing to start", live.PID, live.Port) - } - - // Open the durable store and bring up the CDC substrate: the DB triggers - // capture changes into change_log, the poller tails it, and the broadcaster - // fans events out to the SSE transport. The LCM/Session Manager and the HTTP - // API routes that drive and read this store are owned by the daemon lane and - // are wired there once their collaborators (Notifier, AgentMessenger, and the - // runtime/agent/workspace plugins) have production implementations; here we - // stand up the persistence + change-delivery foundation they build on. - store, err := sqlite.Open(cfg.DataDir) - if err != nil { - return fmt.Errorf("open store: %w", err) - } - defer store.Close() - - // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the - // graceful shutdown inside Server.Run and stops the background goroutines. - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - cdcPipe, err := startCDC(ctx, store, log) - if err != nil { - return err - } - - // Terminal streaming: the tmux runtime supplies the PTY-attach command and - // liveness; the CDC broadcaster feeds the session-state channel. The manager - // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow - // through the CDC change_log — only session-state events do. - runtimeAdapter := tmux.New(tmux.Options{}) - termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) - defer termMgr.Close() - - srv, err := httpd.New(cfg, log, termMgr) - if err != nil { - return err - } - - // Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE - // timer). This makes the write path live end-to-end: LCM write -> store -> DB - // trigger -> change_log -> poller -> broadcaster. - lcStack, err := startLifecycle(ctx, store, log) - if err != nil { - return err - } - - // Bring up the Session Manager. Runtime (tmux) and Workspace (gitworktree) - // are real on main; ports.Agent has no production adapter yet, so a loud - // stub returns a sentinel command that makes any Spawn fail at the runtime - // layer rather than start a broken session quietly. Notifier and - // AgentMessenger remain stubbed alongside the LCM until their multiplexers - // land. No HTTP routes wire to this yet — the daemon lane (#10) owns API - // surfacing — so we hold the SM in a local until it does. - sStack, err := startSession(ctx, cfg, lcStack, log) - if err != nil { - // startSession is the first start* call after this point that can - // realistically fail while the cdc poller and the reaper are already - // running. Mirror the bottom-of-run shutdown sequence so both have - // drained before the deferred store.Close() fires. Defers would hit - // the LIFO trap (see comment after srv.Run), hence explicit. - stop() - lcStack.Stop() - if cdcErr := cdcPipe.Stop(); cdcErr != nil { - log.Error("cdc pipeline shutdown", "err", cdcErr) - } - return err - } - _ = sStack - - runErr := srv.Run(ctx) - - // Shut the background goroutines down in order: cancel the context FIRST so - // their loops exit, then wait for them to drain. Doing this explicitly (not - // via defer) avoids the LIFO trap where a Stop() that blocks on ctx-cancel - // runs before the cancel — which would hang any non-signal exit path. - stop() - lcStack.Stop() - if err := cdcPipe.Stop(); err != nil { - log.Error("cdc pipeline shutdown", "err", err) - } - return runErr -} - -// newLogger returns the daemon's slog logger. It writes to stderr so the -// Electron supervisor can capture it separately from any structured stdout -// protocol added later. -func newLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) -} diff --git a/docs/README.md b/docs/README.md index f42f222fa5..220dec402e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ fakes) on the `feat/lcm-sm-contracts` integration branch. |-----|----------------| | [architecture.md](architecture.md) | How the lane works: the OBSERVE→DECIDE→ACT loop, the canonical state model, the package layout, every component, and the load-bearing invariants. Read this first. | | [status.md](status.md) | What's done (PR by PR), what's left, the integration to-dos, the open cross-lane contract questions, and how to build/test. | +| [cli/README.md](cli/README.md) | CLI foundation decisions: Cobra, reference projects, old CLI inventory, and the first command surface. | ## The one-paragraph mental model diff --git a/docs/cli/README.md b/docs/cli/README.md new file mode 100644 index 0000000000..3d49e00d56 --- /dev/null +++ b/docs/cli/README.md @@ -0,0 +1,393 @@ +# AO CLI Foundation + +This page is the running decision log for the Agent Orchestrator CLI. Keep new +CLI decisions here as the command surface grows. + +## Current State + +This branch implements the daemon-control foundation. AO now has a Go/Cobra +`ao` binary that can start, inspect, diagnose, and stop the local backend daemon +end to end. + +What works now: + +- `ao start` starts the daemon in the background and waits for `/readyz`. +- `ao status` and `ao status --json` report stopped, stale, unhealthy, + not-ready, or ready daemon state. +- `ao stop` gracefully stops the daemon using the PID in `running.json`. +- `ao daemon` is the hidden internal daemon entrypoint used by `ao start`. +- `ao doctor` checks config, data dir, SQLite migrations, daemon state, and + local tool availability for `git`, `tmux`, and `zellij`. +- `ao completion` generates shell completions for `bash`, `zsh`, `fish`, and + `powershell`. +- `ao version` and `ao --version` print build metadata. +- `go run .` still works as a compatibility wrapper around `internal/daemon.Run`. + +Manual smoke test: + +```bash +cd backend +go build -o /tmp/ao ./cmd/ao + +tmp=$(mktemp -d) +export AO_RUN_FILE="$tmp/running.json" +export AO_DATA_DIR="$tmp/data" +export AO_PORT=3037 + +/tmp/ao status --json +/tmp/ao doctor +/tmp/ao start +/tmp/ao status --json +/tmp/ao stop +/tmp/ao status --json +``` + +What is intentionally not implemented yet: + +- `ao project ...` +- `ao spawn` +- `ao session ...` +- `ao send` +- `ao events ...` + +Next steps: + +1. Add `/api/v1/projects` on the daemon over a small project service. +2. Implement `ao project list/add/show/remove`. +3. Wire production Session Manager dependencies: project-backed repo resolver, + tmux/zellij runtime registry, first agent adapter, and AgentMessenger. +4. Add `/api/v1/sessions`, then implement `ao spawn`, `ao session ...`, and + `ao send`. +5. Add `/events` SSE and durable event-list reads, then implement + `ao events tail/list`. + +## Decision + +AO will use a single Go CLI binary built with +[Cobra](https://github.com/spf13/cobra). + +The CLI is a thin client for the Go daemon. It should not call SQLite, runtime +adapters, agent adapters, workspace adapters, or SCM integrations directly. It +should start, discover, inspect, and command the daemon through the loopback API +and the existing `running.json` handshake. + +Initial rules: + +- The binary name is `ao`. +- `ao daemon` is the hidden/internal entrypoint for the long-running daemon. +- User-facing commands call the daemon over loopback after reading + `running.json`. +- Commands that mutate core AO state go through HTTP API routes, not direct + stores. +- Commands support predictable text output first and `--json` where automation + is likely. +- Do not introduce Viper in the foundation. Start with explicit flags and a + small config/client layer, then add config loading once the shape is real. + +## References + +These projects inform the direction, but AO should keep its own command surface +smaller at first. + +| Project | CLI stack | What to take | +|---|---|---| +| [Gastown](https://github.com/gastownhall/gastown) | Go + Cobra, with Charmbracelet packages for richer terminal UI | Simple `cmd//main.go` delegating to internal command construction. Useful confirmation that Cobra is the right default for this size of Go CLI. | +| [GitHub CLI](https://github.com/cli/cli) | Go + Cobra | Command factories, explicit IO streams, JSON output, and testable command construction. | +| [Docker CLI](https://github.com/docker/cli) | Go + Cobra | Daemon/client split, command groups, signal handling, and plugin-aware CLI layout. | +| [kubectl](https://github.com/kubernetes/kubectl) | Go + Cobra | Large command tree patterns and IO abstractions. It is a useful ceiling, not a shape to copy now. | +| [Tailscale CLI](https://github.com/tailscale/tailscale) | Go + ffcli | Useful daemon-backed product model: a CLI talks to a local daemon. Do not copy the framework choice. | + +The old AO TypeScript CLI is a product/workflow reference only. We should not +port its implementation because it mixes CLI, storage, runtime, and project +logic in-process. The rewrite needs the CLI to sit outside the core daemon. + +## Current Legacy CLI Inventory + +Inventory source: installed `ao` binary at version `0.9.2`, plus +`/Users/dhruvsharma/Development/agent-orchestrator/packages/cli/src/program.ts` +and `packages/cli/src/commands/*.ts`. + +Count: + +- 25 public top-level commands, excluding Commander-generated `help`. +- 26 visible top-level commands if generated `help` is counted. +- 64 explicit public command nodes when nested subcommands are counted. +- 1 hidden internal command: `completion __complete`. +- No aliases are registered in the old Commander source. + +Top-level commands: + +| Command | Legacy purpose | Foundation decision | +|---|---|---| +| `start` | Start orchestrator agent and dashboard | Keep, but redefine as daemon start. | +| `stop` | Stop orchestrator agent and dashboard | Keep, daemon stop. | +| `status` | Show all sessions and project/session health | Keep, daemon and session status. | +| `spawn` | Spawn a single agent session | Keep after session API exists. | +| `batch-spawn` | Spawn many sessions | Defer. | +| `session` | Manage sessions | Keep a smaller subset after session API exists. | +| `send` | Send a message to a session | Keep after messaging API exists. | +| `acknowledge` | Agent self-reporting hook | Defer or replace with internal API. | +| `report` | Agent workflow transition hook | Defer or replace with internal API. | +| `review-check` | Trigger agents from review comments | Defer. | +| `review` | Manage AO-local reviewer runs | Defer. | +| `dashboard` | Start web dashboard | Defer to Electron/frontend lane. | +| `open` | Open terminal/dashboard | Defer. | +| `verify` | Verify issue after staging check | Defer. | +| `doctor` | Run install/env/runtime checks | Keep. | +| `update` | Upgrade AO | Defer to packaging/release lane. | +| `setup` | Configure integrations | Defer. | +| `plugin` | Plugin marketplace/install flow | Defer. | +| `notify` | Notification test commands | Defer. | +| `project` | Manage registered projects | Keep after project API exists. | +| `migrate-storage` | Legacy storage migration | Drop for rewrite unless a real migration appears. | +| `completion` | Generate shell completions | Keep. | +| `events` | Query activity event log | Keep a small `tail`/`list` surface after event API exists. | +| `config` | Read/write old global config | Defer. Avoid until config shape is stable. | +| `config-help` | Print old config schema | Drop. | + +Nested legacy commands: + +| Parent | Subcommands | +|---|---| +| `session` | `ls`, `attach`, `kill`, `cleanup`, `claim-pr`, `restore`, `remap` | +| `review` | `run`, `execute`, `send`, `list` | +| `setup` | `dashboard`, `desktop`, `webhook`, `slack`, `discord`, `composio`, `composio-slack`, `composio-discord`, `composio-discord-bot`, `composio-mail`, `openclaw` | +| `plugin` | `list`, `search`, `create`, `install`, `update`, `uninstall` | +| `project` | `ls`, `add`, `rm`, `set-default` | +| `events` | `list`, `search`, `stats` | +| `config` | `set`, `get` | +| `notify` | `test` | +| `completion` | `zsh`, hidden `__complete` | + +## Initial Command Surface + +The first CLI should make AO installable, startable, inspectable, and stoppable +before trying to recreate the old product surface. + +### Foundation Commands + +These are the first commands to implement. + +| Command | Purpose | Notes | +|---|---|---| +| `ao start` | Start the daemon, wait for `/readyz`, and print PID/port. | Reads the same config env as the daemon. Should be idempotent when an existing healthy daemon is already running. | +| `ao stop` | Stop the running daemon. | Reads `running.json`, sends graceful termination, waits for run-file removal, and reports stale/dead daemon state clearly. | +| `ao status` | Show daemon status and, once APIs exist, project/session summary. | First version can show run-file, process liveness, `/healthz`, `/readyz`, uptime, and port. Add `--json`; add `--watch` once useful. | +| `ao daemon` | Hidden internal daemon entrypoint. | This replaces the current direct `go run .` daemon entrypoint once `main.go` is extracted into `internal/daemon`. | +| `ao doctor` | Diagnose the local environment. | Start with daemon/run-file/port checks, required binaries, config dir/data dir permissions, and runtime availability. | +| `ao completion` | Generate shell completions. | Cobra can support `bash`, `zsh`, `fish`, and `powershell`. | +| `ao version` | Print CLI and build metadata. | Implement as both `ao version` and Cobra's `--version` flag. | + +This gives a useful first release even before project/session mutation routes are +complete. + +### First Core Application Commands + +These are the next commands once daemon HTTP routes expose the needed managers. + +| Command | Purpose | Depends on | +|---|---|---| +| `ao project list` | List registered projects. | Project API. Alias `ls` is acceptable for old muscle memory. | +| `ao project add ` | Register a project. | Project API and project identity rules. | +| `ao project show ` | Inspect project config and health. | Project API. | +| `ao project remove ` | Archive/remove a project. | Project API. Alias `rm` is acceptable. | +| `ao spawn [issue]` | Spawn one coding-agent session. | Session Manager HTTP route, tracker lookup, workspace/runtime/agent adapters. | +| `ao session list` | List sessions across projects or one project. | Session API. Alias `ls` is acceptable. | +| `ao session show ` | Show one session with lifecycle, PR, CI, runtime, and paths. | Session API. | +| `ao session attach ` | Attach to the runtime terminal. | Runtime API or direct terminal attach contract exposed by daemon. | +| `ao session kill ` | Kill a session and clean up safely. | Session Manager `Kill`. | +| `ao session restore ` | Restore a terminated/crashed session. | Session Manager `Restore`. | +| `ao send [message...]` | Send instructions to a running session. | AgentMessenger route. | +| `ao events tail` | Follow daemon activity events. | SSE/CDC API. | +| `ao events list` | List recent activity events. | Event read API. | + +This is the smallest surface that covers the core product loop: + +1. Register a repo. +2. Start AO. +3. Spawn work. +4. Inspect work. +5. Intervene in work. +6. Stop AO. + +## Explicit Deferrals + +Do not include these in the CLI foundation: + +- `batch-spawn`: valuable, but it multiplies error handling before single-spawn + semantics are stable. +- `dashboard` and `open`: frontend/Electron should own the primary dashboard + launch path first. +- `review`, `review-check`, and `verify`: useful workflow automation, but not + required to run core AO. +- `setup`, `plugin`, and `notify`: integration/plugin surface should come after + the daemon API and config model settle. +- `update`: belongs with distribution and release packaging. +- `config` and `config-help`: wait for a stable Go config model. Avoid copying + the old TypeScript global config behavior. +- `migrate-storage`: old storage migration is not part of the rewrite unless a + concrete migration requirement appears. +- `acknowledge` and `report`: these are agent self-reporting hooks. Prefer a + daemon/internal protocol before exposing them as durable user CLI commands. + +## Implementation Plan + +1. Add Cobra to `backend/go.mod`. +2. Move current daemon startup from `backend/main.go` into + `backend/internal/daemon.Run(ctx, opts)`. +3. Add `backend/cmd/ao/main.go` as the only user binary entrypoint. +4. Add `backend/internal/cli` for command construction, IO streams, process + launching, run-file discovery, loopback HTTP client, and output formatting. +5. Implement `ao daemon` first so the current daemon behavior is preserved. +6. Implement `ao start`, `ao stop`, and `ao status` around `running.json` and + `/healthz`/`/readyz`. +7. Add `ao doctor`, `ao completion`, and `ao version`. +8. Add command tests using Cobra command construction with fake IO, fake process + runner, and fake daemon client. Keep daemon integration tests in the daemon + packages. + +Suggested package layout: + +```text +backend/ + cmd/ + ao/ + main.go + internal/ + cli/ + root.go + start.go + stop.go + status.go + doctor.go + completion.go + version.go + client.go + output.go + process.go + daemon/ + daemon.go +``` + +Acceptance criteria for the foundation: + +- `go run ./cmd/ao daemon` behaves like today's `go run .`. +- `go run ./cmd/ao start` starts the daemon and waits until `/readyz` returns + ready. +- `go run ./cmd/ao status --json` works when the daemon is running, stopped, and + stale. +- `go run ./cmd/ao stop` gracefully stops the daemon and removes `running.json`. +- `go test ./...`, `go vet ./...`, and `go test -race ./...` pass. + +## Implementation Readiness + +This section records what the CLI can connect to in the current codebase and +what still needs to be built. Inventory date: 2026-05-31 on `main` at +`0672dbb`. + +### Implemented Foundation + +The daemon-control foundation now exists in `backend/cmd/ao` and +`backend/internal/cli`. + +Implemented commands: + +- `ao daemon` hidden/internal daemon entrypoint. +- `ao start` starts the daemon, waits for `/readyz`, and supports `--json`, + `--timeout`, and `--log-file`. +- `ao stop` stops the daemon from `running.json`, removes stale run-files, and + supports `--json` and `--timeout`. +- `ao status` reports stopped/stale/unhealthy/not-ready/ready states and + supports `--json`. +- `ao doctor` checks config, data dir, SQLite open/migrations, daemon state, and + local tool availability for `git`, `tmux`, and `zellij`. +- `ao completion` generates `bash`, `zsh`, `fish`, and `powershell` + completions. +- `ao version` prints build metadata. + +The old `backend/main.go` remains as a compatibility wrapper around +`internal/daemon.Run`, so `go run .` still starts the daemon while scripts move +to `go run ./cmd/ao ...`. + +### Already Implemented and Directly Usable by the CLI + +These pieces are available now and are enough to build the daemon-management +part of the CLI. + +| Area | Existing code | CLI use | +|---|---|---| +| Daemon config | `backend/internal/config` loads `AO_PORT`, `AO_REQUEST_TIMEOUT`, `AO_SHUTDOWN_TIMEOUT`, `AO_RUN_FILE`, and `AO_DATA_DIR`. Host is fixed to `127.0.0.1`. | `ao start`, `ao daemon`, `ao status`, and `ao doctor` can share the same config resolution. | +| HTTP server lifecycle | `backend/internal/httpd.Server` binds loopback, writes `running.json`, serves until context cancellation, then removes `running.json`. | `ao daemon` can preserve today's daemon behavior after extraction into `internal/daemon`. | +| Health probes | `GET /healthz` and `GET /readyz`. | `ao start` can wait for readiness; `ao status` and `ao doctor` can check daemon health. | +| Run-file handshake | `backend/internal/runfile` reads, writes, removes, and stale-checks `running.json`. | `ao status` can discover PID/port; `ao stop` can find the process; `ao start` can detect an already-running daemon. | +| Durable store | `backend/internal/storage/sqlite` opens SQLite, runs goose migrations, uses WAL, stores projects/sessions/PR/check/comment rows, and reads `change_log`. | Not directly called by user CLI commands, but confirms the daemon has a durable backend once APIs expose it. | +| CDC substrate | `backend/internal/cdc` poller and broadcaster exist; daemon starts the poller with `startCDC`. | Future `ao events tail` can build on this once an SSE/API transport exists. | +| Lifecycle manager | `backend/internal/lifecycle` is implemented and currently wired in daemon startup. | Session/status APIs can use it; CLI must wait for HTTP routes rather than calling it directly. | +| Reaper timer | `backend/internal/observe/reaper` exists and is wired. | Runtime liveness will be available once runtime registry wiring exists. | + +### Implemented Internally but Not Reachable by CLI Yet + +These are real backend components, but the CLI cannot responsibly use them until +they are wired into the daemon and exposed through HTTP. + +| Area | Existing code | Missing before CLI can use it | +|---|---|---| +| Project persistence | `sqlite.Store` has `UpsertProject`, `GetProject`, `ListProjects`, and `ArchiveProject`. | Project domain/service layer, project ID/path/origin validation, and `/api/v1/projects` routes. | +| Session Manager | `backend/internal/session.Manager` implements `Spawn`, `Kill`, `Restore`, `List`, `Get`, `Send`, and `Cleanup`. | Production daemon wiring with real runtime, agent, workspace, messenger, and HTTP routes. | +| Runtime adapters | tmux and zellij adapters implement `ports.Runtime` and also have attach/send/output helpers. | Runtime registry wiring in daemon, attach/send abstractions in ports/API, and selection config. | +| Workspace adapter | git worktree adapter implements create/destroy/restore/list with safety checks. | Repo resolver backed by registered projects and daemon wiring into Session Manager. | +| GitHub issue tracker | `backend/internal/adapters/tracker/github` implements read-only issue `Get`, `List`, and `Preflight`. | Tracker registry/config, spawn prompt hydration, and project tracker metadata. | +| PR facts storage | SQLite PR/check/comment writes and CDC triggers exist. | SCM/PR observer that fetches GitHub PR/CI/review facts and calls `LCM.ApplyPRObservation`. | +| Session read model | `SessionManager.List/Get` derive display status from canonical lifecycle + PR facts. | HTTP response DTOs and API routes for CLI/frontend reads. | + +### Still Missing + +These are the main gaps before the full initial command set is real. + +| Gap | Blocks | +|---|---| +| Cobra dependency and CLI packages. | All CLI commands. | +| Daemon extraction from `backend/main.go` into `internal/daemon`. | `ao daemon`, `ao start`, tests around daemon startup. | +| CLI process runner and PID signal helpers. | `ao start`, `ao stop`. | +| Loopback HTTP client package with run-file discovery. | `ao status`, later all daemon-backed commands. | +| Shutdown mechanism choice: PID signal now, optional `POST /api/v1/daemon/shutdown` later. | `ao stop` polish and cross-platform behavior. | +| HTTP API route surface under `/api/v1`. | `project`, `spawn`, `session`, `send`, `events list`, richer `status`. | +| SSE route for live CDC events plus durable catch-up reads. | `ao events tail`, frontend live updates. | +| Agent adapters for supported harnesses (`codex`, `claude-code`, etc.). | `ao spawn`, `ao session restore`. | +| AgentMessenger implementation over tmux/zellij. | `ao send`, LCM auto-nudge reactions. | +| Runtime registry wired with tmux/zellij. | Reaper liveness, `session attach`, spawn/kill/restore runtime work. | +| Notifier implementation/multiplexer. | Human notifications and LCM escalation side effects. | +| Activity hooks or agent self-report protocol. | Accurate working/idle/needs-input status beyond runtime/PR facts. | +| Project/tracker config model. | `project add/show`, tracker-backed `spawn`, `doctor` config checks. | +| OpenAPI/DTO/error contract. | Stable CLI/frontend API clients and tests. | + +### Command Readiness Matrix + +| Command | Can implement now? | Existing support | Remaining work | +|---|---:|---|---| +| `ao daemon` | Implemented | Current daemon startup is extracted to `internal/daemon.Run`. | None for foundation. | +| `ao start` | Implemented | Config, run-file stale check, HTTP readiness probes. | Later: package-manager/service integration if needed. | +| `ao stop` | Implemented | Run-file discovery gives PID/port; server exits cleanly on SIGINT/SIGTERM. | Optional later shutdown HTTP route. | +| `ao status` | Partially implemented | Run-file, process liveness via PID, `/healthz`, `/readyz`. | Rich project/session summary waits for `/api/v1/projects` and `/api/v1/sessions`. | +| `ao doctor` | Partially implemented | Config resolution, run-file, storage open, runtime binary checks. | Deeper adapter preflights need daemon wiring/config. | +| `ao completion` | Implemented | Cobra generators. | None for foundation. | +| `ao version` | Implemented | Build metadata can be injected with `-ldflags`. | Release tooling needs to set metadata. | +| `ao project list/add/show/remove` | Not yet | SQLite project CRUD exists. | Project service and HTTP routes. CLI must not write SQLite directly. | +| `ao spawn` | Not yet | Session Manager exists; runtime/workspace/tracker pieces partly exist. | Agent adapters, registry/config wiring, project lookup, tracker hydration, HTTP route. | +| `ao session list/show` | Not yet | Store and Session Manager read model exist. | HTTP routes and response DTOs. | +| `ao session attach` | Not yet | tmux/zellij have attach command helpers. | Runtime attach port/API and terminal-launch policy. | +| `ao session kill/restore` | Not yet | Session Manager implements both. | Production wiring and HTTP routes. | +| `ao send` | Not yet | Session Manager has `Send`; tmux/zellij have send helpers. | AgentMessenger implementation, port/API wiring, busy/idle delivery policy. | +| `ao events tail/list` | Not yet | Durable `change_log`, CDC poller, in-process broadcaster. | SSE route and durable event-list route. | + +### Recommended Build Order + +1. Build CLI foundation around the daemon only: `daemon`, `start`, `stop`, + `status`, `doctor`, `completion`, `version`. +2. Add `/api/v1/projects` over a small project service, then implement + `project list/add/show/remove`. +3. Wire production Session Manager dependencies: project-backed repo resolver, + tmux/zellij runtime registry, first agent adapter, and AgentMessenger. +4. Add `/api/v1/sessions` and implement `spawn`, `session list/show/kill/restore`, + and `send`. +5. Add `/events` SSE plus event-list reads, then implement `events tail/list`. From f72facb9e5d345a112b221ba941b40dfd0b6ba89 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Sun, 31 May 2026 21:37:43 +0530 Subject: [PATCH 076/250] fix(cli): address greptile review comments --- backend/go.mod | 2 +- backend/internal/cli/completion.go | 7 ++++--- backend/internal/cli/process_windows.go | 21 +++++++++++++++++---- docs/cli/README.md | 5 ++--- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index adc5603942..a2de66a0a0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,6 +8,7 @@ require ( github.com/go-chi/chi/v5 v5.1.0 github.com/pressly/goose/v3 v3.27.1 github.com/spf13/cobra v1.10.1 + golang.org/x/sys v0.43.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.51.0 ) @@ -24,7 +25,6 @@ require ( github.com/spf13/pflag v1.0.9 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/backend/internal/cli/completion.go b/backend/internal/cli/completion.go index 97970395a1..61b9483efc 100644 --- a/backend/internal/cli/completion.go +++ b/backend/internal/cli/completion.go @@ -8,9 +8,10 @@ import ( func newCompletionCommand() *cobra.Command { return &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", - Short: "Generate shell completion scripts", - Args: cobra.ExactArgs(1), + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Args: cobra.ExactArgs(1), + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, RunE: func(cmd *cobra.Command, args []string) error { root := cmd.Root() out := cmd.OutOrStdout() diff --git a/backend/internal/cli/process_windows.go b/backend/internal/cli/process_windows.go index da109f1165..430cea8bf6 100644 --- a/backend/internal/cli/process_windows.go +++ b/backend/internal/cli/process_windows.go @@ -2,18 +2,31 @@ package cli -import "os" +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) func processAlive(pid int) bool { if pid <= 0 { return false } - p, err := os.FindProcess(pid) + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if err != nil { + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return true + } + return false + } + defer windows.CloseHandle(handle) + + status, err := windows.WaitForSingleObject(handle, 0) if err != nil { return false } - _ = p.Release() - return true + return status == uint32(windows.WAIT_TIMEOUT) } func signalTerm(pid int) error { diff --git a/docs/cli/README.md b/docs/cli/README.md index 3d49e00d56..dd83c306ee 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -103,9 +103,8 @@ logic in-process. The rewrite needs the CLI to sit outside the core daemon. ## Current Legacy CLI Inventory -Inventory source: installed `ao` binary at version `0.9.2`, plus -`/Users/dhruvsharma/Development/agent-orchestrator/packages/cli/src/program.ts` -and `packages/cli/src/commands/*.ts`. +Inventory source: installed `ao` binary at version `0.9.2`, plus the old +`packages/cli/src/program.ts` and `packages/cli/src/commands/*.ts` files. Count: From 925e70763da188d13869743140a569c97d833c3a Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Sun, 31 May 2026 21:45:00 +0530 Subject: [PATCH 077/250] fix(cli): verify daemon ownership before stop signal --- backend/internal/cli/root_test.go | 50 +++++++++++++++++++++++- backend/internal/cli/status.go | 60 ++++++++++++++++++++++------- backend/internal/cli/stop.go | 6 +++ backend/internal/daemonmeta/meta.go | 6 +++ backend/internal/httpd/router.go | 14 ++++++- 5 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 backend/internal/daemonmeta/meta.go diff --git a/backend/internal/cli/root_test.go b/backend/internal/cli/root_test.go index 119bf16aa2..73387be88a 100644 --- a/backend/internal/cli/root_test.go +++ b/backend/internal/cli/root_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "fmt" "net" "net/http" "net/http/httptest" @@ -13,6 +14,7 @@ import ( "testing" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/daemonmeta" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" ) @@ -51,9 +53,9 @@ func TestStartReturnsExistingReadyDaemon(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/healthz": - _, _ = w.Write([]byte(`{"status":"ok"}`)) + _, _ = fmt.Fprintf(w, `{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) case "/readyz": - _, _ = w.Write([]byte(`{"status":"ready"}`)) + _, _ = fmt.Fprintf(w, `{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) default: http.NotFound(w, r) } @@ -107,6 +109,50 @@ func TestStopRemovesStaleRunFile(t *testing.T) { } } +func TestStopDoesNotSignalUnverifiedReusedPID(t *testing.T) { + cfg := setConfigEnv(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + _, _ = w.Write([]byte(`{"status":"ok"}`)) + case "/readyz": + _, _ = w.Write([]byte(`{"status":"ready"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: serverPort(t, srv.URL), StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + var signaled bool + out, _, err := executeCLI(t, Deps{ + ProcessAlive: func(pid int) bool { return pid == 4242 }, + SignalTerm: func(pid int) error { + signaled = true + return nil + }, + }, "stop", "--json") + if err != nil { + t.Fatal(err) + } + if signaled { + t.Fatal("stop signaled a PID whose health probe did not prove AO daemon ownership") + } + if !strings.Contains(out, `"state": "stopped"`) { + t.Fatalf("stop did not report stopped:\n%s", out) + } + info, err := runfile.Read(cfg.runFile) + if err != nil { + t.Fatal(err) + } + if info != nil { + t.Fatalf("unverified run-file was not removed: %#v", info) + } +} + type testConfig struct { runFile string dataDir string diff --git a/backend/internal/cli/status.go b/backend/internal/cli/status.go index 04fd755b4f..ea6cb52807 100644 --- a/backend/internal/cli/status.go +++ b/backend/internal/cli/status.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/daemonmeta" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" ) @@ -30,6 +31,13 @@ type daemonStatus struct { Health string `json:"health,omitempty"` Ready string `json:"ready,omitempty"` Error string `json:"error,omitempty"` + owned bool +} + +type probeResult struct { + Status string `json:"status"` + Service string `json:"service"` + PID int `json:"pid"` } func newStatusCommand(ctx *commandContext) *cobra.Command { @@ -81,11 +89,21 @@ func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error health, err := c.readProbe(ctx, info.Port, "healthz") if err != nil { - st.State = "unhealthy" + st.State = "stale" st.Error = err.Error() return st, nil } - st.Health = health + if err := verifyProbeOwner(health, info.PID, "healthz"); err != nil { + st.State = "stale" + st.Error = err.Error() + return st, nil + } + st.owned = true + st.Health = health.Status + if health.Status != "ok" { + st.State = "unhealthy" + return st, nil + } ready, err := c.readProbe(ctx, info.Port, "readyz") if err != nil { @@ -93,8 +111,14 @@ func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error st.Error = err.Error() return st, nil } - st.Ready = ready - if ready == "ready" { + if err := verifyProbeOwner(ready, info.PID, "readyz"); err != nil { + st.State = "stale" + st.owned = false + st.Error = err.Error() + return st, nil + } + st.Ready = ready.Status + if ready.Status == "ready" { st.State = "ready" return st, nil } @@ -102,32 +126,40 @@ func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error return st, nil } -func (c *commandContext) readProbe(ctx context.Context, port int, path string) (string, error) { +func (c *commandContext) readProbe(ctx context.Context, port int, path string) (probeResult, error) { reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, fmt.Sprintf("http://%s:%d/%s", config.LoopbackHost, port, path), nil) if err != nil { - return "", err + return probeResult{}, err } resp, err := c.deps.HTTPClient.Do(req) if err != nil { - return "", fmt.Errorf("%s: %w", path, err) + return probeResult{}, fmt.Errorf("%s: %w", path, err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("%s: HTTP %d", path, resp.StatusCode) - } - var body struct { - Status string `json:"status"` + return probeResult{}, fmt.Errorf("%s: HTTP %d", path, resp.StatusCode) } + var body probeResult if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return "", fmt.Errorf("%s: decode response: %w", path, err) + return probeResult{}, fmt.Errorf("%s: decode response: %w", path, err) } if body.Status == "" { - return "", fmt.Errorf("%s: missing status", path) + return probeResult{}, fmt.Errorf("%s: missing status", path) } - return body.Status, nil + return body, nil +} + +func verifyProbeOwner(probe probeResult, wantPID int, path string) error { + if probe.Service != daemonmeta.ServiceName { + return fmt.Errorf("%s: response is not from AO daemon", path) + } + if probe.PID != wantPID { + return fmt.Errorf("%s: daemon pid %d does not match run-file pid %d", path, probe.PID, wantPID) + } + return nil } func writeStatus(cmd *cobra.Command, st daemonStatus) error { diff --git a/backend/internal/cli/stop.go b/backend/internal/cli/stop.go index 5ce43b4fc4..1b62694092 100644 --- a/backend/internal/cli/stop.go +++ b/backend/internal/cli/stop.go @@ -61,6 +61,12 @@ func (c *commandContext) stopDaemon(ctx context.Context, opts stopOptions) (daem } return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil } + if !st.owned { + if err := runfile.Remove(cfg.RunFilePath); err != nil { + return daemonStatus{}, err + } + return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil + } if err := c.deps.SignalTerm(st.PID); err != nil { if c.deps.ProcessAlive(st.PID) { diff --git a/backend/internal/daemonmeta/meta.go b/backend/internal/daemonmeta/meta.go new file mode 100644 index 0000000000..72f322e5cc --- /dev/null +++ b/backend/internal/daemonmeta/meta.go @@ -0,0 +1,6 @@ +package daemonmeta + +// ServiceName identifies the AO daemon in loopback health/readiness probes. +// The CLI uses it with the reported PID to avoid signaling an unrelated process +// when a stale run-file's PID has been reused. +const ServiceName = "agent-orchestrator-daemon" diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 019f7efe9d..6f142d136b 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -7,11 +7,13 @@ package httpd import ( "log/slog" "net/http" + "os" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/daemonmeta" "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) @@ -68,12 +70,20 @@ func mountHealth(r chi.Router) { // handleHealthz is the liveness probe: it answers 200 as long as the process is // up and serving. It does no dependency checks by design. func handleHealthz(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "service": daemonmeta.ServiceName, + "pid": os.Getpid(), + }) } // handleReadyz is the readiness probe. In the 1a skeleton the daemon is ready // as soon as it is listening; later phases will gate this on dependency // initialisation (e.g. store/event-bus warm-up). func handleReadyz(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ready", + "service": daemonmeta.ServiceName, + "pid": os.Getpid(), + }) } From a614462d387d28dd8f03585e33a6fbac3aee47b4 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Sun, 31 May 2026 21:52:08 +0530 Subject: [PATCH 078/250] fix(cli): preserve live daemon state on probe failures --- backend/internal/cli/root_test.go | 94 +++++++++++++++++++++++++++++++ backend/internal/cli/status.go | 2 +- backend/internal/cli/stop.go | 6 +- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/backend/internal/cli/root_test.go b/backend/internal/cli/root_test.go index 73387be88a..307b80a57d 100644 --- a/backend/internal/cli/root_test.go +++ b/backend/internal/cli/root_test.go @@ -153,6 +153,81 @@ func TestStopDoesNotSignalUnverifiedReusedPID(t *testing.T) { } } +func TestStatusKeepsLiveProbeFailureUnhealthy(t *testing.T) { + cfg := setConfigEnv(t) + if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + out, _, err := executeCLI(t, Deps{ + ProcessAlive: func(pid int) bool { return pid == 4242 }, + }, "status", "--json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"state": "unhealthy"`) { + t.Fatalf("status should keep live probe failures unhealthy:\n%s", out) + } + info, err := runfile.Read(cfg.runFile) + if err != nil { + t.Fatal(err) + } + if info == nil { + t.Fatal("live probe failure should not remove run-file") + } +} + +func TestStopRefusesUnverifiedLivePID(t *testing.T) { + cfg := setConfigEnv(t) + if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + var signaled bool + _, _, err := executeCLI(t, Deps{ + ProcessAlive: func(pid int) bool { return pid == 4242 }, + SignalTerm: func(pid int) error { + signaled = true + return nil + }, + }, "stop", "--json") + if err == nil { + t.Fatal("stop should fail when daemon ownership cannot be verified") + } + if signaled { + t.Fatal("stop signaled a PID whose ownership was unknown") + } + info, err := runfile.Read(cfg.runFile) + if err != nil { + t.Fatal(err) + } + if info == nil { + t.Fatal("unverified live PID should remain tracked") + } +} + +func TestStartDoesNotSpawnWhenLiveProbeFails(t *testing.T) { + cfg := setConfigEnv(t) + if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + var started bool + _, _, err := executeCLI(t, Deps{ + ProcessAlive: func(pid int) bool { return pid == 4242 }, + StartProcess: func(processStartConfig) (processHandle, error) { + started = true + return processHandle{}, nil + }, + }, "start", "--timeout", "1ns", "--json") + if err == nil { + t.Fatal("start should fail instead of spawning over a live unverified PID") + } + if started { + t.Fatal("start spawned while run-file PID was still alive") + } +} + type testConfig struct { runFile string dataDir string @@ -203,3 +278,22 @@ func serverPort(t *testing.T, raw string) int { } return port } + +func closedPort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + _, portRaw, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portRaw) + if err != nil { + t.Fatal(err) + } + return port +} diff --git a/backend/internal/cli/status.go b/backend/internal/cli/status.go index ea6cb52807..85cbf5ac63 100644 --- a/backend/internal/cli/status.go +++ b/backend/internal/cli/status.go @@ -89,7 +89,7 @@ func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error health, err := c.readProbe(ctx, info.Port, "healthz") if err != nil { - st.State = "stale" + st.State = "unhealthy" st.Error = err.Error() return st, nil } diff --git a/backend/internal/cli/stop.go b/backend/internal/cli/stop.go index 1b62694092..f6817c0fd0 100644 --- a/backend/internal/cli/stop.go +++ b/backend/internal/cli/stop.go @@ -62,10 +62,10 @@ func (c *commandContext) stopDaemon(ctx context.Context, opts stopOptions) (daem return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil } if !st.owned { - if err := runfile.Remove(cfg.RunFilePath); err != nil { - return daemonStatus{}, err + if st.Error != "" { + return daemonStatus{}, fmt.Errorf("daemon pid %d is alive but ownership could not be verified: %s", st.PID, st.Error) } - return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil + return daemonStatus{}, fmt.Errorf("daemon pid %d is alive but ownership could not be verified", st.PID) } if err := c.deps.SignalTerm(st.PID); err != nil { From 2f4662b470811ab77982a44598053a07cb485c16 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Sun, 31 May 2026 22:10:54 +0530 Subject: [PATCH 079/250] fix(cli): handle stale start and graceful shutdown --- backend/internal/cli/process_unix.go | 8 -- backend/internal/cli/process_windows.go | 13 --- backend/internal/cli/root.go | 5 - backend/internal/cli/root_test.go | 118 ++++++++++++++++++++---- backend/internal/cli/start.go | 6 ++ backend/internal/cli/stop.go | 28 ++++-- backend/internal/httpd/router.go | 23 +++++ backend/internal/httpd/server.go | 33 +++++-- backend/internal/httpd/server_test.go | 43 +++++++++ 9 files changed, 220 insertions(+), 57 deletions(-) diff --git a/backend/internal/cli/process_unix.go b/backend/internal/cli/process_unix.go index bd28047cdc..61aa333bb8 100644 --- a/backend/internal/cli/process_unix.go +++ b/backend/internal/cli/process_unix.go @@ -4,7 +4,6 @@ package cli import ( "errors" - "os" "syscall" ) @@ -15,10 +14,3 @@ func processAlive(pid int) bool { err := syscall.Kill(pid, 0) return err == nil || errors.Is(err, syscall.EPERM) } - -func signalTerm(pid int) error { - if pid <= 0 { - return os.ErrProcessDone - } - return syscall.Kill(pid, syscall.SIGTERM) -} diff --git a/backend/internal/cli/process_windows.go b/backend/internal/cli/process_windows.go index 430cea8bf6..216431d89c 100644 --- a/backend/internal/cli/process_windows.go +++ b/backend/internal/cli/process_windows.go @@ -4,7 +4,6 @@ package cli import ( "errors" - "os" "golang.org/x/sys/windows" ) @@ -28,15 +27,3 @@ func processAlive(pid int) bool { } return status == uint32(windows.WAIT_TIMEOUT) } - -func signalTerm(pid int) error { - if pid <= 0 { - return os.ErrProcessDone - } - p, err := os.FindProcess(pid) - if err != nil { - return err - } - defer p.Release() - return p.Kill() -} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index cb40363f69..c49a0339b8 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -29,7 +29,6 @@ type Deps struct { HTTPClient *http.Client Executable func() (string, error) StartProcess func(processStartConfig) (processHandle, error) - SignalTerm func(pid int) error ProcessAlive func(pid int) bool LookPath func(file string) (string, error) Now func() time.Time @@ -45,7 +44,6 @@ func DefaultDeps() Deps { HTTPClient: &http.Client{Timeout: 2 * time.Second}, Executable: os.Executable, StartProcess: startProcess, - SignalTerm: signalTerm, ProcessAlive: processAlive, LookPath: exec.LookPath, Now: time.Now, @@ -73,9 +71,6 @@ func (d Deps) withDefaults() Deps { if d.StartProcess == nil { d.StartProcess = def.StartProcess } - if d.SignalTerm == nil { - d.SignalTerm = def.SignalTerm - } if d.ProcessAlive == nil { d.ProcessAlive = def.ProcessAlive } diff --git a/backend/internal/cli/root_test.go b/backend/internal/cli/root_test.go index 307b80a57d..5b9205315a 100644 --- a/backend/internal/cli/root_test.go +++ b/backend/internal/cli/root_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strconv" "strings" + "sync/atomic" "testing" "time" @@ -87,6 +88,57 @@ func TestStartReturnsExistingReadyDaemon(t *testing.T) { } } +func TestStartClearsStaleRunFileBeforeSpawning(t *testing.T) { + cfg := setConfigEnv(t) + var spawned atomic.Bool + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !spawned.Load() { + _, _ = fmt.Fprintf(w, `{"status":"ok","service":"not-ao","pid":4242}`) + return + } + switch r.URL.Path { + case "/healthz": + _, _ = fmt.Fprintf(w, `{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) + case "/readyz": + _, _ = fmt.Fprintf(w, `{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + port := serverPort(t, srv.URL) + if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: port, StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + out, _, err := executeCLI(t, Deps{ + ProcessAlive: func(pid int) bool { return pid == 4242 || pid == os.Getpid() }, + StartProcess: func(processStartConfig) (processHandle, error) { + info, err := runfile.Read(cfg.runFile) + if err != nil { + t.Fatal(err) + } + if info != nil { + t.Fatalf("stale run-file was not removed before spawn: %#v", info) + } + spawned.Store(true) + if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(110, 0).UTC()}); err != nil { + t.Fatal(err) + } + return processHandle{PID: os.Getpid()}, nil + }, + Now: func() time.Time { return time.Unix(120, 0).UTC() }, + }, "start", "--json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"state": "ready"`) { + t.Fatalf("start did not report ready after clearing stale run-file:\n%s", out) + } +} + func TestStopRemovesStaleRunFile(t *testing.T) { cfg := setConfigEnv(t) if err := runfile.Write(cfg.runFile, runfile.Info{PID: 999999, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil { @@ -109,14 +161,18 @@ func TestStopRemovesStaleRunFile(t *testing.T) { } } -func TestStopDoesNotSignalUnverifiedReusedPID(t *testing.T) { +func TestStopDoesNotShutdownUnverifiedReusedPID(t *testing.T) { cfg := setConfigEnv(t) + shutdownCalled := make(chan struct{}, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/healthz": _, _ = w.Write([]byte(`{"status":"ok"}`)) case "/readyz": _, _ = w.Write([]byte(`{"status":"ready"}`)) + case "/shutdown": + shutdownCalled <- struct{}{} + http.Error(w, "unexpected shutdown", http.StatusInternalServerError) default: http.NotFound(w, r) } @@ -127,19 +183,16 @@ func TestStopDoesNotSignalUnverifiedReusedPID(t *testing.T) { t.Fatal(err) } - var signaled bool out, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { return pid == 4242 }, - SignalTerm: func(pid int) error { - signaled = true - return nil - }, }, "stop", "--json") if err != nil { t.Fatal(err) } - if signaled { - t.Fatal("stop signaled a PID whose health probe did not prove AO daemon ownership") + select { + case <-shutdownCalled: + t.Fatal("stop requested shutdown from a process whose health probe did not prove AO daemon ownership") + default: } if !strings.Contains(out, `"state": "stopped"`) { t.Fatalf("stop did not report stopped:\n%s", out) @@ -153,6 +206,47 @@ func TestStopDoesNotSignalUnverifiedReusedPID(t *testing.T) { } } +func TestStopUsesShutdownEndpoint(t *testing.T) { + cfg := setConfigEnv(t) + shutdownCalled := make(chan struct{}, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + _, _ = fmt.Fprintf(w, `{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) + case "/readyz": + _, _ = fmt.Fprintf(w, `{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) + case "/shutdown": + if err := runfile.Remove(cfg.runFile); err != nil { + t.Fatal(err) + } + shutdownCalled <- struct{}{} + _, _ = fmt.Fprintf(w, `{"status":"shutting_down","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: serverPort(t, srv.URL), StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + out, _, err := executeCLI(t, Deps{ + ProcessAlive: func(pid int) bool { return pid == os.Getpid() }, + }, "stop", "--json") + if err != nil { + t.Fatal(err) + } + select { + case <-shutdownCalled: + default: + t.Fatal("stop did not call daemon shutdown endpoint") + } + if !strings.Contains(out, `"state": "stopped"`) { + t.Fatalf("stop did not report stopped:\n%s", out) + } +} + func TestStatusKeepsLiveProbeFailureUnhealthy(t *testing.T) { cfg := setConfigEnv(t) if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil { @@ -183,20 +277,12 @@ func TestStopRefusesUnverifiedLivePID(t *testing.T) { t.Fatal(err) } - var signaled bool _, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { return pid == 4242 }, - SignalTerm: func(pid int) error { - signaled = true - return nil - }, }, "stop", "--json") if err == nil { t.Fatal("stop should fail when daemon ownership cannot be verified") } - if signaled { - t.Fatal("stop signaled a PID whose ownership was unknown") - } info, err := runfile.Read(cfg.runFile) if err != nil { t.Fatal(err) diff --git a/backend/internal/cli/start.go b/backend/internal/cli/start.go index 9dc4a222ea..0787eba956 100644 --- a/backend/internal/cli/start.go +++ b/backend/internal/cli/start.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" ) const defaultStartTimeout = 10 * time.Second @@ -66,6 +67,11 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da } return daemonStatus{}, fmt.Errorf("daemon process exists but did not become ready: %w", waitErr) } + if st.State == "stale" { + if err := runfile.Remove(cfg.RunFilePath); err != nil { + return daemonStatus{}, err + } + } exe, err := c.deps.Executable() if err != nil { diff --git a/backend/internal/cli/stop.go b/backend/internal/cli/stop.go index f6817c0fd0..41d42d3077 100644 --- a/backend/internal/cli/stop.go +++ b/backend/internal/cli/stop.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "net/http" "time" "github.com/spf13/cobra" @@ -68,16 +69,31 @@ func (c *commandContext) stopDaemon(ctx context.Context, opts stopOptions) (daem return daemonStatus{}, fmt.Errorf("daemon pid %d is alive but ownership could not be verified", st.PID) } - if err := c.deps.SignalTerm(st.PID); err != nil { - if c.deps.ProcessAlive(st.PID) { - return daemonStatus{}, fmt.Errorf("signal daemon pid %d: %w", st.PID, err) - } - _ = runfile.Remove(cfg.RunFilePath) - return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil + if err := c.requestShutdown(ctx, st.Port); err != nil { + return daemonStatus{}, fmt.Errorf("request daemon shutdown: %w", err) } return c.waitForStopped(ctx, st.PID, cfg.RunFilePath, cfg.DataDir, opts.timeout) } +func (c *commandContext) requestShutdown(ctx context.Context, port int) error { + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, fmt.Sprintf("http://%s:%d/shutdown", config.LoopbackHost, port), nil) + if err != nil { + return err + } + resp, err := c.deps.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + return nil +} + func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePath, dataDir string, timeout time.Duration) (daemonStatus, error) { if timeout <= 0 { timeout = defaultStopTimeout diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 6f142d136b..d406b029c3 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -36,10 +36,18 @@ func NewRouter(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) c return NewRouterWithAPI(cfg, log, termMgr, APIDeps{}) } +type ControlDeps struct { + RequestShutdown func() +} + // NewRouterWithAPI is the dependency-injected variant. main.go calls it with // real Managers when they exist; tests/dev wiring inject mocks explicitly. // Missing Managers intentionally keep the route-shell 501 behavior. func NewRouterWithAPI(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps) chi.Router { + return NewRouterWithControl(cfg, log, termMgr, deps, ControlDeps{}) +} + +func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps, control ControlDeps) chi.Router { r := chi.NewRouter() r.Use(middleware.Recoverer) @@ -55,6 +63,7 @@ func NewRouterWithAPI(cfg config.Config, log *slog.Logger, termMgr *terminal.Man mountHealth(r) mountMux(r, termMgr, log) + mountControl(r, control) NewAPI(cfg, deps).Register(r) return r @@ -67,6 +76,20 @@ func mountHealth(r chi.Router) { r.Get("/readyz", handleReadyz) } +func mountControl(r chi.Router, deps ControlDeps) { + if deps.RequestShutdown == nil { + return + } + r.Post("/shutdown", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusAccepted, map[string]any{ + "status": "shutting_down", + "service": daemonmeta.ServiceName, + "pid": os.Getpid(), + }) + deps.RequestShutdown() + }) +} + // handleHealthz is the liveness probe: it answers 200 as long as the process is // up and serving. It does no dependency checks by design. func handleHealthz(w http.ResponseWriter, _ *http.Request) { diff --git a/backend/internal/httpd/server.go b/backend/internal/httpd/server.go index 506f78b5c6..0ed67eafb9 100644 --- a/backend/internal/httpd/server.go +++ b/backend/internal/httpd/server.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "os" + "sync" "time" "github.com/aoagents/agent-orchestrator/backend/internal/config" @@ -23,6 +24,9 @@ type Server struct { log *slog.Logger http *http.Server listen net.Listener + + shutdownRequested chan struct{} + shutdownOnce sync.Once } // New constructs a Server and binds the listener immediately so a port @@ -36,15 +40,18 @@ func New(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) (*Serve } srv := &Server{ - cfg: cfg, - log: log, - listen: ln, - http: &http.Server{ - Handler: NewRouter(cfg, log, termMgr), - // ReadHeaderTimeout guards against slow-loris even on loopback; - // per-request body/handler timeouts are applied per-surface. - ReadHeaderTimeout: 10 * time.Second, - }, + cfg: cfg, + log: log, + listen: ln, + shutdownRequested: make(chan struct{}), + } + srv.http = &http.Server{ + Handler: NewRouterWithControl(cfg, log, termMgr, APIDeps{}, ControlDeps{ + RequestShutdown: srv.requestShutdown, + }), + // ReadHeaderTimeout guards against slow-loris even on loopback; + // per-request body/handler timeouts are applied per-surface. + ReadHeaderTimeout: 10 * time.Second, } return srv, nil } @@ -89,6 +96,8 @@ func (s *Server) Run(ctx context.Context) error { // Serve died on its own (bind already happened, so this is a real // runtime failure) before any shutdown signal. return err + case <-s.shutdownRequested: + s.log.Info("shutdown requested over HTTP", "timeout", s.cfg.ShutdownTimeout) case <-ctx.Done(): s.log.Info("shutdown signal received, draining connections", "timeout", s.cfg.ShutdownTimeout) } @@ -113,3 +122,9 @@ func (s *Server) boundPort() int { } return s.cfg.Port } + +func (s *Server) requestShutdown() { + s.shutdownOnce.Do(func() { + close(s.shutdownRequested) + }) +} diff --git a/backend/internal/httpd/server_test.go b/backend/internal/httpd/server_test.go index 39270d1ca0..0248e8663e 100644 --- a/backend/internal/httpd/server_test.go +++ b/backend/internal/httpd/server_test.go @@ -91,6 +91,49 @@ func TestServerLifecycle(t *testing.T) { } } +func TestServerShutdownEndpoint(t *testing.T) { + runPath := filepath.Join(t.TempDir(), "running.json") + cfg := config.Config{ + Host: "127.0.0.1", + Port: 0, + ShutdownTimeout: 5 * time.Second, + RunFilePath: runPath, + } + + srv, err := New(cfg, discardLogger()) + if err != nil { + t.Fatalf("New: %v", err) + } + + runErr := make(chan error, 1) + go func() { runErr <- srv.Run(context.Background()) }() + + base := "http://" + srv.Addr().String() + waitForHealth(t, base) + + resp, err := http.Post(base+"/shutdown", "application/json", nil) + if err != nil { + t.Fatalf("POST /shutdown: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("POST /shutdown = %d, want 202", resp.StatusCode) + } + + select { + case err := <-runErr: + if err != nil { + t.Fatalf("Run returned error on shutdown endpoint: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("Run did not return after shutdown endpoint") + } + + if after, _ := runfile.Read(runPath); after != nil { + t.Error("run-file still present after shutdown endpoint; want it removed") + } +} + func waitForHealth(t *testing.T, base string) { t.Helper() // Per-request timeout so a stalled connect or hung handshake doesn't park From 0d8ffcd17a6769ff501610ae03d9fd787eb92c25 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Mon, 1 Jun 2026 01:50:43 +0530 Subject: [PATCH 080/250] fix(httpd): update server_test for termMgr arg after rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shutdown endpoint test was authored against the pre-rebase httpd.New(cfg, log) signature. After rebasing onto main, the terminal manager (from #50) made termMgr a required third arg. Pass nil — the test exercises /shutdown, not /mux, so the terminal surface stays off. Co-Authored-By: Claude Opus 4.7 --- backend/internal/httpd/server_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/httpd/server_test.go b/backend/internal/httpd/server_test.go index 0248e8663e..2b7ba4f3b9 100644 --- a/backend/internal/httpd/server_test.go +++ b/backend/internal/httpd/server_test.go @@ -100,7 +100,7 @@ func TestServerShutdownEndpoint(t *testing.T) { RunFilePath: runPath, } - srv, err := New(cfg, discardLogger()) + srv, err := New(cfg, discardLogger(), nil) if err != nil { t.Fatalf("New: %v", err) } From 2d00e4675d1575a8942989e39cc75176c73b32f2 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 1 Jun 2026 01:55:14 +0530 Subject: [PATCH 081/250] fix(cli): harden daemon control surface and stop CLI from writing the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on PR #53 (on top of the rebase onto main). - doctor: stop opening/migrating SQLite. The daemon is the sole store writer/migrator (architecture.md §7); the CLI must not run migrations or open a second writer against a DB a live daemon owns. doctor now reports database-file presence and gains --json. - stop: only remove running.json when it still belongs to the PID we stopped, so a concurrent `ao start` that wrote a new run-file is not clobbered into looking stopped. - httpd: gate POST /shutdown to loopback callers with no Origin header, closing the CSRF / DNS-rebinding vector against an unauthenticated, state-changing endpoint. - start: detach the spawned daemon into its own session/process group so a Ctrl-C while `ao start` waits for readiness doesn't also kill it. - cli: exit 2 for usage errors (bad flag / arg count) vs 1 for runtime failures. - daemon: unexport newLogger (only used in-package). - tests: /shutdown guard (cross-origin + rebinding) and stop run-file ownership guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/cmd/ao/main.go | 2 +- backend/internal/cli/completion.go | 11 +++- backend/internal/cli/doctor.go | 76 ++++++++++++++++------ backend/internal/cli/process.go | 4 ++ backend/internal/cli/process_unix.go | 7 +++ backend/internal/cli/process_windows.go | 7 +++ backend/internal/cli/root.go | 27 ++++++++ backend/internal/cli/stop.go | 10 ++- backend/internal/cli/stop_test.go | 83 +++++++++++++++++++++++++ backend/internal/daemon/daemon.go | 6 +- backend/internal/httpd/control_test.go | 52 ++++++++++++++++ backend/internal/httpd/router.go | 37 ++++++++++- docs/cli/README.md | 41 ++++++------ 13 files changed, 316 insertions(+), 47 deletions(-) create mode 100644 backend/internal/cli/stop_test.go create mode 100644 backend/internal/httpd/control_test.go diff --git a/backend/cmd/ao/main.go b/backend/cmd/ao/main.go index 1ee35d622d..d1ea897c92 100644 --- a/backend/cmd/ao/main.go +++ b/backend/cmd/ao/main.go @@ -10,6 +10,6 @@ import ( func main() { if err := cli.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) - os.Exit(1) + os.Exit(cli.ExitCode(err)) } } diff --git a/backend/internal/cli/completion.go b/backend/internal/cli/completion.go index 61b9483efc..f4575de09b 100644 --- a/backend/internal/cli/completion.go +++ b/backend/internal/cli/completion.go @@ -8,9 +8,14 @@ import ( func newCompletionCommand() *cobra.Command { return &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", - Short: "Generate shell completion scripts", - Args: cobra.ExactArgs(1), + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return usageError{err} + } + return nil + }, ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, RunE: func(cmd *cobra.Command, args []string) error { root := cmd.Root() diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index 3a452ac191..4c6953f2ea 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -2,13 +2,15 @@ package cli import ( "context" + "errors" "fmt" + "io/fs" "os" + "path/filepath" "github.com/spf13/cobra" "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) type doctorLevel string @@ -20,34 +22,53 @@ const ( ) type doctorCheck struct { - Level doctorLevel - Name string - Message string + Level doctorLevel `json:"level"` + Name string `json:"name"` + Message string `json:"message"` +} + +type doctorReport struct { + OK bool `json:"ok"` + Failures int `json:"failures"` + Checks []doctorCheck `json:"checks"` } func newDoctorCommand(ctx *commandContext) *cobra.Command { - return &cobra.Command{ + var asJSON bool + cmd := &cobra.Command{ Use: "doctor", Short: "Run local AO health checks", RunE: func(cmd *cobra.Command, args []string) error { checks := ctx.runDoctor(cmd.Context()) - for _, check := range checks { - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil { - return err - } - } - var failures int + failures := 0 for _, check := range checks { if check.Level == doctorFail { failures++ } } + + if asJSON { + if err := writeJSON(cmd.OutOrStdout(), doctorReport{ + OK: failures == 0, Failures: failures, Checks: checks, + }); err != nil { + return err + } + } else { + for _, check := range checks { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil { + return err + } + } + } + if failures > 0 { return fmt.Errorf("doctor found %d failing check(s)", failures) } return nil }, } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output health checks as JSON") + return cmd } func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { @@ -68,13 +89,7 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { checks = append(checks, doctorCheck{Level: doctorPass, Name: "data-dir", Message: cfg.DataDir}) } - store, err := sqlite.Open(cfg.DataDir) - if err != nil { - checks = append(checks, doctorCheck{Level: doctorFail, Name: "sqlite", Message: err.Error()}) - } else { - _ = store.Close() - checks = append(checks, doctorCheck{Level: doctorPass, Name: "sqlite", Message: "opened database and applied migrations"}) - } + checks = append(checks, checkStore(cfg.DataDir)) st, err := c.inspectDaemon(ctx) if err != nil { @@ -103,6 +118,31 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { return checks } +// checkStore inspects the SQLite store WITHOUT opening or migrating it. The +// daemon is the sole writer and migrator of the database (architecture.md §7); +// the CLI must never run migrations or open a second writer against a database +// a live daemon may already own. Migrations are validated by the daemon at +// startup and surfaced through /readyz, so doctor only confirms whether the +// database file exists yet. +func checkStore(dataDir string) doctorCheck { + dbPath := filepath.Join(dataDir, "ao.db") + info, err := os.Stat(dbPath) + switch { + case err == nil: + return doctorCheck{ + Level: doctorPass, Name: "sqlite", + Message: fmt.Sprintf("%s (%d bytes); migrations are applied by the daemon at startup", dbPath, info.Size()), + } + case errors.Is(err, fs.ErrNotExist): + return doctorCheck{ + Level: doctorWarn, Name: "sqlite", + Message: "database not created yet; run `ao start` to initialize and migrate it", + } + default: + return doctorCheck{Level: doctorFail, Name: "sqlite", Message: err.Error()} + } +} + func (c *commandContext) checkTool(name string, required bool) doctorCheck { path, err := c.deps.LookPath(name) if err == nil { diff --git a/backend/internal/cli/process.go b/backend/internal/cli/process.go index 3db4320909..19c4d19fd8 100644 --- a/backend/internal/cli/process.go +++ b/backend/internal/cli/process.go @@ -22,6 +22,10 @@ func startProcess(cfg processStartConfig) (processHandle, error) { cmd.Env = cfg.Env cmd.Stdout = cfg.Stdout cmd.Stderr = cfg.Stderr + // Detach the daemon into its own session/process group so a Ctrl-C in the + // terminal where `ao start` is waiting for readiness doesn't also SIGINT the + // freshly spawned daemon (it would otherwise share the launcher's group). + cmd.SysProcAttr = detachSysProcAttr() if err := cmd.Start(); err != nil { return processHandle{}, err } diff --git a/backend/internal/cli/process_unix.go b/backend/internal/cli/process_unix.go index 61aa333bb8..9963d9e9d4 100644 --- a/backend/internal/cli/process_unix.go +++ b/backend/internal/cli/process_unix.go @@ -14,3 +14,10 @@ func processAlive(pid int) bool { err := syscall.Kill(pid, 0) return err == nil || errors.Is(err, syscall.EPERM) } + +// detachSysProcAttr puts the daemon in a new session (Setsid) so it is no +// longer in the launcher's foreground process group and won't receive the +// terminal's SIGINT/SIGHUP. +func detachSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +} diff --git a/backend/internal/cli/process_windows.go b/backend/internal/cli/process_windows.go index 216431d89c..3ff8190a3a 100644 --- a/backend/internal/cli/process_windows.go +++ b/backend/internal/cli/process_windows.go @@ -4,6 +4,7 @@ package cli import ( "errors" + "syscall" "golang.org/x/sys/windows" ) @@ -27,3 +28,9 @@ func processAlive(pid int) bool { } return status == uint32(windows.WAIT_TIMEOUT) } + +// detachSysProcAttr starts the daemon in a new process group so it does not +// receive the console's CTRL_C/CTRL_BREAK while `ao start` waits for readiness. +func detachSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP} +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index c49a0339b8..36e83e5a95 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -3,6 +3,7 @@ package cli import ( + "errors" "io" "net/http" "os" @@ -19,6 +20,27 @@ func Execute() error { return NewRootCommand(DefaultDeps()).Execute() } +// usageError marks a command-line misuse (bad flag, wrong arg count). It lets +// the process entrypoint return exit code 2 for usage errors versus 1 for +// runtime failures, matching the convention CLIs are scripted against. +type usageError struct{ err error } + +func (e usageError) Error() string { return e.err.Error() } +func (e usageError) Unwrap() error { return e.err } + +// ExitCode maps a CLI error to a process exit code: 2 for usage errors, 1 for +// any other failure, 0 for success. +func ExitCode(err error) int { + if err == nil { + return 0 + } + var ue usageError + if errors.As(err, &ue) { + return 2 + } + return 1 +} + // Deps holds the small set of side effects the CLI needs. Tests replace these // functions without reaching into process-global state. type Deps struct { @@ -103,6 +125,11 @@ func NewRootCommand(deps Deps) *cobra.Command { root.SetOut(deps.Out) root.SetErr(deps.Err) root.CompletionOptions.DisableDefaultCmd = true + // Tag flag-parse failures as usage errors so the entrypoint can exit 2 for + // misuse versus 1 for runtime failures. Subcommands inherit this func. + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return usageError{err} + }) root.AddCommand(newDaemonCommand()) root.AddCommand(newStartCommand(ctx)) diff --git a/backend/internal/cli/stop.go b/backend/internal/cli/stop.go index 41d42d3077..9b00c1c406 100644 --- a/backend/internal/cli/stop.go +++ b/backend/internal/cli/stop.go @@ -115,8 +115,14 @@ func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePat return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil } if !alive { - if err := runfile.Remove(runFilePath); err != nil { - return daemonStatus{}, err + // Only remove the run-file if it still belongs to the process we + // stopped. A concurrent `ao start` may have already written a new + // run-file for a different daemon; removing that would corrupt its + // handshake and make a live daemon look stopped. + if info.PID == pid { + if err := runfile.Remove(runFilePath); err != nil { + return daemonStatus{}, err + } } return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil } diff --git a/backend/internal/cli/stop_test.go b/backend/internal/cli/stop_test.go new file mode 100644 index 0000000000..85b6a5092b --- /dev/null +++ b/backend/internal/cli/stop_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" +) + +// TestWaitForStoppedKeepsRunFileFromConcurrentStart guards against deleting a +// fresh daemon's handshake: if a concurrent `ao start` replaces running.json +// with a new live PID while we are polling the PID we stopped, waitForStopped +// must report stopped but leave the new run-file intact. +func TestWaitForStoppedKeepsRunFileFromConcurrentStart(t *testing.T) { + dir := t.TempDir() + runFile := filepath.Join(dir, "running.json") + + const stoppedPID, newPID = 1111, 2222 + // running.json now belongs to a different, live daemon. + if err := runfile.Write(runFile, runfile.Info{PID: newPID, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + c := &commandContext{deps: Deps{ + ProcessAlive: func(pid int) bool { return pid == newPID }, // stoppedPID is dead + Now: func() time.Time { return time.Unix(200, 0).UTC() }, + Sleep: func(time.Duration) {}, + }.withDefaults()} + + st, err := c.waitForStopped(context.Background(), stoppedPID, runFile, dir, time.Second) + if err != nil { + t.Fatal(err) + } + if st.State != "stopped" { + t.Fatalf("state = %q, want stopped", st.State) + } + + info, err := runfile.Read(runFile) + if err != nil { + t.Fatal(err) + } + if info == nil { + t.Fatal("new daemon's run-file was deleted by stop of a different PID") + } + if info.PID != newPID { + t.Fatalf("run-file PID = %d, want %d (new daemon)", info.PID, newPID) + } +} + +// TestWaitForStoppedRemovesOwnRunFile confirms the normal path still cleans up: +// when the dead PID owns the run-file, it is removed. +func TestWaitForStoppedRemovesOwnRunFile(t *testing.T) { + dir := t.TempDir() + runFile := filepath.Join(dir, "running.json") + + const stoppedPID = 1111 + if err := runfile.Write(runFile, runfile.Info{PID: stoppedPID, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + c := &commandContext{deps: Deps{ + ProcessAlive: func(int) bool { return false }, + Now: func() time.Time { return time.Unix(200, 0).UTC() }, + Sleep: func(time.Duration) {}, + }.withDefaults()} + + st, err := c.waitForStopped(context.Background(), stoppedPID, runFile, dir, time.Second) + if err != nil { + t.Fatal(err) + } + if st.State != "stopped" { + t.Fatalf("state = %q, want stopped", st.State) + } + info, err := runfile.Read(runFile) + if err != nil { + t.Fatal(err) + } + if info != nil { + t.Fatalf("own run-file should have been removed, got %#v", info) + } +} diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index fd7dcc7ca2..556fe5f095 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -27,7 +27,7 @@ func Run() error { return err } - log := NewLogger() + log := newLogger() // Fail fast if a live daemon already owns the handshake file. A run-file // left by a crashed predecessor (dead PID) is treated as stale and @@ -119,8 +119,8 @@ func Run() error { return runErr } -// NewLogger returns the daemon's slog logger. It writes to stderr so supervisors +// newLogger returns the daemon's slog logger. It writes to stderr so supervisors // can capture it separately from any structured stdout protocol added later. -func NewLogger() *slog.Logger { +func newLogger() *slog.Logger { return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) } diff --git a/backend/internal/httpd/control_test.go b/backend/internal/httpd/control_test.go new file mode 100644 index 0000000000..3e8456f8fc --- /dev/null +++ b/backend/internal/httpd/control_test.go @@ -0,0 +1,52 @@ +package httpd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" +) + +// TestShutdownGuard verifies that POST /shutdown only fires for a trusted local +// caller: a loopback Host with no Origin header. A cross-site Origin or a +// non-loopback (DNS-rebinding) Host must be rejected without triggering the +// shutdown side effect. +func TestShutdownGuard(t *testing.T) { + cases := []struct { + name string + host string + origin string + wantStatus int + wantFired bool + }{ + {name: "loopback no origin", host: "127.0.0.1:3001", wantStatus: http.StatusAccepted, wantFired: true}, + {name: "localhost no origin", host: "localhost:3001", wantStatus: http.StatusAccepted, wantFired: true}, + {name: "cross-site origin", host: "127.0.0.1:3001", origin: "https://evil.example", wantStatus: http.StatusForbidden, wantFired: false}, + {name: "rebinding host", host: "evil.example", wantStatus: http.StatusForbidden, wantFired: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fired := false + r := NewRouterWithControl(config.Config{}, discardLogger(), nil, APIDeps{}, ControlDeps{ + RequestShutdown: func() { fired = true }, + }) + + req := httptest.NewRequest(http.MethodPost, "http://"+tc.host+"/shutdown", nil) + req.Host = tc.host + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus) + } + if fired != tc.wantFired { + t.Fatalf("shutdown fired = %v, want %v", fired, tc.wantFired) + } + }) + } +} diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index d406b029c3..5d132eb488 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -6,6 +6,7 @@ package httpd import ( "log/slog" + "net" "net/http" "os" @@ -76,11 +77,22 @@ func mountHealth(r chi.Router) { r.Get("/readyz", handleReadyz) } +// mountControl registers the loopback daemon-control endpoints. /shutdown is +// unauthenticated and state-changing, so it is gated by localControlRequest to +// keep a browser the user happens to have open (CSRF / DNS-rebinding) or a +// remote client from being able to kill the daemon. func mountControl(r chi.Router, deps ControlDeps) { if deps.RequestShutdown == nil { return } - r.Post("/shutdown", func(w http.ResponseWriter, _ *http.Request) { + r.Post("/shutdown", func(w http.ResponseWriter, req *http.Request) { + if !localControlRequest(req) { + writeJSON(w, http.StatusForbidden, map[string]any{ + "status": "forbidden", + "service": daemonmeta.ServiceName, + }) + return + } writeJSON(w, http.StatusAccepted, map[string]any{ "status": "shutting_down", "service": daemonmeta.ServiceName, @@ -90,6 +102,29 @@ func mountControl(r chi.Router, deps ControlDeps) { }) } +// localControlRequest reports whether a control request is a trusted local +// caller. The Go CLI client addresses the daemon by its loopback host and +// never sets an Origin header; a cross-site browser fetch always carries an +// Origin, and a DNS-rebinding attempt resolves a non-loopback Host. Rejecting +// either closes the CSRF/rebinding vector while leaving the CLI unaffected. +func localControlRequest(r *http.Request) bool { + if r.Header.Get("Origin") != "" { + return false + } + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + switch host { + case "127.0.0.1", "::1", "localhost": + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} + // handleHealthz is the liveness probe: it answers 200 as long as the process is // up and serving. It does no dependency checks by design. func handleHealthz(w http.ResponseWriter, _ *http.Request) { diff --git a/docs/cli/README.md b/docs/cli/README.md index dd83c306ee..d78539a035 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -14,10 +14,13 @@ What works now: - `ao start` starts the daemon in the background and waits for `/readyz`. - `ao status` and `ao status --json` report stopped, stale, unhealthy, not-ready, or ready daemon state. -- `ao stop` gracefully stops the daemon using the PID in `running.json`. +- `ao stop` gracefully stops the daemon via the loopback `POST /shutdown` + endpoint, only after verifying the daemon's identity from `running.json`. - `ao daemon` is the hidden internal daemon entrypoint used by `ao start`. -- `ao doctor` checks config, data dir, SQLite migrations, daemon state, and - local tool availability for `git`, `tmux`, and `zellij`. +- `ao doctor` (and `ao doctor --json`) checks config, data dir, the database + file's presence, daemon state, and local tool availability for `git`, `tmux`, + and `zellij`. It never opens or migrates the store — the daemon is the sole + writer/migrator, so doctor only reports whether the database exists yet. - `ao completion` generates shell completions for `bash`, `zsh`, `fish`, and `powershell`. - `ao version` and `ao --version` print build metadata. @@ -52,8 +55,9 @@ What is intentionally not implemented yet: Next steps: -1. Add `/api/v1/projects` on the daemon over a small project service. -2. Implement `ao project list/add/show/remove`. +1. Wire the existing project manager/controller shell into the daemon with a + durable SQLite-backed project store. +2. Implement `ao project list/add/show/remove` against `/api/v1/projects`. 3. Wire production Session Manager dependencies: project-backed repo resolver, tmux/zellij runtime registry, first agent adapter, and AgentMessenger. 4. Add `/api/v1/sessions`, then implement `ao spawn`, `ao session ...`, and @@ -281,8 +285,8 @@ Acceptance criteria for the foundation: ## Implementation Readiness This section records what the CLI can connect to in the current codebase and -what still needs to be built. Inventory date: 2026-05-31 on `main` at -`0672dbb`. +what still needs to be built. Inventory date: 2026-05-31 after merging +`origin/main` at `438b830`. ### Implemented Foundation @@ -298,8 +302,9 @@ Implemented commands: supports `--json` and `--timeout`. - `ao status` reports stopped/stale/unhealthy/not-ready/ready states and supports `--json`. -- `ao doctor` checks config, data dir, SQLite open/migrations, daemon state, and - local tool availability for `git`, `tmux`, and `zellij`. +- `ao doctor` checks config, data dir, database-file presence, daemon state, and + local tool availability for `git`, `tmux`, and `zellij`; supports `--json`. It + does not open or migrate the store (the daemon owns that). - `ao completion` generates `bash`, `zsh`, `fish`, and `powershell` completions. - `ao version` prints build metadata. @@ -331,7 +336,7 @@ they are wired into the daemon and exposed through HTTP. | Area | Existing code | Missing before CLI can use it | |---|---|---| -| Project persistence | `sqlite.Store` has `UpsertProject`, `GetProject`, `ListProjects`, and `ArchiveProject`. | Project domain/service layer, project ID/path/origin validation, and `/api/v1/projects` routes. | +| Project API pieces | `internal/project` has manager/controller DTOs, `/api/v1/projects` routes exist, and `sqlite.Store` has project CRUD. | Durable project-store adapter/wiring in the daemon and CLI commands. The daemon currently constructs the router with nil API deps, so project routes are not product-usable from `ao` yet. | | Session Manager | `backend/internal/session.Manager` implements `Spawn`, `Kill`, `Restore`, `List`, `Get`, `Send`, and `Cleanup`. | Production daemon wiring with real runtime, agent, workspace, messenger, and HTTP routes. | | Runtime adapters | tmux and zellij adapters implement `ports.Runtime` and also have attach/send/output helpers. | Runtime registry wiring in daemon, attach/send abstractions in ports/API, and selection config. | | Workspace adapter | git worktree adapter implements create/destroy/restore/list with safety checks. | Repo resolver backed by registered projects and daemon wiring into Session Manager. | @@ -345,12 +350,10 @@ These are the main gaps before the full initial command set is real. | Gap | Blocks | |---|---| -| Cobra dependency and CLI packages. | All CLI commands. | -| Daemon extraction from `backend/main.go` into `internal/daemon`. | `ao daemon`, `ao start`, tests around daemon startup. | -| CLI process runner and PID signal helpers. | `ao start`, `ao stop`. | -| Loopback HTTP client package with run-file discovery. | `ao status`, later all daemon-backed commands. | +| Product API client package with run-file discovery. | `project`, `spawn`, `session`, `send`, `events list`, richer `status`. | | Shutdown mechanism choice: PID signal now, optional `POST /api/v1/daemon/shutdown` later. | `ao stop` polish and cross-platform behavior. | -| HTTP API route surface under `/api/v1`. | `project`, `spawn`, `session`, `send`, `events list`, richer `status`. | +| Session/send API route surface under `/api/v1`. | `spawn`, `session`, `send`, richer `status`. | +| Project API daemon wiring. | `ao project list/add/show/remove`. | | SSE route for live CDC events plus durable catch-up reads. | `ao events tail`, frontend live updates. | | Agent adapters for supported harnesses (`codex`, `claude-code`, etc.). | `ao spawn`, `ao session restore`. | | AgentMessenger implementation over tmux/zellij. | `ao send`, LCM auto-nudge reactions. | @@ -368,10 +371,10 @@ These are the main gaps before the full initial command set is real. | `ao start` | Implemented | Config, run-file stale check, HTTP readiness probes. | Later: package-manager/service integration if needed. | | `ao stop` | Implemented | Run-file discovery gives PID/port; server exits cleanly on SIGINT/SIGTERM. | Optional later shutdown HTTP route. | | `ao status` | Partially implemented | Run-file, process liveness via PID, `/healthz`, `/readyz`. | Rich project/session summary waits for `/api/v1/projects` and `/api/v1/sessions`. | -| `ao doctor` | Partially implemented | Config resolution, run-file, storage open, runtime binary checks. | Deeper adapter preflights need daemon wiring/config. | +| `ao doctor` | Partially implemented | Config resolution, run-file, database-file presence (no open/migrate), runtime binary checks. | Deeper adapter preflights need daemon wiring/config and should be queried from the daemon, not run in-process. | | `ao completion` | Implemented | Cobra generators. | None for foundation. | | `ao version` | Implemented | Build metadata can be injected with `-ldflags`. | Release tooling needs to set metadata. | -| `ao project list/add/show/remove` | Not yet | SQLite project CRUD exists. | Project service and HTTP routes. CLI must not write SQLite directly. | +| `ao project list/add/show/remove` | Not yet | Project manager/controller route shell and SQLite project CRUD exist. | Durable project-store adapter, daemon API wiring, and CLI HTTP client. CLI must not write SQLite directly. | | `ao spawn` | Not yet | Session Manager exists; runtime/workspace/tracker pieces partly exist. | Agent adapters, registry/config wiring, project lookup, tracker hydration, HTTP route. | | `ao session list/show` | Not yet | Store and Session Manager read model exist. | HTTP routes and response DTOs. | | `ao session attach` | Not yet | tmux/zellij have attach command helpers. | Runtime attach port/API and terminal-launch policy. | @@ -383,8 +386,8 @@ These are the main gaps before the full initial command set is real. 1. Build CLI foundation around the daemon only: `daemon`, `start`, `stop`, `status`, `doctor`, `completion`, `version`. -2. Add `/api/v1/projects` over a small project service, then implement - `project list/add/show/remove`. +2. Wire the existing project manager/controller shell into the daemon with a + durable SQLite-backed store, then implement `project list/add/show/remove`. 3. Wire production Session Manager dependencies: project-backed repo resolver, tmux/zellij runtime registry, first agent adapter, and AgentMessenger. 4. Add `/api/v1/sessions` and implement `spawn`, `session list/show/kill/restore`, From 3680ac5474196faf080d8c20f3421e2968c3462f Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 1 Jun 2026 02:33:56 +0530 Subject: [PATCH 082/250] test(cli): add end-to-end smoke test + Docker/CI harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fresh-machine, install→use→verify E2E test for the `ao` CLI and wires it into CI. The suite drives the real binary (start/status/doctor/stop + the daemon-control HTTP surface) against fully isolated state — its own temp run-file, data dir, and an auto-picked free loopback port — so it never collides with a developer's real AO install or daemon. - test/cli/smoke.sh: 40 assertions covering install resolution, version/help (daemon hidden), doctor text+json (and that it does NOT migrate SQLite), status stopped/stale/ready, start fresh+idempotent, daemon-created store, /healthz identity, the /shutdown CSRF + DNS-rebinding guard (403 + survives), graceful/stale/idempotent stop, run-file ownership cleanup, exit codes (2 usage / 1 runtime), and completion for all four shells. It deliberately ignores an inherited AO_PORT and self-allocates a free port for isolation. - test/cli/Dockerfile: models installing ao on a fresh machine — builds the binary, drops it on PATH in a clean Debian image with only runtime deps (git/tmux/curl), runs the suite as a non-root user. - test/cli/run-local.sh: build-from-source + native run convenience wrapper. - .github/workflows/cli-e2e.yml: two tiers — `native` runs the suite on a ubuntu+macos runner matrix (the real VMs, to cover the unix setsid detach and macOS os.UserConfigDir paths a Linux container can't), and `container` runs the fresh-machine Docker image with --init (real PID-1 reaper so the stale-daemon assertion is reliable). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/cli-e2e.yml | 55 +++++++ test/cli/Dockerfile | 46 ++++++ test/cli/README.md | 73 +++++++++ test/cli/run-local.sh | 24 +++ test/cli/smoke.sh | 299 ++++++++++++++++++++++++++++++++++ 5 files changed, 497 insertions(+) create mode 100644 .github/workflows/cli-e2e.yml create mode 100644 test/cli/Dockerfile create mode 100644 test/cli/README.md create mode 100755 test/cli/run-local.sh create mode 100755 test/cli/smoke.sh diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml new file mode 100644 index 0000000000..e9daf56c11 --- /dev/null +++ b/.github/workflows/cli-e2e.yml @@ -0,0 +1,55 @@ +name: CLI E2E + +on: + push: + branches: [main] + pull_request: + paths: + - "backend/**" + - "test/cli/**" + - ".github/workflows/cli-e2e.yml" + +permissions: + contents: read + +jobs: + # Primary tier: run the REAL `ao` binary on GitHub's native VM runners. These + # runners are the "VMs" — the only place that exercises the OS-specific code + # paths (unix Setsid process-group detach + macOS os.UserConfigDir resolution). + # Bash is available on both ubuntu and macos runners, so the one smoke.sh runs + # unchanged. State is isolated per run (own temp dir + a free loopback port). + native: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache: false + + - name: Build ao + run: cd backend && CGO_ENABLED=0 go build -trimpath -o "$RUNNER_TEMP/ao" ./cmd/ao + + - name: CLI smoke test + run: AO_BIN="$RUNNER_TEMP/ao" bash test/cli/smoke.sh + + # Secondary hardening tier: model "install ao on a fresh machine" in a clean, + # locked-down Linux container with no access to a developer's real state. + # --init gives the container a real PID-1 reaper (tini) so the stale-daemon + # assertion is reliable — without it, an orphaned daemon can linger as a + # zombie and skew the check. + container: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build smoke image (fresh-machine install) + run: docker build -f test/cli/Dockerfile -t ao-cli-smoke . + + - name: Run CLI smoke test in container + run: docker run --rm --init ao-cli-smoke diff --git a/test/cli/Dockerfile b/test/cli/Dockerfile new file mode 100644 index 0000000000..dfeb4c46f1 --- /dev/null +++ b/test/cli/Dockerfile @@ -0,0 +1,46 @@ +# End-to-end CLI smoke test, modelling "install ao on a fresh machine, then use it". +# +# Build context is the REPO ROOT: +# docker build -f test/cli/Dockerfile -t ao-cli-smoke . +# docker run --rm --init ao-cli-smoke +# +# --init gives the container a real PID-1 reaper (tini) so a stopped daemon is +# reaped promptly; the test is written to not depend on it, but it keeps process +# accounting clean. + +# ---- stage 1: build the binary (the "release" a user would download) ---- +FROM golang:1.25-bookworm AS build +WORKDIR /src + +# Cache modules first. +COPY backend/go.mod backend/go.sum ./backend/ +RUN cd backend && go mod download + +COPY backend ./backend +# Pure-Go SQLite (modernc) builds fine with CGO disabled -> a static binary. +RUN cd backend && CGO_ENABLED=0 go build -trimpath -o /out/ao ./cmd/ao + +# ---- stage 2: a clean machine with NO Go toolchain, just like an end user ---- +FROM debian:bookworm-slim AS run + +# Runtime deps a fresh user would need: git is required by `ao doctor`; tmux is +# the optional runtime it probes for; curl drives the HTTP-level guard checks; +# ca-certificates for good measure. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git tmux curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# "Install" the CLI the way a user would: drop the binary on PATH. +COPY --from=build /out/ao /usr/local/bin/ao +COPY test/cli/smoke.sh /usr/local/bin/ao-smoke.sh +RUN chmod +x /usr/local/bin/ao /usr/local/bin/ao-smoke.sh + +# Run as an unprivileged user with a real HOME, like a normal install. +RUN useradd --create-home --shell /bin/bash ao +USER ao +WORKDIR /home/ao + +# Sanity: prove the install resolved before the suite runs. +RUN ao version + +ENTRYPOINT ["/usr/local/bin/ao-smoke.sh"] diff --git a/test/cli/README.md b/test/cli/README.md new file mode 100644 index 0000000000..51ce67fe49 --- /dev/null +++ b/test/cli/README.md @@ -0,0 +1,73 @@ +# `ao` CLI end-to-end tests + +These tests install and drive the **real `ao` binary** the way a user would — +`start` → `status` → `doctor` → `stop`, plus the daemon-control HTTP surface — +and assert the whole thing works end to end. They run against **isolated, +throwaway state** (their own temp run-file + data dir + a free loopback port), +so they never touch a developer's real AO installation. + +## Files + +| File | Purpose | +|------|---------| +| `smoke.sh` | The suite. Host-agnostic bash; drives the binary at `$AO_BIN` (default `ao` on PATH) and prints a PASS/FAIL line per assertion. | +| `Dockerfile` | Models **installing `ao` on a fresh machine**: builds the binary, drops it on `PATH` in a clean Debian image with only runtime deps (`git`, `tmux`, `curl`), then runs `smoke.sh` as a non-root user. | +| `run-local.sh` | Convenience wrapper: build from source and run `smoke.sh` natively against a temp binary. | + +## Run it + +**Native (fastest, uses your toolchain):** +```bash +test/cli/run-local.sh +# or, against a binary you already built: +AO_BIN=/path/to/ao test/cli/smoke.sh +``` + +**Fresh-machine install, in a clean container:** +```bash +docker build -f test/cli/Dockerfile -t ao-cli-smoke . +docker run --rm --init ao-cli-smoke +``` +> `--init` is important: it gives the container a real PID-1 reaper so the +> "stale daemon" assertion is reliable. Without it an orphaned daemon can linger +> as a zombie and skew the check. + +## What it covers + +Install resolves on PATH · `version`/`--version` · `--help` (and hides the +internal `daemon` command) · `doctor` text + `--json` (and that it **does not** +open/migrate SQLite) · `status` stopped/stale/ready · `start` (fresh + +idempotent) · daemon-created store · `/healthz` identity · the `/shutdown` +CSRF/DNS-rebinding guard (403 + daemon survives) · `stop` (graceful + stale + +idempotent) · run-file cleanup/ownership · exit codes (`2` usage, `1` runtime) · +completion for all four shells. + +## Testing strategy — why it's shaped this way + +We deliberately don't make Docker the *only* tier. A daemon that detaches with +`setsid` and outlives the launching process is exactly the workload that +container PID-1 semantics mishandle, and the OS-specific bits (`setsid` vs +Windows `CREATE_NEW_PROCESS_GROUP`, and `os.UserConfigDir()` resolving to +`~/Library/Application Support` on macOS, `%AppData%` on Windows, `~/.config` +on Linux) can't be observed from a Linux container at all. + +So CI (`.github/workflows/cli-e2e.yml`) runs two tiers: + +1. **`native`** — the primary signal. Builds and runs the real binary on a + GitHub matrix of `ubuntu-latest` + `macos-latest` (those runners *are* the + VMs), covering the unix detach path and macOS config-dir resolution. +2. **`container`** — a hardening tier. The `Dockerfile` proves a clean-machine + install works and that the CLI has no hidden dependence on developer state, + run with `--init`. + +### Extending + +- Add an assertion: drop a `step`/`assert_*` pair into the relevant section of + `smoke.sh`. The helpers (`assert_eq`, `assert_contains`, `assert_not_contains`, + `run_rc`) keep cases one-liners. +- Cover Windows: add a `windows-latest` leg to the `native` matrix (Git Bash + ships on the runner) once the suite is confirmed green there, or add Go-based + `os/exec` E2E tests for the Windows process-group path. +- Deeper per-OS path assertions (that state resolves under the OS-native config + dir when `AO_RUN_FILE`/`AO_DATA_DIR` are unset) are best added as Go unit + tests in `internal/config`. diff --git a/test/cli/run-local.sh b/test/cli/run-local.sh new file mode 100755 index 0000000000..4cd786a6e1 --- /dev/null +++ b/test/cli/run-local.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# +# Convenience wrapper: build `ao` from source and run the CLI smoke test against +# it natively, using an isolated temp state dir and a free port. Touches nothing +# in your real AO installation. +# +# test/cli/run-local.sh +# +# To run the same suite the way a brand-new user would install it (clean Linux +# container, binary on PATH), use Docker instead: +# +# docker build -f test/cli/Dockerfile -t ao-cli-smoke . && docker run --rm --init ao-cli-smoke + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +bindir="$(mktemp -d)" +trap 'rm -rf "$bindir"' EXIT + +echo "building ao ..." +( cd "$repo_root/backend" && CGO_ENABLED=0 go build -trimpath -o "$bindir/ao" ./cmd/ao ) + +echo "running smoke test ..." +AO_BIN="$bindir/ao" bash "$repo_root/test/cli/smoke.sh" diff --git a/test/cli/smoke.sh b/test/cli/smoke.sh new file mode 100755 index 0000000000..46bd1a4b58 --- /dev/null +++ b/test/cli/smoke.sh @@ -0,0 +1,299 @@ +#!/usr/bin/env bash +# +# End-to-end smoke test for the `ao` CLI. +# +# It models a *fresh machine*: `ao` is expected to already be installed on PATH +# (the Dockerfile in this directory installs it, simulating a new user), and the +# whole test runs against isolated, throwaway state — its own temp run-file, +# data dir, and a free loopback port — so it never touches a developer's real +# AO installation. +# +# Run locally against a binary you built: +# AO_BIN=/path/to/ao test/cli/smoke.sh +# Or in the container (models install-on-a-fresh-machine): +# docker build -f test/cli/Dockerfile -t ao-cli-smoke . && docker run --rm --init ao-cli-smoke +# +# Exit code: 0 if every assertion passes, 1 otherwise. + +set -uo pipefail + +AO_BIN="${AO_BIN:-ao}" + +# --------------------------------------------------------------------------- +# Tiny assertion framework +# --------------------------------------------------------------------------- +PASS=0 +FAIL=0 +CURRENT="" + +section() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } +step() { CURRENT="$1"; printf ' • %s ... ' "$1"; } + +ok() { PASS=$((PASS + 1)); printf '\033[32mPASS\033[0m\n'; } +bad() { FAIL=$((FAIL + 1)); printf '\033[31mFAIL\033[0m\n %s\n' "$1"; } + +# assert_eq [msg] +assert_eq() { + if [ "$1" = "$2" ]; then ok; else bad "${3:-}: expected [$2], got [$1]"; fi +} +# assert_contains +assert_contains() { + case "$1" in + *"$2"*) ok ;; + *) bad "expected output to contain [$2]; got: $(printf '%s' "$1" | head -c 400)" ;; + esac +} +# assert_not_contains +assert_not_contains() { + case "$1" in + *"$2"*) bad "expected output to NOT contain [$2]" ;; + *) ok ;; + esac +} + +# run_rc -> sets RC and OUT (stdout+stderr combined) +run_rc() { + OUT="$("$@" 2>&1)" + RC=$? +} + +# --------------------------------------------------------------------------- +# Isolated, throwaway environment +# --------------------------------------------------------------------------- +TMP="$(mktemp -d)" +export AO_RUN_FILE="$TMP/running.json" +export AO_DATA_DIR="$TMP/data" + +# Pick a free loopback port (bash /dev/tcp probe; connect-refused == free). +find_free_port() { + local p + for p in $(seq 3071 3170); do + if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then + echo "$p"; return 0 + fi + exec 3>&- 2>/dev/null || true + done + echo 3071 +} +# Always run against an isolated, free port. We deliberately do NOT honour an +# inherited AO_PORT — it might point at a real daemon, which is exactly the +# collision this isolation is meant to prevent. Override only via AO_SMOKE_PORT. +export AO_PORT="${AO_SMOKE_PORT:-$(find_free_port)}" + +cleanup() { + "$AO_BIN" stop >/dev/null 2>&1 || true + rm -rf "$TMP" +} +trap cleanup EXIT + +printf 'ao smoke test\n binary : %s\n port : %s\n state : %s\n' \ + "$(command -v "$AO_BIN" || echo "$AO_BIN")" "$AO_PORT" "$TMP" + +# --------------------------------------------------------------------------- +# 1. Install verification — `ao` is a real, runnable binary on this machine +# --------------------------------------------------------------------------- +section "install" + +step "ao resolves on PATH / at AO_BIN" +if command -v "$AO_BIN" >/dev/null 2>&1; then ok; else bad "ao not found"; fi + +step "ao version prints build metadata" +run_rc "$AO_BIN" version +if [ "$RC" -eq 0 ] && [ -n "$OUT" ]; then ok; else bad "rc=$RC out=$OUT"; fi + +step "ao --version works" +run_rc "$AO_BIN" --version +assert_eq "$RC" "0" "--version rc" + +step "ao --help lists product commands" +run_rc "$AO_BIN" --help +assert_contains "$OUT" "start" +step "ao --help lists status/stop/doctor" +assert_contains "$OUT" "doctor" +step "ao --help hides internal daemon command" +assert_not_contains "$OUT" $'\n daemon' + +# --------------------------------------------------------------------------- +# 2. doctor on a fresh machine (no daemon yet) +# --------------------------------------------------------------------------- +section "doctor (fresh)" + +step "doctor exits 0 when required tools present" +run_rc "$AO_BIN" doctor +assert_eq "$RC" "0" "doctor rc (git/tmux must be installed in the image)" + +step "doctor reports git found" +assert_contains "$OUT" "git" + +step "doctor does NOT migrate the store (sqlite WARN, db absent)" +assert_contains "$OUT" "database not created yet" + +step "doctor data dir was created but ao.db was NOT (CLI is not the store writer)" +if [ ! -f "$AO_DATA_DIR/ao.db" ]; then ok; else bad "ao.db exists — doctor must not create/migrate the DB"; fi + +step "doctor --json is valid JSON with ok=true" +run_rc "$AO_BIN" doctor --json +assert_contains "$OUT" '"ok": true' + +# --------------------------------------------------------------------------- +# 3. status when stopped +# --------------------------------------------------------------------------- +section "status (stopped)" + +step "status --json reports stopped" +run_rc "$AO_BIN" status --json +assert_contains "$OUT" '"state": "stopped"' +step "status exits 0 even when stopped (status never errors)" +assert_eq "$RC" "0" "status exit code" +step "stopped status omits startedAt" +assert_not_contains "$OUT" "startedAt" + +step "stop is idempotent when already stopped" +run_rc "$AO_BIN" stop +if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "stopped"; else bad "stop-when-stopped rc=$RC"; fi + +# --------------------------------------------------------------------------- +# 4. start → ready, and status reflects it +# --------------------------------------------------------------------------- +section "start" + +step "start brings the daemon up and reports ready" +run_rc "$AO_BIN" start +if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "ready"; else bad "start rc=$RC out=$OUT"; fi + +step "status --json reports ready with pid+port" +run_rc "$AO_BIN" status --json +assert_contains "$OUT" '"state": "ready"' +step "status carries the bound port" +assert_contains "$OUT" "\"port\": $AO_PORT" + +step "start is idempotent (second start returns ready, no error)" +run_rc "$AO_BIN" start +if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "ready"; else bad "idempotent start rc=$RC"; fi + +# Capture the live pid for later assertions. +PID="$("$AO_BIN" status --json | sed -n 's/.*"pid": \([0-9]*\).*/\1/p' | head -1)" + +# --------------------------------------------------------------------------- +# 5. doctor while running — now the daemon (not the CLI) has created the store +# --------------------------------------------------------------------------- +section "doctor (running)" + +step "daemon created and migrated the store" +if [ -f "$AO_DATA_DIR/ao.db" ]; then ok; else bad "daemon should have created ao.db"; fi + +step "doctor now reports sqlite present + daemon-migrated" +run_rc "$AO_BIN" doctor +assert_contains "$OUT" "migrations are applied by the daemon" + +# --------------------------------------------------------------------------- +# 6. Health endpoint identity (loopback) +# --------------------------------------------------------------------------- +section "health endpoint" + +if command -v curl >/dev/null 2>&1; then + step "/healthz reports the AO daemon service + pid" + run_rc curl -fsS "http://127.0.0.1:$AO_PORT/healthz" + assert_contains "$OUT" "agent-orchestrator-daemon" + + # ------------------------------------------------------------------------- + # 7. /shutdown CSRF / DNS-rebinding guard (review fix M3) + # ------------------------------------------------------------------------- + section "/shutdown guard" + + step "cross-origin POST /shutdown is rejected (403)" + CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H 'Origin: https://evil.example' "http://127.0.0.1:$AO_PORT/shutdown")" + assert_eq "$CODE" "403" "cross-origin shutdown" + + step "non-loopback Host POST /shutdown is rejected (403)" + CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H 'Host: evil.example' "http://127.0.0.1:$AO_PORT/shutdown")" + assert_eq "$CODE" "403" "rebinding-host shutdown" + + step "daemon survived the rejected shutdown attempts" + run_rc "$AO_BIN" status --json + assert_contains "$OUT" '"state": "ready"' +else + section "/shutdown guard" + step "curl unavailable — skipping HTTP-level guard checks" + printf '\033[33mSKIP\033[0m\n' +fi + +# --------------------------------------------------------------------------- +# 8. stop → stopped, run-file cleaned up +# --------------------------------------------------------------------------- +section "stop" + +step "stop gracefully stops the daemon" +run_rc "$AO_BIN" stop +if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "stopped"; else bad "stop rc=$RC out=$OUT"; fi + +step "run-file removed after stop" +if [ ! -f "$AO_RUN_FILE" ]; then ok; else bad "running.json still present"; fi + +step "status --json reports stopped after stop" +run_rc "$AO_BIN" status --json +assert_contains "$OUT" '"state": "stopped"' + +# --------------------------------------------------------------------------- +# 9. stale run-file (dead PID) — deterministic, no real process needed +# --------------------------------------------------------------------------- +section "stale run-file" + +# PID 2147483647 is never alive; the CLI must classify this as stale, not kill it. +printf '{"pid":2147483647,"port":%s,"startedAt":"2020-01-01T00:00:00Z"}\n' "$AO_PORT" > "$AO_RUN_FILE" + +step "status reports stale for a dead-PID run-file" +run_rc "$AO_BIN" status --json +assert_contains "$OUT" '"state": "stale"' +step "status still exits 0 for a stale daemon (reports, never errors)" +assert_eq "$RC" "0" "stale status exit code" + +step "stop clears a stale run-file and reports stopped" +run_rc "$AO_BIN" stop +assert_contains "$OUT" "stopped" +step "stale run-file removed" +if [ ! -f "$AO_RUN_FILE" ]; then ok; else bad "stale running.json not removed"; fi + +# --------------------------------------------------------------------------- +# 10. exit codes: 2 for usage errors, 1 for runtime errors +# --------------------------------------------------------------------------- +section "exit codes" + +step "unknown flag exits 2 (usage error)" +run_rc "$AO_BIN" status --definitely-not-a-flag +assert_eq "$RC" "2" "bad-flag exit code" + +step "missing required arg exits 2 (completion needs a shell)" +run_rc "$AO_BIN" completion +assert_eq "$RC" "2" "missing-arg exit code" + +step "unsupported shell exits non-zero (runtime error)" +run_rc "$AO_BIN" completion notashell +if [ "$RC" -ne 0 ]; then ok; else bad "expected non-zero for bad shell"; fi + +step "invalid config (AO_PORT out of range) exits 1, not 2" +OUT="$(AO_PORT=99999 "$AO_BIN" status 2>&1)"; RC=$? +assert_eq "$RC" "1" "config-error exit code (runtime, not usage)" + +# --------------------------------------------------------------------------- +# 11. shell completion generators +# --------------------------------------------------------------------------- +section "completion" + +for sh in bash zsh fish powershell; do + step "completion $sh generates a script" + run_rc "$AO_BIN" completion "$sh" + if [ "$RC" -eq 0 ] && [ -n "$OUT" ]; then ok; else bad "completion $sh rc=$RC"; fi +done + +# --------------------------------------------------------------------------- +# Result +# --------------------------------------------------------------------------- +printf '\n\033[1m== result ==\033[0m\n passed: %s\n failed: %s\n' "$PASS" "$FAIL" +if [ "$FAIL" -ne 0 ]; then + printf '\033[31mSMOKE TEST FAILED\033[0m\n' + exit 1 +fi +printf '\033[32mSMOKE TEST PASSED\033[0m\n' From a9e83011f188ccbe106bb56a8c35356832bc792b Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 1 Jun 2026 02:37:51 +0530 Subject: [PATCH 083/250] docs(test): correct --init rationale (suite uses a fabricated dead PID) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-daemon assertion does not depend on container PID-1 reaping — it writes a fabricated dead PID rather than killing a real process. --init is still run so the real daemon spawned by the `start` test (detached via setsid) is reaped after `stop` instead of lingering as a zombie. Reword the README, Dockerfile, and workflow comments to say that accurately. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/cli-e2e.yml | 7 ++++--- test/cli/Dockerfile | 7 ++++--- test/cli/README.md | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index e9daf56c11..35ef26cd4d 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -40,9 +40,10 @@ jobs: # Secondary hardening tier: model "install ao on a fresh machine" in a clean, # locked-down Linux container with no access to a developer's real state. - # --init gives the container a real PID-1 reaper (tini) so the stale-daemon - # assertion is reliable — without it, an orphaned daemon can linger as a - # zombie and skew the check. + # --init gives the container a real PID-1 reaper (tini) so the live daemon the + # `start` test spawns (it detaches via setsid) is reaped after `stop` rather + # than lingering as a zombie. The suite doesn't depend on it (the stale case + # uses a fabricated dead PID), but it keeps process accounting clean. container: runs-on: ubuntu-latest steps: diff --git a/test/cli/Dockerfile b/test/cli/Dockerfile index dfeb4c46f1..ce429a9c6e 100644 --- a/test/cli/Dockerfile +++ b/test/cli/Dockerfile @@ -4,9 +4,10 @@ # docker build -f test/cli/Dockerfile -t ao-cli-smoke . # docker run --rm --init ao-cli-smoke # -# --init gives the container a real PID-1 reaper (tini) so a stopped daemon is -# reaped promptly; the test is written to not depend on it, but it keeps process -# accounting clean. +# Run with --init so the real daemon spawned during the `start` test (it detaches +# via setsid) is reaped promptly after `stop` instead of lingering as a zombie. +# The suite does NOT depend on it — the stale-daemon case uses a fabricated dead +# PID — but --init keeps process accounting clean. # ---- stage 1: build the binary (the "release" a user would download) ---- FROM golang:1.25-bookworm AS build diff --git a/test/cli/README.md b/test/cli/README.md index 51ce67fe49..5eb1ecb5bd 100644 --- a/test/cli/README.md +++ b/test/cli/README.md @@ -28,9 +28,10 @@ AO_BIN=/path/to/ao test/cli/smoke.sh docker build -f test/cli/Dockerfile -t ao-cli-smoke . docker run --rm --init ao-cli-smoke ``` -> `--init` is important: it gives the container a real PID-1 reaper so the -> "stale daemon" assertion is reliable. Without it an orphaned daemon can linger -> as a zombie and skew the check. +> `--init` gives the container a real PID-1 reaper (tini) so the live daemon +> spawned during the `start` test is reaped after `stop` instead of lingering as +> a zombie. The suite itself doesn't depend on it — the stale-daemon case uses a +> fabricated dead PID — but it keeps process accounting clean. ## What it covers From e6661e3f3b57144989da374569700a1e9e8df192 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 1 Jun 2026 02:38:25 +0530 Subject: [PATCH 084/250] test(cli): pin ExitCode mapping (usage=2, runtime=1, nil=0) Closes the one nit from the regression audit: the exit-code wiring was correct and covered end-to-end by the smoke test, but not pinned by a unit test. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/cli/exitcode_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 backend/internal/cli/exitcode_test.go diff --git a/backend/internal/cli/exitcode_test.go b/backend/internal/cli/exitcode_test.go new file mode 100644 index 0000000000..bd8817c645 --- /dev/null +++ b/backend/internal/cli/exitcode_test.go @@ -0,0 +1,27 @@ +package cli + +import ( + "errors" + "fmt" + "testing" +) + +func TestExitCode(t *testing.T) { + cases := []struct { + name string + err error + want int + }{ + {"nil is success", nil, 0}, + {"runtime error is 1", errors.New("boom"), 1}, + {"usage error is 2", usageError{errors.New("bad flag")}, 2}, + {"wrapped usage error is still 2", fmt.Errorf("ctx: %w", usageError{errors.New("x")}), 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ExitCode(tc.err); got != tc.want { + t.Errorf("ExitCode(%v) = %d, want %d", tc.err, got, tc.want) + } + }) + } +} From 4f13dd1b83871ce6c87e37e28405a6684c319291 Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 1 Jun 2026 03:04:54 +0530 Subject: [PATCH 085/250] test(cli): add -v/--verbose mode that prints each command and full output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-local.sh -v (or AO_SMOKE_VERBOSE=1) makes smoke.sh echo every command and its complete output, indented, alongside the PASS/FAIL — for auditing exactly what the suite runs and what the CLI returns. Default output is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/cli/README.md | 5 +++-- test/cli/run-local.sh | 15 +++++++++++++-- test/cli/smoke.sh | 41 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/test/cli/README.md b/test/cli/README.md index 5eb1ecb5bd..231c44e9c2 100644 --- a/test/cli/README.md +++ b/test/cli/README.md @@ -18,9 +18,10 @@ so they never touch a developer's real AO installation. **Native (fastest, uses your toolchain):** ```bash -test/cli/run-local.sh +test/cli/run-local.sh # PASS/FAIL per assertion +test/cli/run-local.sh -v # also print every command and its full output # or, against a binary you already built: -AO_BIN=/path/to/ao test/cli/smoke.sh +AO_BIN=/path/to/ao test/cli/smoke.sh [-v] ``` **Fresh-machine install, in a clean container:** diff --git a/test/cli/run-local.sh b/test/cli/run-local.sh index 4cd786a6e1..f2f560ac04 100755 --- a/test/cli/run-local.sh +++ b/test/cli/run-local.sh @@ -4,7 +4,8 @@ # it natively, using an isolated temp state dir and a free port. Touches nothing # in your real AO installation. # -# test/cli/run-local.sh +# test/cli/run-local.sh # PASS/FAIL per assertion +# test/cli/run-local.sh -v # also print every command and its full output # # To run the same suite the way a brand-new user would install it (clean Linux # container, binary on PATH), use Docker instead: @@ -13,6 +14,16 @@ set -euo pipefail +# Pass through -v/--verbose (anything else is forwarded to smoke.sh too). +smoke_args=() +for arg in "$@"; do + case "$arg" in + -v|--verbose) smoke_args+=("--verbose") ;; + -h|--help) echo "usage: run-local.sh [-v|--verbose]"; exit 0 ;; + *) smoke_args+=("$arg") ;; + esac +done + repo_root="$(cd "$(dirname "$0")/../.." && pwd)" bindir="$(mktemp -d)" trap 'rm -rf "$bindir"' EXIT @@ -21,4 +32,4 @@ echo "building ao ..." ( cd "$repo_root/backend" && CGO_ENABLED=0 go build -trimpath -o "$bindir/ao" ./cmd/ao ) echo "running smoke test ..." -AO_BIN="$bindir/ao" bash "$repo_root/test/cli/smoke.sh" +AO_BIN="$bindir/ao" bash "$repo_root/test/cli/smoke.sh" "${smoke_args[@]+"${smoke_args[@]}"}" diff --git a/test/cli/smoke.sh b/test/cli/smoke.sh index 46bd1a4b58..4682d53b4f 100755 --- a/test/cli/smoke.sh +++ b/test/cli/smoke.sh @@ -10,6 +10,8 @@ # # Run locally against a binary you built: # AO_BIN=/path/to/ao test/cli/smoke.sh +# Verbose — print each command and its full output: +# AO_BIN=/path/to/ao test/cli/smoke.sh -v (or AO_SMOKE_VERBOSE=1) # Or in the container (models install-on-a-fresh-machine): # docker build -f test/cli/Dockerfile -t ao-cli-smoke . && docker run --rm --init ao-cli-smoke # @@ -19,6 +21,16 @@ set -uo pipefail AO_BIN="${AO_BIN:-ao}" +# Verbose mode prints every command and its complete output, not just PASS/FAIL. +VERBOSE="${AO_SMOKE_VERBOSE:-0}" +for arg in "$@"; do + case "$arg" in + -v|--verbose) VERBOSE=1 ;; + -h|--help) echo "usage: [AO_BIN=...] smoke.sh [-v|--verbose]"; exit 0 ;; + *) echo "unknown argument: $arg" >&2; exit 2 ;; + esac +done + # --------------------------------------------------------------------------- # Tiny assertion framework # --------------------------------------------------------------------------- @@ -27,10 +39,29 @@ FAIL=0 CURRENT="" section() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } -step() { CURRENT="$1"; printf ' • %s ... ' "$1"; } -ok() { PASS=$((PASS + 1)); printf '\033[32mPASS\033[0m\n'; } -bad() { FAIL=$((FAIL + 1)); printf '\033[31mFAIL\033[0m\n %s\n' "$1"; } +step() { + CURRENT="$1" + if [ "$VERBOSE" = 1 ]; then printf ' • %s\n' "$1"; else printf ' • %s ... ' "$1"; fi +} + +ok() { + PASS=$((PASS + 1)) + if [ "$VERBOSE" = 1 ]; then printf ' \033[32m→ PASS\033[0m\n'; else printf '\033[32mPASS\033[0m\n'; fi +} +bad() { + FAIL=$((FAIL + 1)) + if [ "$VERBOSE" = 1 ]; then printf ' \033[31m→ FAIL\033[0m %s\n' "$1"; else printf '\033[31mFAIL\033[0m\n %s\n' "$1"; fi +} + +# vdump : in verbose mode, echo the command +# and its complete output, indented. A no-op otherwise. +vdump() { + [ "$VERBOSE" = 1 ] || return 0 + printf ' \033[2m$ %s\033[0m\n' "$1" + if [ -n "$2" ]; then printf '%s\n' "$2" | sed 's/^/ | /'; fi + printf ' \033[2m(exit %s)\033[0m\n' "$3" +} # assert_eq [msg] assert_eq() { @@ -55,6 +86,7 @@ assert_not_contains() { run_rc() { OUT="$("$@" 2>&1)" RC=$? + vdump "$*" "$OUT" "$RC" } # --------------------------------------------------------------------------- @@ -204,11 +236,13 @@ if command -v curl >/dev/null 2>&1; then step "cross-origin POST /shutdown is rejected (403)" CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ -H 'Origin: https://evil.example' "http://127.0.0.1:$AO_PORT/shutdown")" + vdump "curl -X POST -H 'Origin: https://evil.example' http://127.0.0.1:$AO_PORT/shutdown" "HTTP $CODE" "-" assert_eq "$CODE" "403" "cross-origin shutdown" step "non-loopback Host POST /shutdown is rejected (403)" CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ -H 'Host: evil.example' "http://127.0.0.1:$AO_PORT/shutdown")" + vdump "curl -X POST -H 'Host: evil.example' http://127.0.0.1:$AO_PORT/shutdown" "HTTP $CODE" "-" assert_eq "$CODE" "403" "rebinding-host shutdown" step "daemon survived the rejected shutdown attempts" @@ -275,6 +309,7 @@ if [ "$RC" -ne 0 ]; then ok; else bad "expected non-zero for bad shell"; fi step "invalid config (AO_PORT out of range) exits 1, not 2" OUT="$(AO_PORT=99999 "$AO_BIN" status 2>&1)"; RC=$? +vdump "AO_PORT=99999 $AO_BIN status" "$OUT" "$RC" assert_eq "$RC" "1" "config-error exit code (runtime, not usage)" # --------------------------------------------------------------------------- From c0bf99eb2281cf4e3385dde087418df6ac1a4c1c Mon Sep 17 00:00:00 2001 From: itrytoohard Date: Mon, 1 Jun 2026 03:14:08 +0530 Subject: [PATCH 086/250] test(cli): port the E2E suite to cross-platform Go; slim the Docker harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the growing bash smoke test with a Go os/exec suite behind the `e2e` build tag (backend/internal/cli/e2e_test.go). It builds the real binary and drives start/status/doctor/stop + the daemon-control HTTP surface against isolated state (temp dir + OS-assigned free port), and now runs natively on ubuntu + macOS + WINDOWS in CI — finally covering the Windows CREATE_NEW_PROCESS_GROUP detach path and per-OS os.UserConfigDir resolution that a Linux container can't observe. `go test -tags e2e -v` logs every command and its output, replacing the bash -v flag. - backend/internal/cli/e2e_test.go: 8 table-style TestE2E_* cases; strips any inherited AO_* env so a real daemon's AO_PORT can't leak in. - test/cli/install-check.sh: small, linear fresh-install proof the Dockerfile runs (binary on PATH, no toolchain) — kept as the hardening tier. - test/cli/Dockerfile: run install-check.sh instead of the full bash suite. - .github/workflows/cli-e2e.yml: `native` is now a go test matrix over ubuntu+macos+windows; `container` builds the image and runs it with --init. - Removes test/cli/smoke.sh and test/cli/run-local.sh (superseded by `go test`). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/cli-e2e.yml | 37 ++-- backend/internal/cli/e2e_test.go | 346 +++++++++++++++++++++++++++++++ test/cli/Dockerfile | 8 +- test/cli/README.md | 98 ++++----- test/cli/install-check.sh | 36 ++++ test/cli/run-local.sh | 35 ---- test/cli/smoke.sh | 334 ----------------------------- 7 files changed, 448 insertions(+), 446 deletions(-) create mode 100644 backend/internal/cli/e2e_test.go create mode 100755 test/cli/install-check.sh delete mode 100755 test/cli/run-local.sh delete mode 100755 test/cli/smoke.sh diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 35ef26cd4d..3073843283 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -13,17 +13,20 @@ permissions: contents: read jobs: - # Primary tier: run the REAL `ao` binary on GitHub's native VM runners. These - # runners are the "VMs" — the only place that exercises the OS-specific code - # paths (unix Setsid process-group detach + macOS os.UserConfigDir resolution). - # Bash is available on both ubuntu and macos runners, so the one smoke.sh runs - # unchanged. State is isolated per run (own temp dir + a free loopback port). + # Primary tier: the cross-platform Go E2E suite (build tag `e2e`) runs the real + # `ao` binary against isolated state on every OS GitHub hosts. These runners + # are the "VMs" — the only place that exercises the OS-specific process-detach + # paths (unix Setsid vs Windows CREATE_NEW_PROCESS_GROUP) and os.UserConfigDir + # resolution. The suite builds its own binary and self-allocates a free port. native: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: backend steps: - uses: actions/checkout@v4 @@ -32,25 +35,21 @@ jobs: go-version: "1.25" cache: false - - name: Build ao - run: cd backend && CGO_ENABLED=0 go build -trimpath -o "$RUNNER_TEMP/ao" ./cmd/ao + - name: CLI E2E (native) + run: go test -tags e2e -v ./internal/cli/... - - name: CLI smoke test - run: AO_BIN="$RUNNER_TEMP/ao" bash test/cli/smoke.sh - - # Secondary hardening tier: model "install ao on a fresh machine" in a clean, - # locked-down Linux container with no access to a developer's real state. - # --init gives the container a real PID-1 reaper (tini) so the live daemon the - # `start` test spawns (it detaches via setsid) is reaped after `stop` rather - # than lingering as a zombie. The suite doesn't depend on it (the stale case - # uses a fabricated dead PID), but it keeps process accounting clean. + # Secondary hardening tier: prove that a freshly installed binary works on a + # clean machine with no Go toolchain and no developer state. The Dockerfile + # installs `ao` on PATH in a slim image and runs test/cli/install-check.sh. + # --init gives a real PID-1 reaper so the daemon the check starts is reaped + # after `stop` instead of lingering as a zombie. container: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Build smoke image (fresh-machine install) + - name: Build fresh-install image run: docker build -f test/cli/Dockerfile -t ao-cli-smoke . - - name: Run CLI smoke test in container + - name: Fresh-install check (container) run: docker run --rm --init ao-cli-smoke diff --git a/backend/internal/cli/e2e_test.go b/backend/internal/cli/e2e_test.go new file mode 100644 index 0000000000..f89e467116 --- /dev/null +++ b/backend/internal/cli/e2e_test.go @@ -0,0 +1,346 @@ +//go:build e2e + +// Package cli_test holds the end-to-end suite for the `ao` CLI. It builds the +// real binary and drives it (start/status/doctor/stop + the daemon-control HTTP +// surface) against fully isolated state — a per-test temp run-file, data dir, +// and an OS-assigned free loopback port — so it never touches a developer's real +// AO install. Unlike the Linux-only container smoke test, this runs natively on +// every OS in CI (ubuntu/macos/windows), which is the only way to exercise the +// unix setsid vs Windows CREATE_NEW_PROCESS_GROUP detach paths and the per-OS +// os.UserConfigDir resolution. +// +// It is gated behind the `e2e` build tag so it never runs in the normal +// `go test ./...` lane (it spawns processes and binds ports): +// +// go test -tags e2e ./internal/cli/... # run it +// go test -tags e2e -v -run TestE2E ./internal/cli/... # verbose, see every command +package cli_test + +import ( + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// aoBin is the path to the binary built once for the whole suite. +var aoBin string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "ao-e2e-bin") + if err != nil { + fmt.Fprintln(os.Stderr, "e2e: mktemp:", err) + os.Exit(1) + } + aoBin = filepath.Join(dir, "ao") + if runtime.GOOS == "windows" { + aoBin += ".exe" + } + build := exec.Command("go", "build", "-o", aoBin, "github.com/aoagents/agent-orchestrator/backend/cmd/ao") + build.Stdout, build.Stderr = os.Stderr, os.Stderr + if err := build.Run(); err != nil { + fmt.Fprintln(os.Stderr, "e2e: build ao:", err) + os.Exit(1) + } + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +// env is an isolated CLI environment: its own state files and free port. +type env struct { + runFile string + dataDir string + port int +} + +func newEnv(t *testing.T) env { + t.Helper() + dir := t.TempDir() + return env{ + runFile: filepath.Join(dir, "running.json"), + dataDir: filepath.Join(dir, "data"), + port: freePort(t), + } +} + +// environ builds the child env: the ambient environment with every inherited +// AO_* var stripped (so a real daemon's AO_PORT can't leak in) plus our isolated +// settings. portOverride, when non-empty, replaces the numeric AO_PORT — used to +// inject an invalid value. +func (e env) environ(portOverride string) []string { + out := make([]string, 0, len(os.Environ())+3) + for _, kv := range os.Environ() { + if strings.HasPrefix(kv, "AO_") { + continue + } + out = append(out, kv) + } + port := fmt.Sprintf("%d", e.port) + if portOverride != "" { + port = portOverride + } + return append(out, "AO_RUN_FILE="+e.runFile, "AO_DATA_DIR="+e.dataDir, "AO_PORT="+port) +} + +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("alloc free port: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +// run executes `ao args...` in env e and returns combined output + exit code. +func (e env) run(t *testing.T, args ...string) (string, int) { + t.Helper() + return e.runEnv(t, e.environ(""), args...) +} + +func (e env) runEnv(t *testing.T, environ []string, args ...string) (string, int) { + t.Helper() + cmd := exec.Command(aoBin, args...) + cmd.Env = environ + b, err := cmd.CombinedOutput() + out := string(b) + code := 0 + if err != nil { + var ee *exec.ExitError + if asExit(err, &ee) { + code = ee.ExitCode() + } else { + t.Fatalf("run %v: %v\n%s", args, err, out) + } + } + t.Logf("$ ao %s\n%s(exit %d)", strings.Join(args, " "), out, code) + return out, code +} + +func asExit(err error, target **exec.ExitError) bool { + if ee, ok := err.(*exec.ExitError); ok { + *target = ee + return true + } + return false +} + +// startDaemon brings the daemon up and registers a stop on cleanup. +func (e env) startDaemon(t *testing.T) { + t.Helper() + out, code := e.run(t, "start") + if code != 0 { + t.Fatalf("start failed (exit %d): %s", code, out) + } + t.Cleanup(func() { e.run(t, "stop") }) +} + +func mustContain(t *testing.T, out, want string) { + t.Helper() + if !strings.Contains(out, want) { + t.Fatalf("expected output to contain %q; got:\n%s", want, out) + } +} + +func mustNotContain(t *testing.T, out, notWant string) { + t.Helper() + if strings.Contains(out, notWant) { + t.Fatalf("expected output NOT to contain %q; got:\n%s", notWant, out) + } +} + +// --------------------------------------------------------------------------- + +func TestE2E_VersionAndHelp(t *testing.T) { + e := newEnv(t) + + if out, code := e.run(t, "version"); code != 0 || strings.TrimSpace(out) == "" { + t.Fatalf("version: exit %d, out %q", code, out) + } + if _, code := e.run(t, "--version"); code != 0 { + t.Fatalf("--version exit %d", code) + } + + out, code := e.run(t, "--help") + if code != 0 { + t.Fatalf("--help exit %d", code) + } + for _, want := range []string{"start", "stop", "status", "doctor", "completion", "version"} { + mustContain(t, out, want) + } + // the internal daemon command is hidden from help (rendered as "\n daemon") + mustNotContain(t, out, "\n daemon") +} + +func TestE2E_DoctorDoesNotTouchTheStore(t *testing.T) { + e := newEnv(t) + + out, code := e.run(t, "doctor") + if code != 0 { + t.Fatalf("doctor (fresh) exit %d: %s", code, out) + } + mustContain(t, out, "git") + mustContain(t, out, "database not created yet") // sqlite WARN, never migrated + + // doctor must NOT create/migrate the DB — the daemon is the sole writer. + if _, err := os.Stat(filepath.Join(e.dataDir, "ao.db")); err == nil { + t.Fatal("doctor created ao.db; the CLI must not open/migrate the store") + } + + if out, code := e.run(t, "doctor", "--json"); code != 0 || !strings.Contains(out, `"ok": true`) { + t.Fatalf("doctor --json: exit %d, out %s", code, out) + } +} + +func TestE2E_StatusStopped(t *testing.T) { + e := newEnv(t) + out, code := e.run(t, "status", "--json") + if code != 0 { // status always exits 0 + t.Fatalf("status exit %d", code) + } + mustContain(t, out, `"state": "stopped"`) + mustNotContain(t, out, "startedAt") + + if out, code := e.run(t, "stop"); code != 0 || !strings.Contains(out, "stopped") { + t.Fatalf("stop-when-stopped: exit %d, out %s", code, out) // idempotent + } +} + +func TestE2E_Lifecycle(t *testing.T) { + e := newEnv(t) + e.startDaemon(t) + + out, _ := e.run(t, "status", "--json") + mustContain(t, out, `"state": "ready"`) + mustContain(t, out, fmt.Sprintf(`"port": %d`, e.port)) + + // idempotent + if out, code := e.run(t, "start"); code != 0 || !strings.Contains(out, "ready") { + t.Fatalf("idempotent start: exit %d, out %s", code, out) + } + + // now the daemon (not the CLI) has created + migrated the store + if _, err := os.Stat(filepath.Join(e.dataDir, "ao.db")); err != nil { + t.Fatalf("daemon should have created ao.db: %v", err) + } + out, _ = e.run(t, "doctor") + mustContain(t, out, "migrations are applied by the daemon") + + // /healthz identity + body := httpGet(t, e.port, "/healthz") + mustContain(t, body, "agent-orchestrator-daemon") + + if out, code := e.run(t, "stop"); code != 0 || !strings.Contains(out, "stopped") { + t.Fatalf("stop: exit %d, out %s", code, out) + } + if _, err := os.Stat(e.runFile); !os.IsNotExist(err) { + t.Fatal("run-file should be removed after stop") + } +} + +func TestE2E_ShutdownGuard(t *testing.T) { + e := newEnv(t) + e.startDaemon(t) + + // A cross-site Origin header must be rejected without stopping the daemon. + if code := postShutdown(t, e.port, func(r *http.Request) { r.Header.Set("Origin", "https://evil.example") }); code != http.StatusForbidden { + t.Fatalf("cross-origin /shutdown = %d, want 403", code) + } + // A non-loopback Host (DNS-rebinding) must be rejected too. + if code := postShutdown(t, e.port, func(r *http.Request) { r.Host = "evil.example" }); code != http.StatusForbidden { + t.Fatalf("rebinding-host /shutdown = %d, want 403", code) + } + // The daemon survived both. + out, _ := e.run(t, "status", "--json") + mustContain(t, out, `"state": "ready"`) +} + +func TestE2E_StaleRunFile(t *testing.T) { + e := newEnv(t) + // PID 2147483647 is never alive -> the CLI must classify this as stale. + content := fmt.Sprintf(`{"pid":2147483647,"port":%d,"startedAt":"2020-01-01T00:00:00Z"}`, e.port) + if err := os.MkdirAll(filepath.Dir(e.runFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(e.runFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + out, _ := e.run(t, "status", "--json") + mustContain(t, out, `"state": "stale"`) + + if out, code := e.run(t, "stop"); code != 0 || !strings.Contains(out, "stopped") { + t.Fatalf("stop stale: exit %d, out %s", code, out) + } + if _, err := os.Stat(e.runFile); !os.IsNotExist(err) { + t.Fatal("stale run-file should be removed") + } +} + +func TestE2E_ExitCodes(t *testing.T) { + e := newEnv(t) + + if _, code := e.run(t, "status", "--definitely-not-a-flag"); code != 2 { + t.Fatalf("bad flag exit %d, want 2", code) + } + if _, code := e.run(t, "completion"); code != 2 { // missing required arg + t.Fatalf("missing-arg exit %d, want 2", code) + } + if _, code := e.run(t, "completion", "notashell"); code == 0 { // runtime error + t.Fatal("unsupported shell should be non-zero") + } + // invalid config is a runtime error (1), not a usage error (2). + if _, code := e.runEnv(t, e.environ("notaport"), "status"); code != 1 { + t.Fatalf("invalid AO_PORT exit %d, want 1", code) + } +} + +func TestE2E_Completion(t *testing.T) { + e := newEnv(t) + for _, sh := range []string{"bash", "zsh", "fish", "powershell"} { + out, code := e.run(t, "completion", sh) + if code != 0 || strings.TrimSpace(out) == "" { + t.Fatalf("completion %s: exit %d, empty=%v", sh, code, strings.TrimSpace(out) == "") + } + } +} + +// --------------------------------------------------------------------------- +// HTTP helpers (loopback) + +func httpClient() *http.Client { return &http.Client{Timeout: 3 * time.Second} } + +func httpGet(t *testing.T, port int, path string) string { + t.Helper() + resp, err := httpClient().Get(fmt.Sprintf("http://127.0.0.1:%d%s", port, path)) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + defer resp.Body.Close() + b := make([]byte, 4096) + n, _ := resp.Body.Read(b) + return string(b[:n]) +} + +// postShutdown issues POST /shutdown with mutator applied, returns the status code. +func postShutdown(t *testing.T, port int, mutate func(*http.Request)) int { + t.Helper() + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/shutdown", port), nil) + if err != nil { + t.Fatal(err) + } + mutate(req) + resp, err := httpClient().Do(req) + if err != nil { + t.Fatalf("POST /shutdown: %v", err) + } + defer resp.Body.Close() + return resp.StatusCode +} diff --git a/test/cli/Dockerfile b/test/cli/Dockerfile index ce429a9c6e..fb5d85b2a7 100644 --- a/test/cli/Dockerfile +++ b/test/cli/Dockerfile @@ -33,15 +33,15 @@ RUN apt-get update \ # "Install" the CLI the way a user would: drop the binary on PATH. COPY --from=build /out/ao /usr/local/bin/ao -COPY test/cli/smoke.sh /usr/local/bin/ao-smoke.sh -RUN chmod +x /usr/local/bin/ao /usr/local/bin/ao-smoke.sh +COPY test/cli/install-check.sh /usr/local/bin/ao-install-check.sh +RUN chmod +x /usr/local/bin/ao /usr/local/bin/ao-install-check.sh # Run as an unprivileged user with a real HOME, like a normal install. RUN useradd --create-home --shell /bin/bash ao USER ao WORKDIR /home/ao -# Sanity: prove the install resolved before the suite runs. +# Sanity: prove the install resolved before the check runs. RUN ao version -ENTRYPOINT ["/usr/local/bin/ao-smoke.sh"] +ENTRYPOINT ["/usr/local/bin/ao-install-check.sh"] diff --git a/test/cli/README.md b/test/cli/README.md index 231c44e9c2..14c3a56c46 100644 --- a/test/cli/README.md +++ b/test/cli/README.md @@ -1,75 +1,65 @@ # `ao` CLI end-to-end tests -These tests install and drive the **real `ao` binary** the way a user would — -`start` → `status` → `doctor` → `stop`, plus the daemon-control HTTP surface — -and assert the whole thing works end to end. They run against **isolated, -throwaway state** (their own temp run-file + data dir + a free loopback port), -so they never touch a developer's real AO installation. +These tests drive the **real `ao` binary** the way a user would — `start` → +`status` → `doctor` → `stop`, plus the daemon-control HTTP surface — and assert +the whole thing works. They run against **isolated, throwaway state** (a per-test +temp run-file + data dir + an OS-assigned free loopback port), so they never +touch a developer's real AO installation. -## Files +## Two tiers -| File | Purpose | -|------|---------| -| `smoke.sh` | The suite. Host-agnostic bash; drives the binary at `$AO_BIN` (default `ao` on PATH) and prints a PASS/FAIL line per assertion. | -| `Dockerfile` | Models **installing `ao` on a fresh machine**: builds the binary, drops it on `PATH` in a clean Debian image with only runtime deps (`git`, `tmux`, `curl`), then runs `smoke.sh` as a non-root user. | -| `run-local.sh` | Convenience wrapper: build from source and run `smoke.sh` natively against a temp binary. | +| Tier | What | Where | +|------|------|-------| +| **Comprehensive (primary)** | A cross-platform Go suite that builds `ao` and exercises the full behaviour. Runs natively on **ubuntu + macOS + windows** — the only way to cover the OS-specific process-detach paths (`setsid` vs `CREATE_NEW_PROCESS_GROUP`) and `os.UserConfigDir()` resolution. | `backend/internal/cli/e2e_test.go` (build tag `e2e`) | +| **Fresh-install (hardening)** | Proves a freshly installed binary works on a clean machine with no Go toolchain and no developer state. | `test/cli/Dockerfile` + `test/cli/install-check.sh` | ## Run it -**Native (fastest, uses your toolchain):** +**The Go suite (fastest, cross-platform):** ```bash -test/cli/run-local.sh # PASS/FAIL per assertion -test/cli/run-local.sh -v # also print every command and its full output -# or, against a binary you already built: -AO_BIN=/path/to/ao test/cli/smoke.sh [-v] +cd backend +go test -tags e2e ./internal/cli/... # run it +go test -tags e2e -v -run TestE2E ./internal/cli/... # verbose: prints every command + output ``` +It builds its own `ao` binary; `git` must be on PATH (required by `doctor`). +`-v` logs each `ao` invocation and its full output, which is the audit trail you +get for free from `go test`. **Fresh-machine install, in a clean container:** ```bash docker build -f test/cli/Dockerfile -t ao-cli-smoke . docker run --rm --init ao-cli-smoke ``` -> `--init` gives the container a real PID-1 reaper (tini) so the live daemon -> spawned during the `start` test is reaped after `stop` instead of lingering as -> a zombie. The suite itself doesn't depend on it — the stale-daemon case uses a -> fabricated dead PID — but it keeps process accounting clean. +> `--init` gives the container a real PID-1 reaper (tini) so the daemon the +> check starts is reaped after `stop` instead of lingering as a zombie. -## What it covers +## What the Go suite covers -Install resolves on PATH · `version`/`--version` · `--help` (and hides the -internal `daemon` command) · `doctor` text + `--json` (and that it **does not** -open/migrate SQLite) · `status` stopped/stale/ready · `start` (fresh + -idempotent) · daemon-created store · `/healthz` identity · the `/shutdown` -CSRF/DNS-rebinding guard (403 + daemon survives) · `stop` (graceful + stale + -idempotent) · run-file cleanup/ownership · exit codes (`2` usage, `1` runtime) · -completion for all four shells. +`TestE2E_VersionAndHelp` (version/`--version`/help, daemon hidden) · +`TestE2E_DoctorDoesNotTouchTheStore` (doctor text + `--json`; proves it does +**not** create/migrate `ao.db`) · `TestE2E_StatusStopped` (stopped + idempotent +stop) · `TestE2E_Lifecycle` (start, ready, idempotent, daemon-created store, +`/healthz` identity, stop, run-file cleanup) · `TestE2E_ShutdownGuard` (the +`/shutdown` CSRF + DNS-rebinding 403 guard, daemon survives) · +`TestE2E_StaleRunFile` (dead-PID run-file → stale → cleaned) · `TestE2E_ExitCodes` +(2 usage / 1 runtime / config error) · `TestE2E_Completion` (all four shells). -## Testing strategy — why it's shaped this way +## Why a Go suite (not bash, not Python) -We deliberately don't make Docker the *only* tier. A daemon that detaches with -`setsid` and outlives the launching process is exactly the workload that -container PID-1 semantics mishandle, and the OS-specific bits (`setsid` vs -Windows `CREATE_NEW_PROCESS_GROUP`, and `os.UserConfigDir()` resolving to -`~/Library/Application Support` on macOS, `%AppData%` on Windows, `~/.config` -on Linux) can't be observed from a Linux container at all. +The bash version grew past the point where bash was a good fit, and a Linux +container can't observe the macOS/Windows code paths at all. A Go `os/exec` +suite is the right home: it uses the repo's own toolchain (runs under `go test`), +gives real assertions and structured data, and — critically — runs natively on +the Windows and macOS runners, finally covering the `CREATE_NEW_PROCESS_GROUP` +detach path and per-OS config-dir resolution. The container stays as a thin +"clean install actually works" check. -So CI (`.github/workflows/cli-e2e.yml`) runs two tiers: +## Extending -1. **`native`** — the primary signal. Builds and runs the real binary on a - GitHub matrix of `ubuntu-latest` + `macos-latest` (those runners *are* the - VMs), covering the unix detach path and macOS config-dir resolution. -2. **`container`** — a hardening tier. The `Dockerfile` proves a clean-machine - install works and that the CLI has no hidden dependence on developer state, - run with `--init`. - -### Extending - -- Add an assertion: drop a `step`/`assert_*` pair into the relevant section of - `smoke.sh`. The helpers (`assert_eq`, `assert_contains`, `assert_not_contains`, - `run_rc`) keep cases one-liners. -- Cover Windows: add a `windows-latest` leg to the `native` matrix (Git Bash - ships on the runner) once the suite is confirmed green there, or add Go-based - `os/exec` E2E tests for the Windows process-group path. -- Deeper per-OS path assertions (that state resolves under the OS-native config - dir when `AO_RUN_FILE`/`AO_DATA_DIR` are unset) are best added as Go unit - tests in `internal/config`. +- **Add a case:** a new `TestE2E_*` function (or a `t.Run` subtest) in + `e2e_test.go`. Use `newEnv(t)` for isolated state and the `env.run`/`httpGet`/ + `postShutdown` helpers. +- **Add an OS:** extend the `matrix.os` list in `.github/workflows/cli-e2e.yml`. +- Deeper per-OS path assertions (state resolves under the OS-native config dir + when `AO_RUN_FILE`/`AO_DATA_DIR` are unset) fit best as unit tests in + `internal/config`. diff --git a/test/cli/install-check.sh b/test/cli/install-check.sh new file mode 100755 index 0000000000..f4bcacd47e --- /dev/null +++ b/test/cli/install-check.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# +# Fresh-machine install check. The Dockerfile installs `ao` on PATH in a clean +# image and runs this; it proves a freshly installed binary actually works on a +# machine with no Go toolchain and no developer state. The COMPREHENSIVE, +# cross-platform behavioural suite lives in Go (backend/internal/cli/e2e_test.go, +# `go test -tags e2e`); this stays deliberately small and linear. + +set -euo pipefail + +AO_BIN="${AO_BIN:-ao}" +tmp="$(mktemp -d)" +export AO_RUN_FILE="$tmp/running.json" +export AO_DATA_DIR="$tmp/data" +export AO_PORT="${AO_PORT:-3001}" # the container is isolated; 3001 is free +trap '"$AO_BIN" stop >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT + +fail() { echo "FAIL: $1" >&2; exit 1; } + +echo "ao binary : $(command -v "$AO_BIN")" +"$AO_BIN" version >/dev/null || fail "version" +"$AO_BIN" doctor >/dev/null || fail "doctor" +"$AO_BIN" start >/dev/null || fail "start" + +"$AO_BIN" status --json | grep -q '"state": "ready"' || fail "daemon not ready after start" + +# the /shutdown control endpoint rejects a cross-origin caller (403) and survives +code="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H 'Origin: https://evil.example' "http://127.0.0.1:$AO_PORT/shutdown")" +[ "$code" = "403" ] || fail "cross-origin /shutdown returned $code, want 403" +"$AO_BIN" status --json | grep -q '"state": "ready"' || fail "daemon died after rejected shutdown" + +"$AO_BIN" stop >/dev/null || fail "stop" +"$AO_BIN" status --json | grep -q '"state": "stopped"' || fail "daemon not stopped" + +echo "fresh-install check: OK" diff --git a/test/cli/run-local.sh b/test/cli/run-local.sh deleted file mode 100755 index f2f560ac04..0000000000 --- a/test/cli/run-local.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# -# Convenience wrapper: build `ao` from source and run the CLI smoke test against -# it natively, using an isolated temp state dir and a free port. Touches nothing -# in your real AO installation. -# -# test/cli/run-local.sh # PASS/FAIL per assertion -# test/cli/run-local.sh -v # also print every command and its full output -# -# To run the same suite the way a brand-new user would install it (clean Linux -# container, binary on PATH), use Docker instead: -# -# docker build -f test/cli/Dockerfile -t ao-cli-smoke . && docker run --rm --init ao-cli-smoke - -set -euo pipefail - -# Pass through -v/--verbose (anything else is forwarded to smoke.sh too). -smoke_args=() -for arg in "$@"; do - case "$arg" in - -v|--verbose) smoke_args+=("--verbose") ;; - -h|--help) echo "usage: run-local.sh [-v|--verbose]"; exit 0 ;; - *) smoke_args+=("$arg") ;; - esac -done - -repo_root="$(cd "$(dirname "$0")/../.." && pwd)" -bindir="$(mktemp -d)" -trap 'rm -rf "$bindir"' EXIT - -echo "building ao ..." -( cd "$repo_root/backend" && CGO_ENABLED=0 go build -trimpath -o "$bindir/ao" ./cmd/ao ) - -echo "running smoke test ..." -AO_BIN="$bindir/ao" bash "$repo_root/test/cli/smoke.sh" "${smoke_args[@]+"${smoke_args[@]}"}" diff --git a/test/cli/smoke.sh b/test/cli/smoke.sh deleted file mode 100755 index 4682d53b4f..0000000000 --- a/test/cli/smoke.sh +++ /dev/null @@ -1,334 +0,0 @@ -#!/usr/bin/env bash -# -# End-to-end smoke test for the `ao` CLI. -# -# It models a *fresh machine*: `ao` is expected to already be installed on PATH -# (the Dockerfile in this directory installs it, simulating a new user), and the -# whole test runs against isolated, throwaway state — its own temp run-file, -# data dir, and a free loopback port — so it never touches a developer's real -# AO installation. -# -# Run locally against a binary you built: -# AO_BIN=/path/to/ao test/cli/smoke.sh -# Verbose — print each command and its full output: -# AO_BIN=/path/to/ao test/cli/smoke.sh -v (or AO_SMOKE_VERBOSE=1) -# Or in the container (models install-on-a-fresh-machine): -# docker build -f test/cli/Dockerfile -t ao-cli-smoke . && docker run --rm --init ao-cli-smoke -# -# Exit code: 0 if every assertion passes, 1 otherwise. - -set -uo pipefail - -AO_BIN="${AO_BIN:-ao}" - -# Verbose mode prints every command and its complete output, not just PASS/FAIL. -VERBOSE="${AO_SMOKE_VERBOSE:-0}" -for arg in "$@"; do - case "$arg" in - -v|--verbose) VERBOSE=1 ;; - -h|--help) echo "usage: [AO_BIN=...] smoke.sh [-v|--verbose]"; exit 0 ;; - *) echo "unknown argument: $arg" >&2; exit 2 ;; - esac -done - -# --------------------------------------------------------------------------- -# Tiny assertion framework -# --------------------------------------------------------------------------- -PASS=0 -FAIL=0 -CURRENT="" - -section() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } - -step() { - CURRENT="$1" - if [ "$VERBOSE" = 1 ]; then printf ' • %s\n' "$1"; else printf ' • %s ... ' "$1"; fi -} - -ok() { - PASS=$((PASS + 1)) - if [ "$VERBOSE" = 1 ]; then printf ' \033[32m→ PASS\033[0m\n'; else printf '\033[32mPASS\033[0m\n'; fi -} -bad() { - FAIL=$((FAIL + 1)) - if [ "$VERBOSE" = 1 ]; then printf ' \033[31m→ FAIL\033[0m %s\n' "$1"; else printf '\033[31mFAIL\033[0m\n %s\n' "$1"; fi -} - -# vdump : in verbose mode, echo the command -# and its complete output, indented. A no-op otherwise. -vdump() { - [ "$VERBOSE" = 1 ] || return 0 - printf ' \033[2m$ %s\033[0m\n' "$1" - if [ -n "$2" ]; then printf '%s\n' "$2" | sed 's/^/ | /'; fi - printf ' \033[2m(exit %s)\033[0m\n' "$3" -} - -# assert_eq [msg] -assert_eq() { - if [ "$1" = "$2" ]; then ok; else bad "${3:-}: expected [$2], got [$1]"; fi -} -# assert_contains -assert_contains() { - case "$1" in - *"$2"*) ok ;; - *) bad "expected output to contain [$2]; got: $(printf '%s' "$1" | head -c 400)" ;; - esac -} -# assert_not_contains -assert_not_contains() { - case "$1" in - *"$2"*) bad "expected output to NOT contain [$2]" ;; - *) ok ;; - esac -} - -# run_rc -> sets RC and OUT (stdout+stderr combined) -run_rc() { - OUT="$("$@" 2>&1)" - RC=$? - vdump "$*" "$OUT" "$RC" -} - -# --------------------------------------------------------------------------- -# Isolated, throwaway environment -# --------------------------------------------------------------------------- -TMP="$(mktemp -d)" -export AO_RUN_FILE="$TMP/running.json" -export AO_DATA_DIR="$TMP/data" - -# Pick a free loopback port (bash /dev/tcp probe; connect-refused == free). -find_free_port() { - local p - for p in $(seq 3071 3170); do - if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then - echo "$p"; return 0 - fi - exec 3>&- 2>/dev/null || true - done - echo 3071 -} -# Always run against an isolated, free port. We deliberately do NOT honour an -# inherited AO_PORT — it might point at a real daemon, which is exactly the -# collision this isolation is meant to prevent. Override only via AO_SMOKE_PORT. -export AO_PORT="${AO_SMOKE_PORT:-$(find_free_port)}" - -cleanup() { - "$AO_BIN" stop >/dev/null 2>&1 || true - rm -rf "$TMP" -} -trap cleanup EXIT - -printf 'ao smoke test\n binary : %s\n port : %s\n state : %s\n' \ - "$(command -v "$AO_BIN" || echo "$AO_BIN")" "$AO_PORT" "$TMP" - -# --------------------------------------------------------------------------- -# 1. Install verification — `ao` is a real, runnable binary on this machine -# --------------------------------------------------------------------------- -section "install" - -step "ao resolves on PATH / at AO_BIN" -if command -v "$AO_BIN" >/dev/null 2>&1; then ok; else bad "ao not found"; fi - -step "ao version prints build metadata" -run_rc "$AO_BIN" version -if [ "$RC" -eq 0 ] && [ -n "$OUT" ]; then ok; else bad "rc=$RC out=$OUT"; fi - -step "ao --version works" -run_rc "$AO_BIN" --version -assert_eq "$RC" "0" "--version rc" - -step "ao --help lists product commands" -run_rc "$AO_BIN" --help -assert_contains "$OUT" "start" -step "ao --help lists status/stop/doctor" -assert_contains "$OUT" "doctor" -step "ao --help hides internal daemon command" -assert_not_contains "$OUT" $'\n daemon' - -# --------------------------------------------------------------------------- -# 2. doctor on a fresh machine (no daemon yet) -# --------------------------------------------------------------------------- -section "doctor (fresh)" - -step "doctor exits 0 when required tools present" -run_rc "$AO_BIN" doctor -assert_eq "$RC" "0" "doctor rc (git/tmux must be installed in the image)" - -step "doctor reports git found" -assert_contains "$OUT" "git" - -step "doctor does NOT migrate the store (sqlite WARN, db absent)" -assert_contains "$OUT" "database not created yet" - -step "doctor data dir was created but ao.db was NOT (CLI is not the store writer)" -if [ ! -f "$AO_DATA_DIR/ao.db" ]; then ok; else bad "ao.db exists — doctor must not create/migrate the DB"; fi - -step "doctor --json is valid JSON with ok=true" -run_rc "$AO_BIN" doctor --json -assert_contains "$OUT" '"ok": true' - -# --------------------------------------------------------------------------- -# 3. status when stopped -# --------------------------------------------------------------------------- -section "status (stopped)" - -step "status --json reports stopped" -run_rc "$AO_BIN" status --json -assert_contains "$OUT" '"state": "stopped"' -step "status exits 0 even when stopped (status never errors)" -assert_eq "$RC" "0" "status exit code" -step "stopped status omits startedAt" -assert_not_contains "$OUT" "startedAt" - -step "stop is idempotent when already stopped" -run_rc "$AO_BIN" stop -if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "stopped"; else bad "stop-when-stopped rc=$RC"; fi - -# --------------------------------------------------------------------------- -# 4. start → ready, and status reflects it -# --------------------------------------------------------------------------- -section "start" - -step "start brings the daemon up and reports ready" -run_rc "$AO_BIN" start -if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "ready"; else bad "start rc=$RC out=$OUT"; fi - -step "status --json reports ready with pid+port" -run_rc "$AO_BIN" status --json -assert_contains "$OUT" '"state": "ready"' -step "status carries the bound port" -assert_contains "$OUT" "\"port\": $AO_PORT" - -step "start is idempotent (second start returns ready, no error)" -run_rc "$AO_BIN" start -if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "ready"; else bad "idempotent start rc=$RC"; fi - -# Capture the live pid for later assertions. -PID="$("$AO_BIN" status --json | sed -n 's/.*"pid": \([0-9]*\).*/\1/p' | head -1)" - -# --------------------------------------------------------------------------- -# 5. doctor while running — now the daemon (not the CLI) has created the store -# --------------------------------------------------------------------------- -section "doctor (running)" - -step "daemon created and migrated the store" -if [ -f "$AO_DATA_DIR/ao.db" ]; then ok; else bad "daemon should have created ao.db"; fi - -step "doctor now reports sqlite present + daemon-migrated" -run_rc "$AO_BIN" doctor -assert_contains "$OUT" "migrations are applied by the daemon" - -# --------------------------------------------------------------------------- -# 6. Health endpoint identity (loopback) -# --------------------------------------------------------------------------- -section "health endpoint" - -if command -v curl >/dev/null 2>&1; then - step "/healthz reports the AO daemon service + pid" - run_rc curl -fsS "http://127.0.0.1:$AO_PORT/healthz" - assert_contains "$OUT" "agent-orchestrator-daemon" - - # ------------------------------------------------------------------------- - # 7. /shutdown CSRF / DNS-rebinding guard (review fix M3) - # ------------------------------------------------------------------------- - section "/shutdown guard" - - step "cross-origin POST /shutdown is rejected (403)" - CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ - -H 'Origin: https://evil.example' "http://127.0.0.1:$AO_PORT/shutdown")" - vdump "curl -X POST -H 'Origin: https://evil.example' http://127.0.0.1:$AO_PORT/shutdown" "HTTP $CODE" "-" - assert_eq "$CODE" "403" "cross-origin shutdown" - - step "non-loopback Host POST /shutdown is rejected (403)" - CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ - -H 'Host: evil.example' "http://127.0.0.1:$AO_PORT/shutdown")" - vdump "curl -X POST -H 'Host: evil.example' http://127.0.0.1:$AO_PORT/shutdown" "HTTP $CODE" "-" - assert_eq "$CODE" "403" "rebinding-host shutdown" - - step "daemon survived the rejected shutdown attempts" - run_rc "$AO_BIN" status --json - assert_contains "$OUT" '"state": "ready"' -else - section "/shutdown guard" - step "curl unavailable — skipping HTTP-level guard checks" - printf '\033[33mSKIP\033[0m\n' -fi - -# --------------------------------------------------------------------------- -# 8. stop → stopped, run-file cleaned up -# --------------------------------------------------------------------------- -section "stop" - -step "stop gracefully stops the daemon" -run_rc "$AO_BIN" stop -if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "stopped"; else bad "stop rc=$RC out=$OUT"; fi - -step "run-file removed after stop" -if [ ! -f "$AO_RUN_FILE" ]; then ok; else bad "running.json still present"; fi - -step "status --json reports stopped after stop" -run_rc "$AO_BIN" status --json -assert_contains "$OUT" '"state": "stopped"' - -# --------------------------------------------------------------------------- -# 9. stale run-file (dead PID) — deterministic, no real process needed -# --------------------------------------------------------------------------- -section "stale run-file" - -# PID 2147483647 is never alive; the CLI must classify this as stale, not kill it. -printf '{"pid":2147483647,"port":%s,"startedAt":"2020-01-01T00:00:00Z"}\n' "$AO_PORT" > "$AO_RUN_FILE" - -step "status reports stale for a dead-PID run-file" -run_rc "$AO_BIN" status --json -assert_contains "$OUT" '"state": "stale"' -step "status still exits 0 for a stale daemon (reports, never errors)" -assert_eq "$RC" "0" "stale status exit code" - -step "stop clears a stale run-file and reports stopped" -run_rc "$AO_BIN" stop -assert_contains "$OUT" "stopped" -step "stale run-file removed" -if [ ! -f "$AO_RUN_FILE" ]; then ok; else bad "stale running.json not removed"; fi - -# --------------------------------------------------------------------------- -# 10. exit codes: 2 for usage errors, 1 for runtime errors -# --------------------------------------------------------------------------- -section "exit codes" - -step "unknown flag exits 2 (usage error)" -run_rc "$AO_BIN" status --definitely-not-a-flag -assert_eq "$RC" "2" "bad-flag exit code" - -step "missing required arg exits 2 (completion needs a shell)" -run_rc "$AO_BIN" completion -assert_eq "$RC" "2" "missing-arg exit code" - -step "unsupported shell exits non-zero (runtime error)" -run_rc "$AO_BIN" completion notashell -if [ "$RC" -ne 0 ]; then ok; else bad "expected non-zero for bad shell"; fi - -step "invalid config (AO_PORT out of range) exits 1, not 2" -OUT="$(AO_PORT=99999 "$AO_BIN" status 2>&1)"; RC=$? -vdump "AO_PORT=99999 $AO_BIN status" "$OUT" "$RC" -assert_eq "$RC" "1" "config-error exit code (runtime, not usage)" - -# --------------------------------------------------------------------------- -# 11. shell completion generators -# --------------------------------------------------------------------------- -section "completion" - -for sh in bash zsh fish powershell; do - step "completion $sh generates a script" - run_rc "$AO_BIN" completion "$sh" - if [ "$RC" -eq 0 ] && [ -n "$OUT" ]; then ok; else bad "completion $sh rc=$RC"; fi -done - -# --------------------------------------------------------------------------- -# Result -# --------------------------------------------------------------------------- -printf '\n\033[1m== result ==\033[0m\n passed: %s\n failed: %s\n' "$PASS" "$FAIL" -if [ "$FAIL" -ne 0 ]; then - printf '\033[31mSMOKE TEST FAILED\033[0m\n' - exit 1 -fi -printf '\033[32mSMOKE TEST PASSED\033[0m\n' From 28e1205d2856404baa332e80d9c8fba7886b4a76 Mon Sep 17 00:00:00 2001 From: prateek Date: Mon, 1 Jun 2026 03:08:21 +0530 Subject: [PATCH 087/250] refactor(backend): collapse duplicate PR row types into one domain definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each PR-child table (pr / pr_checks / pr_comment) had three near-identical structs — gen.* (generated), sqlite.*Row, and ports.* — with wiring.Adapter copying field-by-field between them. Collapse to one shared definition per table in domain (PRRow / PRCheckRow / PRComment), used by both the PRWriter port and the sqlite store; gen.* stays sealed inside the storage layer. - *sqlite.Store now satisfies ports.SessionStore + ports.PRWriter directly, so the entire wiring.Adapter package is deleted (lifecycle.New(store, store)). - The bool PR state <-> single state column, int<->int64, and enum-default translation now lives only at the gen<->domain boundary in pr_store.go. - WritePRObservation renamed WritePR to match the port; the integration test and composition root drop their adapter copies. Net -280 lines, behaviour unchanged. go test -race ./... green. Co-Authored-By: Claude Opus 4.8 --- backend/internal/cdc/cdc_test.go | 2 +- backend/internal/daemon/lifecycle_wiring.go | 14 +- backend/internal/daemon/wiring_test.go | 14 +- backend/internal/domain/pr.go | 47 +++ .../integration/lifecycle_sqlite_test.go | 115 +------- backend/internal/lifecycle/manager.go | 6 +- backend/internal/lifecycle/manager_test.go | 18 +- backend/internal/ports/facts.go | 43 +-- backend/internal/ports/outbound.go | 2 +- .../internal/storage/sqlite/pr_cdc_test.go | 24 +- backend/internal/storage/sqlite/pr_facts.go | 10 +- backend/internal/storage/sqlite/pr_store.go | 273 ++++++++---------- backend/internal/storage/sqlite/store_test.go | 24 +- .../internal/storage/sqlite/wiring/adapter.go | 107 ------- 14 files changed, 233 insertions(+), 466 deletions(-) create mode 100644 backend/internal/domain/pr.go delete mode 100644 backend/internal/storage/sqlite/wiring/adapter.go diff --git a/backend/internal/cdc/cdc_test.go b/backend/internal/cdc/cdc_test.go index d72370f4aa..52a0c57400 100644 --- a/backend/internal/cdc/cdc_test.go +++ b/backend/internal/cdc/cdc_test.go @@ -78,7 +78,7 @@ func TestE2E_StoreWriteToBroadcast(t *testing.T) { if err := s.UpdateSession(ctx, r); err != nil { // -> session_updated (seq 2) t.Fatal(err) } - if err := s.UpsertPR(ctx, sqlite.PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: r.UpdatedAt}); err != nil { // -> pr_created (seq 3) + if err := s.UpsertPR(ctx, domain.PRRow{URL: "pr1", SessionID: string(r.ID), UpdatedAt: r.UpdatedAt}); err != nil { // -> pr_created (seq 3) t.Fatal(err) } diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 5a79105452..e96b55647f 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -16,17 +16,16 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/session" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/wiring" ) // lifecycleStack owns the running LCM + reaper. The LCM is the sole writer of // canonical transitions; the reaper is the OBSERVE-layer timer that probes live -// runtimes and reports facts back through it. Adapter is exposed so the Session +// runtimes and reports facts back through it. Store is exposed so the Session // Manager construction in startSession can plug the same SessionStore + PRWriter -// instance the LCM already holds. +// instance the LCM already holds (*sqlite.Store satisfies both ports directly). type lifecycleStack struct { LCM *lifecycle.Manager - Adapter wiring.Adapter + Store *sqlite.Store reaperDone <-chan struct{} } @@ -38,12 +37,11 @@ type lifecycleStack struct { // - reaper.MapRegistry{} — empty runtime registry, so the reaper ticks // escalations but probes nothing until the runtime plugins exist. func startLifecycle(ctx context.Context, store *sqlite.Store, logger *slog.Logger) (*lifecycleStack, error) { - a := wiring.Adapter{Store: store} renderer := notification.NewRenderer(store) notifier := notification.NewEnqueuer(store, renderer, logger) - lcm := lifecycle.New(a, a, notifier, noopMessenger{}) + lcm := lifecycle.New(store, store, notifier, noopMessenger{}) rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Logger: logger}) - return &lifecycleStack{LCM: lcm, Adapter: a, reaperDone: rp.Start(ctx)}, nil + return &lifecycleStack{LCM: lcm, Store: store, reaperDone: rp.Start(ctx)}, nil } // Stop waits for the reaper goroutine to exit (the caller must have cancelled the @@ -88,7 +86,7 @@ func startSession(ctx context.Context, cfg config.Config, ls *lifecycleStack, lo Runtime: runtime, Agent: agent, Workspace: ws, - Store: ls.Adapter, + Store: ls.Store, Messenger: noopMessenger{}, Lifecycle: ls.LCM, }) diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index c2cfb72135..f83be0dde2 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -18,7 +18,6 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/session" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/wiring" ) // TestWiring_WriteFlowsToBroadcaster exercises the real boot path end to end: @@ -32,11 +31,10 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { } defer store.Close() - a := wiring.Adapter{Store: store} renderer := notification.NewRenderer(store) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) notifier := notification.NewEnqueuer(store, renderer, logger) - lcm := lifecycle.New(a, a, notifier, noopMessenger{}) + lcm := lifecycle.New(store, store, notifier, noopMessenger{}) bcast := cdc.NewBroadcaster() poller := cdc.NewPoller(cdcSource{store}, bcast, cdc.PollerConfig{}) @@ -123,13 +121,13 @@ func TestWiring_SessionManagerSharesLifecycleStoreAndLCM(t *testing.T) { gotStore, gotLCM := inspectSessionDeps(t, sStack.SM) - // Store should be the exact wiring.Adapter the LCM was constructed with. - gotAdapter, ok := gotStore.(wiring.Adapter) + // Store should be the exact *sqlite.Store the LCM was constructed with. + gotSqlite, ok := gotStore.(*sqlite.Store) if !ok { - t.Fatalf("SM.store is %T, want wiring.Adapter", gotStore) + t.Fatalf("SM.store is %T, want *sqlite.Store", gotStore) } - if gotAdapter.Store != lcStack.Adapter.Store { - t.Fatalf("SM.store wraps a different *sqlite.Store than lcStack.Adapter") + if gotSqlite != lcStack.Store { + t.Fatalf("SM.store is a different *sqlite.Store than lcStack.Store") } // Lifecycle should be the exact *lifecycle.Manager pointer from startLifecycle. diff --git a/backend/internal/domain/pr.go b/backend/internal/domain/pr.go new file mode 100644 index 0000000000..77f94f2758 --- /dev/null +++ b/backend/internal/domain/pr.go @@ -0,0 +1,47 @@ +package domain + +import "time" + +// The PR rows are the canonical shapes for the pr / pr_checks / pr_comment +// tables, shared by the PRWriter port and the sqlite store (the store maps them +// to/from the sqlc gen.* models). They are flat by design — these tables carry +// no nesting or derivation, so a single definition serves every layer. +// +// PRRow is the scalar facts of one tracked pull request (the pr table). A session +// can own several PRs; a PR belongs to one session. PRFacts is the read-model +// derived from these for display status; PRRow is what gets written. +type PRRow struct { + URL string + SessionID string + Number int + Draft bool + Merged bool + Closed bool + CI CIState + Review ReviewDecision + Mergeability Mergeability + UpdatedAt time.Time +} + +// PRCheckRow is one CI check run — one row per check name per commit. +type PRCheckRow struct { + PRURL string + Name string + CommitHash string + Status string + URL string + LogTail string + CreatedAt time.Time +} + +// PRComment is one review comment. Feedback is injected into the agent +// regardless of author, so there is no bot/human distinction. +type PRComment struct { + ID string + Author string + File string + Line int + Body string + Resolved bool + CreatedAt time.Time +} diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index c353bc6dc8..67b781fb9b 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -24,99 +24,6 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) -// ---- store adapter ---- -// -// MIRROR OF backend/lifecycle_wiring.go's storeAdapter. The integration tests -// can't import package main, so the small set of methods that bridge -// *sqlite.Store to ports.SessionStore + ports.PRWriter is duplicated here. -// Function bodies are line-for-line identical to the production adapter so a -// future divergence shows up as a real diff in code review; the obvious -// follow-up is to extract the production adapter into a shared internal -// package — explicitly out of scope for this PR ("do NOT redesign anything"). - -type storeAdapter struct{ *sqlite.Store } - -var ( - _ ports.SessionStore = storeAdapter{} - _ ports.PRWriter = storeAdapter{} -) - -func (a storeAdapter) PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) { - rows, err := a.Store.ListPRsBySession(ctx, string(id)) - if err != nil { - return domain.PRFacts{}, err - } - if len(rows) == 0 { - return domain.PRFacts{}, nil - } - pick := rows[0] - for _, r := range rows { - if r.State == "draft" || r.State == "open" { - pick = r - break - } - } - facts := domain.PRFacts{ - URL: pick.URL, Number: int(pick.Number), Exists: true, - Draft: pick.State == "draft", Merged: pick.State == "merged", Closed: pick.State == "closed", - CI: domain.CIState(pick.CIState), - Review: domain.ReviewDecision(pick.ReviewDecision), - Mergeability: domain.Mergeability(pick.Mergeability), - } - comments, err := a.Store.ListPRComments(ctx, pick.URL) - if err != nil { - return domain.PRFacts{}, err - } - for _, c := range comments { - if !c.Resolved { - facts.ReviewComments = true - break - } - } - return facts, nil -} - -func (a storeAdapter) WritePR(ctx context.Context, pr ports.PRRow, checks []ports.PRCheckRow, comments []ports.PRComment) error { - row := sqlite.PRRow{ - URL: pr.URL, SessionID: pr.SessionID, Number: int64(pr.Number), - State: prState(pr), - ReviewDecision: string(pr.Review), - CIState: string(pr.CI), - Mergeability: string(pr.Mergeability), - UpdatedAt: pr.UpdatedAt, - } - checkRows := make([]sqlite.PRCheckRow, len(checks)) - for i, c := range checks { - checkRows[i] = sqlite.PRCheckRow{ - PRURL: c.PRURL, Name: c.Name, CommitHash: c.CommitHash, - Status: c.Status, URL: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, - } - } - commentRows := make([]sqlite.PRCommentRow, len(comments)) - for i, c := range comments { - commentRows[i] = sqlite.PRCommentRow{ - PRURL: pr.URL, CommentID: c.ID, Author: c.Author, File: c.File, - Line: int64(c.Line), Body: c.Body, Resolved: c.Resolved, CreatedAt: c.CreatedAt, - } - } - return a.Store.WritePRObservation(ctx, row, checkRows, commentRows) -} - -// prState mirrors the production helper of the same name in -// backend/lifecycle_wiring.go. -func prState(r ports.PRRow) string { - switch { - case r.Merged: - return "merged" - case r.Closed: - return "closed" - case r.Draft: - return "draft" - default: - return "open" - } -} - // ---- plugin fakes (minimal: only enough to drive SM through real LCM) ---- type stubRuntime struct { @@ -197,7 +104,6 @@ func (n *captureNotifier) drain() []ports.Event { type liveStack struct { dataDir string store *sqlite.Store - adapter storeAdapter lcm *lifecycle.Manager sm *session.Manager notifier *captureNotifier @@ -216,24 +122,22 @@ func openLiveStack(t *testing.T, dataDir string) *liveStack { if err != nil { t.Fatalf("open sqlite: %v", err) } - adapter := storeAdapter{store} notifier := &captureNotifier{} messenger := &captureMessenger{} - lcm := lifecycle.New(adapter, adapter, notifier, messenger) + lcm := lifecycle.New(store, store, notifier, messenger) wsRoot := t.TempDir() sm := session.New(session.Deps{ Runtime: &stubRuntime{id: "h1", name: "tmux"}, Agent: stubAgent{}, Workspace: &stubWorkspace{root: wsRoot}, - Store: adapter, + Store: store, Messenger: messenger, Lifecycle: lcm, }) st := &liveStack{ dataDir: dataDir, store: store, - adapter: adapter, lcm: lcm, sm: sm, notifier: notifier, @@ -272,11 +176,10 @@ func seedProject(t *testing.T, store *sqlite.Store, id string) { } func durableLifecycle(store *sqlite.Store, messenger ports.AgentMessenger) *lifecycle.Manager { - adapter := storeAdapter{store} renderer := notification.NewRenderer(store) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) notifier := notification.NewEnqueuer(store, renderer, logger) - return lifecycle.New(adapter, adapter, notifier, messenger) + return lifecycle.New(store, store, notifier, messenger) } func durableRecord(project, issue, branch string) domain.SessionRecord { @@ -347,7 +250,7 @@ func TestHappyPath_Spawn_PR_Kill(t *testing.T) { if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ Fetched: true, URL: prURL, Number: 1, CI: domain.CIPassing, Review: domain.ReviewNone, Mergeability: domain.MergeMergeable, - Checks: []ports.PRCheckRow{{ + Checks: []domain.PRCheckRow{{ Name: "ci/build", CommitHash: "abc123", Status: "passed", CreatedAt: time.Now(), }}, }); err != nil { @@ -357,7 +260,7 @@ func TestHappyPath_Spawn_PR_Kill(t *testing.T) { if err != nil || !ok { t.Fatalf("get pr: ok=%v err=%v", ok, err) } - if prRow.SessionID != string(sess.ID) || prRow.CIState != "passing" || prRow.State != "open" { + if prRow.SessionID != string(sess.ID) || prRow.CI != domain.CIPassing || prRow.Draft || prRow.Merged || prRow.Closed { t.Fatalf("pr row wrong: %+v", prRow) } @@ -491,7 +394,7 @@ func TestCIFailureAndRecovery_NudgeThenClears(t *testing.T) { if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ Fetched: true, URL: prURL, Number: 2, CI: domain.CIFailing, Mergeability: domain.MergeUnstable, - Checks: []ports.PRCheckRow{{ + Checks: []domain.PRCheckRow{{ Name: "ci/build", CommitHash: "c1", Status: "failed", LogTail: "panic: nil map", CreatedAt: time.Now(), }}, }); err != nil { @@ -507,7 +410,7 @@ func TestCIFailureAndRecovery_NudgeThenClears(t *testing.T) { // Brake confirmation: only one failure so far, RecentCheckStatuses should // reflect it. - history, err := st.adapter.RecentCheckStatuses(ctx, prURL, "ci/build", 3) + history, err := st.store.RecentCheckStatuses(ctx, prURL, "ci/build", 3) if err != nil { t.Fatalf("recent checks: %v", err) } @@ -521,7 +424,7 @@ func TestCIFailureAndRecovery_NudgeThenClears(t *testing.T) { if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ Fetched: true, URL: prURL, Number: 2, CI: domain.CIPassing, Mergeability: domain.MergeMergeable, - Checks: []ports.PRCheckRow{{ + Checks: []domain.PRCheckRow{{ Name: "ci/build", CommitHash: "c2", Status: "passed", CreatedAt: time.Now(), }}, }); err != nil { @@ -537,7 +440,7 @@ func TestCIFailureAndRecovery_NudgeThenClears(t *testing.T) { // And the pr row reflects the recovery in the canonical fact store. prRow, ok, _ := st.store.GetPR(ctx, prURL) - if !ok || prRow.CIState != "passing" { + if !ok || prRow.CI != domain.CIPassing { t.Fatalf("pr ci_state should be passing post-recovery: %+v", prRow) } } diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 438a76c6f6..dff0443d2c 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -180,12 +180,12 @@ func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o // in one atomic store call. PR-table CDC is emitted by the DB triggers. func (m *Manager) writePR(ctx context.Context, id domain.SessionID, o ports.PRObservation) error { now := m.clock() - row := ports.PRRow{ + row := domain.PRRow{ URL: o.URL, SessionID: string(id), Number: o.Number, Draft: o.Draft, Merged: o.Merged, Closed: o.Closed, CI: o.CI, Review: o.Review, Mergeability: o.Mergeability, UpdatedAt: now, } - checks := make([]ports.PRCheckRow, len(o.Checks)) + checks := make([]domain.PRCheckRow, len(o.Checks)) for i, c := range o.Checks { c.PRURL = o.URL if c.CreatedAt.IsZero() { @@ -193,7 +193,7 @@ func (m *Manager) writePR(ctx context.Context, id domain.SessionID, o ports.PROb } checks[i] = c } - comments := make([]ports.PRComment, len(o.Comments)) + comments := make([]domain.PRComment, len(o.Comments)) for i, c := range o.Comments { if c.CreatedAt.IsZero() { c.CreatedAt = now diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index 4ae9aaafd0..8adfd862ad 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -20,17 +20,17 @@ var ctx = context.Background() // write path and the read-back together. type fakeStore struct { sessions map[domain.SessionID]domain.SessionRecord - pr map[domain.SessionID]ports.PRRow - comments map[string][]ports.PRComment - checks []ports.PRCheckRow + pr map[domain.SessionID]domain.PRRow + comments map[string][]domain.PRComment + checks []domain.PRCheckRow num int } func newFakeStore() *fakeStore { return &fakeStore{ sessions: map[domain.SessionID]domain.SessionRecord{}, - pr: map[domain.SessionID]ports.PRRow{}, - comments: map[string][]ports.PRComment{}, + pr: map[domain.SessionID]domain.PRRow{}, + comments: map[string][]domain.PRComment{}, } } @@ -82,7 +82,7 @@ func (f *fakeStore) PRFactsForSession(_ context.Context, id domain.SessionID) (d } return facts, nil } -func (f *fakeStore) WritePR(_ context.Context, pr ports.PRRow, checks []ports.PRCheckRow, comments []ports.PRComment) error { +func (f *fakeStore) WritePR(_ context.Context, pr domain.PRRow, checks []domain.PRCheckRow, comments []domain.PRComment) error { f.pr[domain.SessionID(pr.SessionID)] = pr f.checks = append(f.checks, checks...) f.comments[pr.URL] = comments @@ -222,7 +222,7 @@ func TestPR_CIFailingNudgesAgentWithLogs(t *testing.T) { m, st, _, msg := newManager() st.sessions["mer-1"] = working("mer-1") - o := openPR(ports.PRObservation{CI: domain.CIFailing, Checks: []ports.PRCheckRow{{Name: "build", CommitHash: "c1", Status: "failed", LogTail: "boom"}}}) + o := openPR(ports.PRObservation{CI: domain.CIFailing, Checks: []domain.PRCheckRow{{Name: "build", CommitHash: "c1", Status: "failed", LogTail: "boom"}}}) if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { t.Fatal(err) } @@ -236,7 +236,7 @@ func TestPR_CIBrakeEscalatesAfterThreeFails(t *testing.T) { st.sessions["mer-1"] = working("mer-1") for _, commit := range []string{"c1", "c2", "c3"} { - o := openPR(ports.PRObservation{CI: domain.CIFailing, Checks: []ports.PRCheckRow{{Name: "build", CommitHash: commit, Status: "failed", LogTail: "boom"}}}) + o := openPR(ports.PRObservation{CI: domain.CIFailing, Checks: []domain.PRCheckRow{{Name: "build", CommitHash: commit, Status: "failed", LogTail: "boom"}}}) if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { t.Fatal(err) } @@ -255,7 +255,7 @@ func TestPR_ReviewCommentsInjectedRegardlessOfAuthor(t *testing.T) { o := openPR(ports.PRObservation{ Review: domain.ReviewChangesRequest, - Comments: []ports.PRComment{{ID: "1", Author: "greptileai", Body: "use a constant here"}}, + Comments: []domain.PRComment{{ID: "1", Author: "greptileai", Body: "use a constant here"}}, }) if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { t.Fatal(err) diff --git a/backend/internal/ports/facts.go b/backend/internal/ports/facts.go index a3b3b39795..01a789617c 100644 --- a/backend/internal/ports/facts.go +++ b/backend/internal/ports/facts.go @@ -52,8 +52,8 @@ type PRObservation struct { CI domain.CIState Review domain.ReviewDecision Mergeability domain.Mergeability - Checks []PRCheckRow - Comments []PRComment + Checks []domain.PRCheckRow + Comments []domain.PRComment } // SpawnOutcome is what the Session Manager reports once a spawn is live: the @@ -65,42 +65,3 @@ type SpawnOutcome struct { AgentSessionID string Prompt string } - -// ---- store row DTOs (shared by the PRWriter port and its sqlite adapter) ---- - -// PRRow is the scalar PR facts row. -type PRRow struct { - URL string - SessionID string - Number int - Draft bool - Merged bool - Closed bool - CI domain.CIState - Review domain.ReviewDecision - Mergeability domain.Mergeability - UpdatedAt time.Time -} - -// PRCheckRow is one CI check run (one row per check name per commit). -type PRCheckRow struct { - PRURL string - Name string - CommitHash string - Status string - URL string - LogTail string - CreatedAt time.Time -} - -// PRComment is one review comment. Review feedback is injected into the agent -// regardless of author, so there is no bot/human distinction. -type PRComment struct { - ID string - Author string - File string - Line int - Body string - Resolved bool - CreatedAt time.Time -} diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 79c2042307..bc7321d334 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -28,7 +28,7 @@ type PRWriter interface { // WritePR persists a full PR observation — scalar facts, check runs, and the // replacement comment set — in one transaction, so the rows and the CDC // events they emit are all-or-nothing. - WritePR(ctx context.Context, pr PRRow, checks []PRCheckRow, comments []PRComment) error + WritePR(ctx context.Context, pr domain.PRRow, checks []domain.PRCheckRow, comments []domain.PRComment) error // RecentCheckStatuses reads the last `limit` runs of a check (the CI brake). RecentCheckStatuses(ctx context.Context, prURL, name string, limit int) ([]string, error) } diff --git a/backend/internal/storage/sqlite/pr_cdc_test.go b/backend/internal/storage/sqlite/pr_cdc_test.go index 8c8f7ea2ef..102e8b4f20 100644 --- a/backend/internal/storage/sqlite/pr_cdc_test.go +++ b/backend/internal/storage/sqlite/pr_cdc_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) // A check can change status on the same commit (in_progress -> failed) via @@ -19,13 +21,13 @@ func TestPRChecksCDC_EmitsOnInsertAndStatusUpdate(t *testing.T) { t.Fatal(err) } url := "https://example/pr/1" - if err := s.UpsertPR(ctx, PRRow{URL: url, SessionID: string(rec.ID), Number: 1}); err != nil { + if err := s.UpsertPR(ctx, domain.PRRow{URL: url, SessionID: string(rec.ID), Number: 1}); err != nil { t.Fatal(err) } now := time.Now() mustCheck := func(status string) { - if err := s.RecordCheck(ctx, PRCheckRow{PRURL: url, Name: "build", CommitHash: "c1", Status: status, CreatedAt: now}); err != nil { + if err := s.RecordCheck(ctx, domain.PRCheckRow{PRURL: url, Name: "build", CommitHash: "c1", Status: status, CreatedAt: now}); err != nil { t.Fatal(err) } } @@ -51,9 +53,9 @@ func TestPRChecksCDC_EmitsOnInsertAndStatusUpdate(t *testing.T) { } } -// WritePRObservation persists scalar facts, checks, and comments in one tx; all -// three should be queryable afterward. -func TestWritePRObservation_PersistsScalarsChecksAndComments(t *testing.T) { +// WritePR persists scalar facts, checks, and comments in one tx; all three +// should be queryable afterward. +func TestWritePR_PersistsScalarsChecksAndComments(t *testing.T) { s := newTestStore(t) ctx := context.Background() seedProject(t, s, "mer") @@ -64,18 +66,18 @@ func TestWritePRObservation_PersistsScalarsChecksAndComments(t *testing.T) { url := "https://example/pr/7" now := time.Now() - err = s.WritePRObservation(ctx, - PRRow{URL: url, SessionID: string(rec.ID), Number: 7, CIState: "failing", UpdatedAt: now}, - []PRCheckRow{{PRURL: url, Name: "build", CommitHash: "c1", Status: "failed", CreatedAt: now}}, - []PRCommentRow{{PRURL: url, CommentID: "1", Author: "reviewer", Body: "use a const", CreatedAt: now}}, + err = s.WritePR(ctx, + domain.PRRow{URL: url, SessionID: string(rec.ID), Number: 7, CI: domain.CIFailing, UpdatedAt: now}, + []domain.PRCheckRow{{PRURL: url, Name: "build", CommitHash: "c1", Status: "failed", CreatedAt: now}}, + []domain.PRComment{{ID: "1", Author: "reviewer", Body: "use a const", CreatedAt: now}}, ) if err != nil { t.Fatal(err) } pr, ok, err := s.GetPR(ctx, url) - if err != nil || !ok || pr.CIState != "failing" { - t.Fatalf("scalar facts not persisted: ok=%v ci=%q err=%v", ok, pr.CIState, err) + if err != nil || !ok || pr.CI != domain.CIFailing { + t.Fatalf("scalar facts not persisted: ok=%v ci=%q err=%v", ok, pr.CI, err) } if checks, _ := s.ListChecks(ctx, url); len(checks) != 1 || checks[0].Status != "failed" { t.Fatalf("check not persisted: %+v", checks) diff --git a/backend/internal/storage/sqlite/pr_facts.go b/backend/internal/storage/sqlite/pr_facts.go index d72f2978eb..c0c3068b41 100644 --- a/backend/internal/storage/sqlite/pr_facts.go +++ b/backend/internal/storage/sqlite/pr_facts.go @@ -19,17 +19,15 @@ func (s *Store) PRFactsForSession(ctx context.Context, id domain.SessionID) (dom } pick := rows[0] for _, r := range rows { - if r.State == "draft" || r.State == "open" { + if !r.Merged && !r.Closed { // newest non-closed (draft or open) pick = r break } } facts := domain.PRFacts{ - URL: pick.URL, Number: int(pick.Number), Exists: true, - Draft: pick.State == "draft", Merged: pick.State == "merged", Closed: pick.State == "closed", - CI: domain.CIState(pick.CIState), - Review: domain.ReviewDecision(pick.ReviewDecision), - Mergeability: domain.Mergeability(pick.Mergeability), + URL: pick.URL, Number: pick.Number, Exists: true, + Draft: pick.Draft, Merged: pick.Merged, Closed: pick.Closed, + CI: pick.CI, Review: pick.Review, Mergeability: pick.Mergeability, } comments, err := s.ListPRComments(ctx, pick.URL) if err != nil { diff --git a/backend/internal/storage/sqlite/pr_store.go b/backend/internal/storage/sqlite/pr_store.go index 8b41396c57..e23626240f 100644 --- a/backend/internal/storage/sqlite/pr_store.go +++ b/backend/internal/storage/sqlite/pr_store.go @@ -5,123 +5,73 @@ import ( "database/sql" "errors" "fmt" - "time" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -// PRRow is the scalar PR facts row (the pr table), keyed by normalized URL. One -// session can own many PRs; a PR belongs to one session (session_id FK). -type PRRow struct { - URL string - SessionID string - Number int64 - State string // draft | open | merged | closed - ReviewDecision string // none | approved | changes_requested | review_required - CIState string // unknown | pending | passing | failing - Mergeability string // unknown | mergeable | conflicting | blocked | unstable - UpdatedAt time.Time -} +// The pr / pr_checks / pr_comment rows are modelled by domain.PRRow / +// domain.PRCheckRow / domain.PRComment — flat tables, one shared type per table. +// This layer only maps those to/from the sqlc gen.* params: the bool PR state +// becomes the single pr.state column, empty enums default to their +// "nothing known yet" value (matching the CHECK constraints), and ints widen to +// int64. -// UpsertPR inserts or replaces the scalar PR facts for a PR URL. Empty enum -// fields default to their "nothing known yet" value so a partial row is valid -// against the CHECK constraints (matches the domain zero values none/unknown). -func (s *Store) UpsertPR(ctx context.Context, r PRRow) error { - r = r.withDefaults() +// UpsertPR inserts or replaces the scalar PR facts for a PR URL. +func (s *Store) UpsertPR(ctx context.Context, r domain.PRRow) error { s.writeMu.Lock() defer s.writeMu.Unlock() - return s.qw.UpsertPR(ctx, gen.UpsertPRParams{ - Url: r.URL, - SessionID: r.SessionID, - Number: r.Number, - PrState: r.State, - ReviewDecision: r.ReviewDecision, - CiState: r.CIState, - Mergeability: r.Mergeability, - UpdatedAt: r.UpdatedAt, - }) + return s.qw.UpsertPR(ctx, genPRParams(r)) } -// WritePRObservation persists a full PR observation — scalar facts, check runs, -// and the replacement comment set — in one write transaction, so the rows and -// the change_log events their triggers emit are committed all-or-nothing. The -// scalar PR upsert runs first so the checks'/comments' CDC triggers can resolve -// the session id from the pr row within the same transaction. -func (s *Store) WritePRObservation(ctx context.Context, pr PRRow, checks []PRCheckRow, comments []PRCommentRow) error { - pr = pr.withDefaults() +// WritePR persists a full PR observation — scalar facts, check runs, and the +// replacement comment set — in one write transaction, so the rows and the +// change_log events their triggers emit are committed all-or-nothing. The scalar +// PR upsert runs first so the checks'/comments' CDC triggers can resolve the +// session id from the pr row within the same transaction. +func (s *Store) WritePR(ctx context.Context, pr domain.PRRow, checks []domain.PRCheckRow, comments []domain.PRComment) error { s.writeMu.Lock() defer s.writeMu.Unlock() return s.inTx(ctx, "write pr observation", func(q *gen.Queries) error { - if err := q.UpsertPR(ctx, gen.UpsertPRParams{ - Url: pr.URL, SessionID: pr.SessionID, Number: pr.Number, - PrState: pr.State, ReviewDecision: pr.ReviewDecision, - CiState: pr.CIState, Mergeability: pr.Mergeability, UpdatedAt: pr.UpdatedAt, - }); err != nil { + if err := q.UpsertPR(ctx, genPRParams(pr)); err != nil { return err } for _, c := range checks { - if c.Status == "" { - c.Status = "unknown" - } - if err := q.UpsertPRCheck(ctx, gen.UpsertPRCheckParams{ - PrUrl: c.PRURL, Name: c.Name, CommitHash: c.CommitHash, - Status: c.Status, Url: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, - }); err != nil { + if err := q.UpsertPRCheck(ctx, genCheckParams(c)); err != nil { return err } } if err := q.DeletePRComments(ctx, pr.URL); err != nil { return err } - for _, cm := range comments { - if err := q.UpsertPRComment(ctx, gen.UpsertPRCommentParams{ - PrUrl: pr.URL, CommentID: cm.CommentID, Author: cm.Author, File: cm.File, - Line: cm.Line, Body: cm.Body, Resolved: boolToInt(cm.Resolved), CreatedAt: cm.CreatedAt, - }); err != nil { - return fmt.Errorf("comment %q: %w", cm.CommentID, err) + for _, c := range comments { + if err := q.UpsertPRComment(ctx, genCommentParams(pr.URL, c)); err != nil { + return fmt.Errorf("comment %q: %w", c.ID, err) } } return nil }) } -// withDefaults fills empty enum fields with their "nothing known yet" value so a -// partial row satisfies the CHECK constraints (matches UpsertPR). -func (r PRRow) withDefaults() PRRow { - if r.State == "" { - r.State = "open" - } - if r.ReviewDecision == "" { - r.ReviewDecision = "none" - } - if r.CIState == "" { - r.CIState = "unknown" - } - if r.Mergeability == "" { - r.Mergeability = "unknown" - } - return r -} - // GetPR returns the PR facts for a URL, or ok=false if absent. -func (s *Store) GetPR(ctx context.Context, url string) (PRRow, bool, error) { +func (s *Store) GetPR(ctx context.Context, url string) (domain.PRRow, bool, error) { p, err := s.qr.GetPR(ctx, url) if errors.Is(err, sql.ErrNoRows) { - return PRRow{}, false, nil + return domain.PRRow{}, false, nil } if err != nil { - return PRRow{}, false, fmt.Errorf("get pr %s: %w", url, err) + return domain.PRRow{}, false, fmt.Errorf("get pr %s: %w", url, err) } return prRowFromGen(p), true, nil } // ListPRsBySession returns every PR owned by a session, newest first. -func (s *Store) ListPRsBySession(ctx context.Context, sessionID string) ([]PRRow, error) { +func (s *Store) ListPRsBySession(ctx context.Context, sessionID string) ([]domain.PRRow, error) { rows, err := s.qr.ListPRsBySession(ctx, sessionID) if err != nil { return nil, fmt.Errorf("list prs for %s: %w", sessionID, err) } - out := make([]PRRow, 0, len(rows)) + out := make([]domain.PRRow, 0, len(rows)) for _, p := range rows { out = append(out, prRowFromGen(p)) } @@ -135,49 +85,12 @@ func (s *Store) DeletePR(ctx context.Context, url string) error { return s.qw.DeletePR(ctx, url) } -func prRowFromGen(p gen.Pr) PRRow { - return PRRow{ - URL: p.Url, - SessionID: p.SessionID, - Number: p.Number, - State: p.PrState, - ReviewDecision: p.ReviewDecision, - CIState: p.CiState, - Mergeability: p.Mergeability, - UpdatedAt: p.UpdatedAt, - } -} - -// ---- pr_checks: CI run history ---- - -// PRCheckRow is one CI check run for a PR (one row per check name per commit). -type PRCheckRow struct { - PRURL string - Name string - CommitHash string - Status string // unknown | queued | in_progress | passed | failed | skipped | cancelled - URL string - LogTail string - CreatedAt time.Time -} - // RecordCheck upserts a CI check run. Re-polling the same (pr, name, commit) // updates the same row; a new commit creates a new row (a fresh agent attempt). -func (s *Store) RecordCheck(ctx context.Context, r PRCheckRow) error { - if r.Status == "" { - r.Status = "unknown" - } +func (s *Store) RecordCheck(ctx context.Context, r domain.PRCheckRow) error { s.writeMu.Lock() defer s.writeMu.Unlock() - return s.qw.UpsertPRCheck(ctx, gen.UpsertPRCheckParams{ - PrUrl: r.PRURL, - Name: r.Name, - CommitHash: r.CommitHash, - Status: r.Status, - Url: r.URL, - LogTail: r.LogTail, - CreatedAt: r.CreatedAt, - }) + return s.qw.UpsertPRCheck(ctx, genCheckParams(r)) } // RecentCheckStatuses returns the statuses of the last `limit` runs of a check, @@ -197,38 +110,21 @@ func (s *Store) RecentCheckStatuses(ctx context.Context, prURL, name string, lim } // ListChecks returns every recorded check run for a PR. -func (s *Store) ListChecks(ctx context.Context, prURL string) ([]PRCheckRow, error) { +func (s *Store) ListChecks(ctx context.Context, prURL string) ([]domain.PRCheckRow, error) { rows, err := s.qr.ListChecksByPR(ctx, prURL) if err != nil { return nil, fmt.Errorf("list checks %s: %w", prURL, err) } - out := make([]PRCheckRow, 0, len(rows)) + out := make([]domain.PRCheckRow, 0, len(rows)) for _, c := range rows { - out = append(out, PRCheckRow{ - PRURL: c.PrUrl, Name: c.Name, CommitHash: c.CommitHash, - Status: c.Status, URL: c.Url, LogTail: c.LogTail, CreatedAt: c.CreatedAt, - }) + out = append(out, checkRowFromGen(c)) } return out, nil } -// ---- pr_comment ---- - -// PRCommentRow is one review comment on a PR. -type PRCommentRow struct { - PRURL string - CommentID string - Author string - File string - Line int64 - Body string - Resolved bool - CreatedAt time.Time -} - // ReplacePRComments atomically replaces the full comment set for a PR (each SCM // fetch reports the current set, so a replace keeps it in sync). -func (s *Store) ReplacePRComments(ctx context.Context, prURL string, comments []PRCommentRow) error { +func (s *Store) ReplacePRComments(ctx context.Context, prURL string, comments []domain.PRComment) error { s.writeMu.Lock() defer s.writeMu.Unlock() return s.inTx(ctx, "replace pr comments", func(q *gen.Queries) error { @@ -236,17 +132,8 @@ func (s *Store) ReplacePRComments(ctx context.Context, prURL string, comments [] return err } for _, c := range comments { - if err := q.UpsertPRComment(ctx, gen.UpsertPRCommentParams{ - PrUrl: prURL, - CommentID: c.CommentID, - Author: c.Author, - File: c.File, - Line: c.Line, - Body: c.Body, - Resolved: boolToInt(c.Resolved), - CreatedAt: c.CreatedAt, - }); err != nil { - return fmt.Errorf("comment %q: %w", c.CommentID, err) + if err := q.UpsertPRComment(ctx, genCommentParams(prURL, c)); err != nil { + return fmt.Errorf("comment %q: %w", c.ID, err) } } return nil @@ -254,17 +141,97 @@ func (s *Store) ReplacePRComments(ctx context.Context, prURL string, comments [] } // ListPRComments returns a PR's review comments, oldest first. -func (s *Store) ListPRComments(ctx context.Context, prURL string) ([]PRCommentRow, error) { +func (s *Store) ListPRComments(ctx context.Context, prURL string) ([]domain.PRComment, error) { rows, err := s.qr.ListPRComments(ctx, prURL) if err != nil { return nil, fmt.Errorf("list pr comments %s: %w", prURL, err) } - out := make([]PRCommentRow, 0, len(rows)) + out := make([]domain.PRComment, 0, len(rows)) for _, c := range rows { - out = append(out, PRCommentRow{ - PRURL: c.PrUrl, CommentID: c.CommentID, Author: c.Author, File: c.File, - Line: c.Line, Body: c.Body, Resolved: c.Resolved != 0, CreatedAt: c.CreatedAt, - }) + out = append(out, commentFromGen(c)) } return out, nil } + +// ---- domain <-> gen mapping ---- + +// prState collapses the PR's bools into the single pr.state column value. +func prState(r domain.PRRow) string { + switch { + case r.Merged: + return "merged" + case r.Closed: + return "closed" + case r.Draft: + return "draft" + default: + return "open" + } +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} + +func genPRParams(r domain.PRRow) gen.UpsertPRParams { + return gen.UpsertPRParams{ + Url: r.URL, + SessionID: r.SessionID, + Number: int64(r.Number), + PrState: prState(r), + ReviewDecision: orDefault(string(r.Review), "none"), + CiState: orDefault(string(r.CI), "unknown"), + Mergeability: orDefault(string(r.Mergeability), "unknown"), + UpdatedAt: r.UpdatedAt, + } +} + +func prRowFromGen(p gen.Pr) domain.PRRow { + return domain.PRRow{ + URL: p.Url, + SessionID: p.SessionID, + Number: int(p.Number), + Draft: p.PrState == "draft", + Merged: p.PrState == "merged", + Closed: p.PrState == "closed", + CI: domain.CIState(p.CiState), + Review: domain.ReviewDecision(p.ReviewDecision), + Mergeability: domain.Mergeability(p.Mergeability), + UpdatedAt: p.UpdatedAt, + } +} + +func genCheckParams(c domain.PRCheckRow) gen.UpsertPRCheckParams { + status := c.Status + if status == "" { + status = "unknown" + } + return gen.UpsertPRCheckParams{ + PrUrl: c.PRURL, Name: c.Name, CommitHash: c.CommitHash, + Status: status, Url: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, + } +} + +func checkRowFromGen(c gen.PrCheck) domain.PRCheckRow { + return domain.PRCheckRow{ + PRURL: c.PrUrl, Name: c.Name, CommitHash: c.CommitHash, + Status: c.Status, URL: c.Url, LogTail: c.LogTail, CreatedAt: c.CreatedAt, + } +} + +func genCommentParams(prURL string, c domain.PRComment) gen.UpsertPRCommentParams { + return gen.UpsertPRCommentParams{ + PrUrl: prURL, CommentID: c.ID, Author: c.Author, File: c.File, + Line: int64(c.Line), Body: c.Body, Resolved: boolToInt(c.Resolved), CreatedAt: c.CreatedAt, + } +} + +func commentFromGen(c gen.PrComment) domain.PRComment { + return domain.PRComment{ + ID: c.CommentID, Author: c.Author, File: c.File, Line: int(c.Line), + Body: c.Body, Resolved: c.Resolved != 0, CreatedAt: c.CreatedAt, + } +} diff --git a/backend/internal/storage/sqlite/store_test.go b/backend/internal/storage/sqlite/store_test.go index 55165c41fe..832bcfa480 100644 --- a/backend/internal/storage/sqlite/store_test.go +++ b/backend/internal/storage/sqlite/store_test.go @@ -143,9 +143,9 @@ func TestPRCRUD(t *testing.T) { r, _ := s.CreateSession(ctx, sampleRecord("mer")) now := time.Now().UTC().Truncate(time.Second) - pr := PRRow{ - URL: "https://gh/pr/1", SessionID: string(r.ID), Number: 1, State: "open", - ReviewDecision: "review_required", CIState: "failing", Mergeability: "blocked", UpdatedAt: now, + pr := domain.PRRow{ + URL: "https://gh/pr/1", SessionID: string(r.ID), Number: 1, + Review: domain.ReviewRequired, CI: domain.CIFailing, Mergeability: domain.MergeBlocked, UpdatedAt: now, } if err := s.UpsertPR(ctx, pr); err != nil { t.Fatal(err) @@ -171,11 +171,11 @@ func TestPRChecksLoopBrakeQuery(t *testing.T) { seedProject(t, s, "mer") r, _ := s.CreateSession(ctx, sampleRecord("mer")) now := time.Now().UTC().Truncate(time.Second) - _ = s.UpsertPR(ctx, PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: now}) + _ = s.UpsertPR(ctx, domain.PRRow{URL: "pr1", SessionID: string(r.ID), UpdatedAt: now}) // three consecutive failing runs of "build" (one per commit). for i := 1; i <= 3; i++ { - if err := s.RecordCheck(ctx, PRCheckRow{ + if err := s.RecordCheck(ctx, domain.PRCheckRow{ PRURL: "pr1", Name: "build", CommitHash: fmt.Sprintf("c%d", i), Status: "failed", CreatedAt: now.Add(time.Duration(i) * time.Second), }); err != nil { @@ -190,7 +190,7 @@ func TestPRChecksLoopBrakeQuery(t *testing.T) { t.Fatalf("recent statuses = %v, want 3x failed (loop brake would trip)", last3) } // a pass on a newer commit breaks the streak. - _ = s.RecordCheck(ctx, PRCheckRow{PRURL: "pr1", Name: "build", CommitHash: "c4", Status: "passed", CreatedAt: now.Add(4 * time.Second)}) + _ = s.RecordCheck(ctx, domain.PRCheckRow{PRURL: "pr1", Name: "build", CommitHash: "c4", Status: "passed", CreatedAt: now.Add(4 * time.Second)}) last3, _ = s.RecentCheckStatuses(ctx, "pr1", "build", 3) if last3[0] != "passed" { t.Fatalf("most recent should be passed, got %v", last3) @@ -203,17 +203,17 @@ func TestPRCommentsReplace(t *testing.T) { seedProject(t, s, "mer") r, _ := s.CreateSession(ctx, sampleRecord("mer")) now := time.Now().UTC().Truncate(time.Second) - _ = s.UpsertPR(ctx, PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: now}) + _ = s.UpsertPR(ctx, domain.PRRow{URL: "pr1", SessionID: string(r.ID), UpdatedAt: now}) - _ = s.ReplacePRComments(ctx, "pr1", []PRCommentRow{ - {PRURL: "pr1", CommentID: "c1", Author: "a", File: "a.go", Line: 1, Body: "nit", CreatedAt: now}, - {PRURL: "pr1", CommentID: "c2", Author: "b", File: "b.go", Line: 2, Body: "bug", Resolved: true, CreatedAt: now.Add(time.Second)}, + _ = s.ReplacePRComments(ctx, "pr1", []domain.PRComment{ + {ID: "c1", Author: "a", File: "a.go", Line: 1, Body: "nit", CreatedAt: now}, + {ID: "c2", Author: "b", File: "b.go", Line: 2, Body: "bug", Resolved: true, CreatedAt: now.Add(time.Second)}, }) if list, _ := s.ListPRComments(ctx, "pr1"); len(list) != 2 { t.Fatalf("comments = %d, want 2", len(list)) } // replace with a smaller set drops the rest. - _ = s.ReplacePRComments(ctx, "pr1", []PRCommentRow{{PRURL: "pr1", CommentID: "c1", Body: "x", CreatedAt: now}}) + _ = s.ReplacePRComments(ctx, "pr1", []domain.PRComment{{ID: "c1", Body: "x", CreatedAt: now}}) if list, _ := s.ListPRComments(ctx, "pr1"); len(list) != 1 { t.Fatalf("after replace, comments = %d, want 1", len(list)) } @@ -231,7 +231,7 @@ func TestCDCTriggersPopulateChangeLog(t *testing.T) { r.Metadata.Prompt = "only metadata changed" _ = s.UpdateSession(ctx, r) // a PR insert logs too. - _ = s.UpsertPR(ctx, PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: r.UpdatedAt}) + _ = s.UpsertPR(ctx, domain.PRRow{URL: "pr1", SessionID: string(r.ID), UpdatedAt: r.UpdatedAt}) evs, err := s.ReadChangeLogAfter(ctx, 0, 100) if err != nil { diff --git a/backend/internal/storage/sqlite/wiring/adapter.go b/backend/internal/storage/sqlite/wiring/adapter.go deleted file mode 100644 index 8a8d017dd1..0000000000 --- a/backend/internal/storage/sqlite/wiring/adapter.go +++ /dev/null @@ -1,107 +0,0 @@ -// Package wiring bridges *sqlite.Store to the engine's outbound ports. It -// embeds the store (so the SessionStore reads/writes and PRWriter.RecentCheckStatuses -// promote directly) and supplies the PR conversions plus the PRFacts read-model -// that drives the derived display status. -// -// The adapter lives in its own package so the daemon's composition root and any -// in-process integration tests (e.g. backend/internal/integration) can share the -// same bridge instead of redefining it. -package wiring - -import ( - "context" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" -) - -// Adapter wraps *sqlite.Store and implements ports.SessionStore + ports.PRWriter. -// The embedded *sqlite.Store promotes CreateSession / UpdateSession / GetSession -// / ListSessions / ListAllSessions and RecentCheckStatuses verbatim; the two -// methods defined here are the ones that need shape translation between the port -// types and the sqlite row types. -type Adapter struct{ *sqlite.Store } - -var ( - _ ports.SessionStore = Adapter{} - _ ports.PRWriter = Adapter{} -) - -// PRFactsForSession picks the PR that drives display status — the most-recently -// updated non-closed PR, else the most recent — and folds in whether it has -// unresolved review comments. -func (a Adapter) PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) { - rows, err := a.Store.ListPRsBySession(ctx, string(id)) // newest first - if err != nil { - return domain.PRFacts{}, err - } - if len(rows) == 0 { - return domain.PRFacts{}, nil - } - pick := rows[0] - for _, r := range rows { - if r.State == "draft" || r.State == "open" { - pick = r - break - } - } - facts := domain.PRFacts{ - URL: pick.URL, Number: int(pick.Number), Exists: true, - Draft: pick.State == "draft", Merged: pick.State == "merged", Closed: pick.State == "closed", - CI: domain.CIState(pick.CIState), - Review: domain.ReviewDecision(pick.ReviewDecision), - Mergeability: domain.Mergeability(pick.Mergeability), - } - comments, err := a.Store.ListPRComments(ctx, pick.URL) - if err != nil { - return domain.PRFacts{}, err - } - for _, c := range comments { - if !c.Resolved { - facts.ReviewComments = true - break - } - } - return facts, nil -} - -func (a Adapter) WritePR(ctx context.Context, pr ports.PRRow, checks []ports.PRCheckRow, comments []ports.PRComment) error { - row := sqlite.PRRow{ - URL: pr.URL, SessionID: pr.SessionID, Number: int64(pr.Number), - State: prState(pr), - ReviewDecision: string(pr.Review), - CIState: string(pr.CI), - Mergeability: string(pr.Mergeability), - UpdatedAt: pr.UpdatedAt, - } - checkRows := make([]sqlite.PRCheckRow, len(checks)) - for i, c := range checks { - checkRows[i] = sqlite.PRCheckRow{ - PRURL: c.PRURL, Name: c.Name, CommitHash: c.CommitHash, - Status: c.Status, URL: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, - } - } - commentRows := make([]sqlite.PRCommentRow, len(comments)) - for i, c := range comments { - commentRows[i] = sqlite.PRCommentRow{ - PRURL: pr.URL, CommentID: c.ID, Author: c.Author, File: c.File, - Line: int64(c.Line), Body: c.Body, Resolved: c.Resolved, CreatedAt: c.CreatedAt, - } - } - return a.Store.WritePRObservation(ctx, row, checkRows, commentRows) -} - -// prState collapses the PR's bools into the single pr.state column value. -func prState(r ports.PRRow) string { - switch { - case r.Merged: - return "merged" - case r.Closed: - return "closed" - case r.Draft: - return "draft" - default: - return "open" - } -} From 42eab57d49f4de654c1bbf26038d13ac054aca08 Mon Sep 17 00:00:00 2001 From: prateek Date: Mon, 1 Jun 2026 03:38:23 +0530 Subject: [PATCH 088/250] refactor(storage): add compile-time port guards on *Store Re-add the blank-identifier interface assertions lost when wiring.Adapter was collapsed: *Store now directly satisfies ports.SessionStore and ports.PRWriter, so prove it at the point of definition. Drift between either port and the implementation now fails here instead of at the call sites in lifecycle_wiring or tests. Addresses greptile review comment on #60. Co-Authored-By: Claude Opus 4.8 --- backend/internal/storage/sqlite/pr_store.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/internal/storage/sqlite/pr_store.go b/backend/internal/storage/sqlite/pr_store.go index e23626240f..1d57b40d44 100644 --- a/backend/internal/storage/sqlite/pr_store.go +++ b/backend/internal/storage/sqlite/pr_store.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) @@ -17,6 +18,14 @@ import ( // "nothing known yet" value (matching the CHECK constraints), and ints widen to // int64. +// Compile-time proof that *Store satisfies both ports it is wired into, so a +// drift between either interface and this implementation fails here at the point +// of definition rather than later at the call sites in lifecycle_wiring / tests. +var ( + _ ports.SessionStore = (*Store)(nil) + _ ports.PRWriter = (*Store)(nil) +) + // UpsertPR inserts or replaces the scalar PR facts for a PR URL. func (s *Store) UpsertPR(ctx context.Context, r domain.PRRow) error { s.writeMu.Lock() From 217f6b1652585b37b1056fb6f3fcc804226682ac Mon Sep 17 00:00:00 2001 From: whoisasx Date: Mon, 1 Jun 2026 01:17:34 +0530 Subject: [PATCH 089/250] feat: add notifier delivery runtime --- backend/internal/cdc/event.go | 16 +- backend/internal/config/config.go | 72 +++ backend/internal/config/config_test.go | 14 + backend/internal/domain/notification.go | 1 + .../integration/notification_runtime_test.go | 82 ++++ backend/internal/notification/delivery.go | 115 +++++ backend/internal/notification/dispatcher.go | 36 ++ .../internal/notification/dispatcher_test.go | 135 ++++++ backend/internal/notification/enqueuer.go | 10 +- backend/internal/notification/manager.go | 146 ++++++ backend/internal/notification/retry.go | 104 +++++ backend/internal/notification/retry_test.go | 54 +++ backend/internal/notification/routing.go | 72 +++ backend/internal/notification/routing_test.go | 87 ++++ backend/internal/notification/settings.go | 122 +++++ .../internal/notification/settings_test.go | 45 ++ backend/internal/notification/store.go | 24 + backend/internal/storage/sqlite/gen/models.go | 37 ++ .../sqlite/gen/notification_deliveries.sql.go | 256 +++++++++++ .../storage/sqlite/gen/notifications.sql.go | 30 +- .../internal/storage/sqlite/gen/querier.go | 5 + .../0003_notification_deliveries.sql | 119 +++++ .../sqlite/notification_delivery_store.go | 417 ++++++++++++++++++ .../notification_delivery_store_test.go | 253 +++++++++++ .../storage/sqlite/notification_store.go | 3 + .../queries/notification_deliveries.sql | 46 ++ .../storage/sqlite/queries/notifications.sql | 20 +- backend/notifier_wiring.go | 28 ++ 28 files changed, 2317 insertions(+), 32 deletions(-) create mode 100644 backend/internal/integration/notification_runtime_test.go create mode 100644 backend/internal/notification/delivery.go create mode 100644 backend/internal/notification/dispatcher.go create mode 100644 backend/internal/notification/dispatcher_test.go create mode 100644 backend/internal/notification/manager.go create mode 100644 backend/internal/notification/retry.go create mode 100644 backend/internal/notification/retry_test.go create mode 100644 backend/internal/notification/routing.go create mode 100644 backend/internal/notification/routing_test.go create mode 100644 backend/internal/notification/settings.go create mode 100644 backend/internal/notification/settings_test.go create mode 100644 backend/internal/notification/store.go create mode 100644 backend/internal/storage/sqlite/gen/notification_deliveries.sql.go create mode 100644 backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql create mode 100644 backend/internal/storage/sqlite/notification_delivery_store.go create mode 100644 backend/internal/storage/sqlite/notification_delivery_store_test.go create mode 100644 backend/internal/storage/sqlite/queries/notification_deliveries.sql create mode 100644 backend/notifier_wiring.go diff --git a/backend/internal/cdc/event.go b/backend/internal/cdc/event.go index 5d37f47e26..35576cf6d1 100644 --- a/backend/internal/cdc/event.go +++ b/backend/internal/cdc/event.go @@ -18,13 +18,15 @@ import ( type EventType string const ( - EventSessionCreated EventType = "session_created" - EventSessionUpdated EventType = "session_updated" - EventPRCreated EventType = "pr_created" - EventPRUpdated EventType = "pr_updated" - EventPRCheckRecorded EventType = "pr_check_recorded" - EventNotificationCreated EventType = "notification_created" - EventNotificationUpdated EventType = "notification_updated" + EventSessionCreated EventType = "session_created" + EventSessionUpdated EventType = "session_updated" + EventPRCreated EventType = "pr_created" + EventPRUpdated EventType = "pr_updated" + EventPRCheckRecorded EventType = "pr_check_recorded" + EventNotificationCreated EventType = "notification_created" + EventNotificationUpdated EventType = "notification_updated" + EventNotificationDeliveryCreated EventType = "notification_delivery_created" + EventNotificationDeliveryUpdated EventType = "notification_delivery_updated" ) // Event is one CDC change read from change_log. Seq is the monotonic ordering + diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 719e75244f..62eb41a2e1 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -11,6 +11,8 @@ import ( "path/filepath" "strconv" "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( @@ -50,6 +52,46 @@ type Config struct { // DataDir is the directory holding durable state (the SQLite database and // the CDC JSONL log). It is created on first use by the storage layer. DataDir string + // Notifications controls the central notifier runtime. The dashboard is the + // durable notifications table itself; desktop delivery is handed off to the + // AO Electron app via notification_deliveries rows. + Notifications NotificationConfig +} + +// NotificationConfig contains the global notification settings used by the +// central notifier runtime. It intentionally starts global (not per-project) so +// the routing model can grow without changing lifecycle reactions. +type NotificationConfig struct { + Enabled bool + Dashboard DashboardNotificationConfig + Desktop DesktopNotificationConfig + Routing NotificationRoutingConfig + Retry NotificationRetryConfig +} + +type DashboardNotificationConfig struct { + Enabled bool + Limit int +} + +type DesktopNotificationConfig struct { + Enabled bool + Priorities []ports.Priority + SoundPriorities []ports.Priority +} + +type NotificationRoutingConfig struct { + // Priorities maps notification priority to built-in route names. The + // notifier currently implements dashboard and desktop only. + Priorities map[ports.Priority][]string +} + +type NotificationRetryConfig struct { + MaxAttempts int + BaseDelay time.Duration + MaxDelay time.Duration + LeaseTTL time.Duration + BatchSize int } // Addr returns the host:port the HTTP server binds. It uses net.JoinHostPort so @@ -77,6 +119,7 @@ func Load() (Config, error) { Port: DefaultPort, RequestTimeout: DefaultRequestTimeout, ShutdownTimeout: DefaultShutdownTimeout, + Notifications: DefaultNotificationConfig(), } if raw := os.Getenv("AO_PORT"); raw != "" { @@ -121,6 +164,35 @@ func Load() (Config, error) { return cfg, nil } +// DefaultNotificationConfig returns the safe zero-setup notification settings. +func DefaultNotificationConfig() NotificationConfig { + return NotificationConfig{ + Enabled: true, + Dashboard: DashboardNotificationConfig{ + Enabled: true, + Limit: 50, + }, + Desktop: DesktopNotificationConfig{ + Enabled: true, + Priorities: []ports.Priority{ports.PriorityUrgent, ports.PriorityAction}, + SoundPriorities: []ports.Priority{ports.PriorityUrgent}, + }, + Routing: NotificationRoutingConfig{Priorities: map[ports.Priority][]string{ + ports.PriorityUrgent: []string{"dashboard", "desktop"}, + ports.PriorityAction: []string{"dashboard", "desktop"}, + ports.PriorityWarning: []string{"dashboard"}, + ports.PriorityInfo: []string{"dashboard"}, + }}, + Retry: NotificationRetryConfig{ + MaxAttempts: 5, + BaseDelay: time.Second, + MaxDelay: 5 * time.Minute, + LeaseTTL: 30 * time.Second, + BatchSize: 50, + }, + } +} + // parsePositiveDuration rejects zero and negative durations: a zero // RequestTimeout would expire every request instantly, and a non-positive // ShutdownTimeout would defeat graceful shutdown. diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index dfcb5b8af7..88f0d927b7 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -3,6 +3,8 @@ package config import ( "testing" "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) func TestLoadDefaults(t *testing.T) { @@ -31,6 +33,18 @@ func TestLoadDefaults(t *testing.T) { if cfg.RunFilePath == "" { t.Error("RunFilePath is empty, want a resolved default path") } + if !cfg.Notifications.Enabled || !cfg.Notifications.Dashboard.Enabled || !cfg.Notifications.Desktop.Enabled { + t.Fatalf("notification defaults should be enabled: %+v", cfg.Notifications) + } + if cfg.Notifications.Dashboard.Limit != 50 { + t.Fatalf("dashboard limit = %d, want 50", cfg.Notifications.Dashboard.Limit) + } + if got := cfg.Notifications.Routing.Priorities[ports.PriorityUrgent]; len(got) != 2 || got[0] != "dashboard" || got[1] != "desktop" { + t.Fatalf("urgent routes = %v, want dashboard+desktop", got) + } + if cfg.Notifications.Retry.MaxAttempts != 5 || cfg.Notifications.Retry.LeaseTTL != 30*time.Second { + t.Fatalf("retry defaults = %+v", cfg.Notifications.Retry) + } } func TestLoadOverrides(t *testing.T) { diff --git a/backend/internal/domain/notification.go b/backend/internal/domain/notification.go index 8c64c9bcde..8af4955019 100644 --- a/backend/internal/domain/notification.go +++ b/backend/internal/domain/notification.go @@ -27,6 +27,7 @@ type Notification struct { CauseKey string ReadAt time.Time ArchivedAt time.Time + RoutedAt time.Time CreatedAt time.Time UpdatedAt time.Time } diff --git a/backend/internal/integration/notification_runtime_test.go b/backend/internal/integration/notification_runtime_test.go new file mode 100644 index 0000000000..c837bf293d --- /dev/null +++ b/backend/internal/integration/notification_runtime_test.go @@ -0,0 +1,82 @@ +package integration + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/notification" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +func TestNotificationRuntimeRoutesDesktopEligiblePriorities(t *testing.T) { + t.Parallel() + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer store.Close() + seedProject(t, store, "mer") + rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-55", "feat/notifier")) + if err != nil { + t.Fatal(err) + } + + urgent := enqueueRuntimeNotification(t, store, rec, "urgent", "urgent") + action := enqueueRuntimeNotification(t, store, rec, "action", "action") + info := enqueueRuntimeNotification(t, store, rec, "info", "info") + + mgr := notification.NewManager(store, notification.StaticSettings(config.DefaultNotificationConfig()), slog.New(slog.NewTextHandler(io.Discard, nil))) + routed, err := mgr.RoutePending(ctx, 50) + if err != nil { + t.Fatal(err) + } + if routed != 3 { + t.Fatalf("routed = %d, want 3", routed) + } + + for _, ntf := range []domain.Notification{urgent, action} { + rows, err := store.ListDeliveries(ctx, sqlite.DeliveryFilter{NotificationID: string(ntf.ID), Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Sink != notification.SinkAOApp || rows[0].RouteName != notification.RouteDesktop { + t.Fatalf("%s should have one AO-app desktop delivery, got %+v", ntf.Priority, rows) + } + } + rows, err := store.ListDeliveries(ctx, sqlite.DeliveryFilter{NotificationID: string(info.ID), Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatalf("info should remain dashboard/read-model only, got deliveries %+v", rows) + } +} + +func enqueueRuntimeNotification(t *testing.T, store *sqlite.Store, rec domain.SessionRecord, priority, dedupe string) domain.Notification { + t.Helper() + now := time.Now().UTC().Truncate(time.Second) + row, _, err := store.EnqueueNotification(context.Background(), domain.Notification{ + ProjectID: rec.ProjectID, + SessionID: rec.ID, + Source: "lifecycle", + EventType: "reaction.test", + SemanticType: "test." + priority, + Priority: priority, + Message: "test " + priority, + Payload: json.RawMessage(`{}`), + DedupeKey: "runtime-" + dedupe, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatalf("enqueue notification: %v", err) + } + return row +} diff --git a/backend/internal/notification/delivery.go b/backend/internal/notification/delivery.go new file mode 100644 index 0000000000..e33c85e700 --- /dev/null +++ b/backend/internal/notification/delivery.go @@ -0,0 +1,115 @@ +package notification + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +const ( + RouteDashboard = "dashboard" + RouteDesktop = "desktop" + + SinkAOApp = "ao-app" + SinkUnknown = "unknown" +) + +type DeliveryStatus string + +const ( + DeliveryQueued DeliveryStatus = "queued" + DeliveryLeased DeliveryStatus = "leased" + DeliverySent DeliveryStatus = "sent" + DeliveryRetryWait DeliveryStatus = "retry_wait" + DeliveryFailed DeliveryStatus = "failed" + DeliverySkipped DeliveryStatus = "skipped" + DeliveryCancelled DeliveryStatus = "cancelled" +) + +// DeliveryRow is the durable handoff state for one notification route. The +// backend creates AO-app rows; Electron claims them later and reports success or +// failure. External sinks can use the same shape in future issues. +type DeliveryRow struct { + ID string + NotificationID domain.NotificationID + NotificationSeq int64 + ProjectID domain.ProjectID + SessionID domain.SessionID + + RouteName string + Sink string + DestinationKey string + RequestJSON json.RawMessage + + Status DeliveryStatus + Attempts int + MaxAttempts int + NextAttemptAt time.Time + LeaseOwner string + // LeaseExpiresAt is zero when the row is not leased. + LeaseExpiresAt time.Time + + LastErrorCode string + LastError string + ExternalID string + + CreatedAt time.Time + UpdatedAt time.Time + DeliveredAt time.Time +} + +func NewDeliveryID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("generate delivery id: %w", err) + } + return "del_" + hex.EncodeToString(b[:]), nil +} + +func NormalizeDelivery(row DeliveryRow, now time.Time, maxAttempts int) (DeliveryRow, error) { + if row.ID == "" { + id, err := NewDeliveryID() + if err != nil { + return DeliveryRow{}, err + } + row.ID = id + } + if len(row.RequestJSON) == 0 { + row.RequestJSON = json.RawMessage(`{}`) + } + if !json.Valid(row.RequestJSON) { + return DeliveryRow{}, fmt.Errorf("invalid delivery request JSON for %s", row.ID) + } + if row.Status == "" { + row.Status = DeliveryQueued + } + if row.MaxAttempts <= 0 { + row.MaxAttempts = maxAttempts + } + if row.MaxAttempts <= 0 { + row.MaxAttempts = 1 + } + if row.NextAttemptAt.IsZero() { + row.NextAttemptAt = now + } + if row.CreatedAt.IsZero() { + row.CreatedAt = now + } + if row.UpdatedAt.IsZero() { + row.UpdatedAt = row.CreatedAt + } + return row, nil +} + +func TerminalStatus(s DeliveryStatus) bool { + switch s { + case DeliverySent, DeliveryFailed, DeliverySkipped, DeliveryCancelled: + return true + default: + return false + } +} diff --git a/backend/internal/notification/dispatcher.go b/backend/internal/notification/dispatcher.go new file mode 100644 index 0000000000..19d0cbf554 --- /dev/null +++ b/backend/internal/notification/dispatcher.go @@ -0,0 +1,36 @@ +package notification + +import ( + "context" + "time" +) + +func startDispatcher(ctx context.Context, m *Manager) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + runDispatcherOnce(ctx, m) + + interval := m.interval + if interval <= 0 { + interval = time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runDispatcherOnce(ctx, m) + } + } + }() + return done +} + +func runDispatcherOnce(ctx context.Context, m *Manager) { + if err := m.RunOnce(ctx); err != nil { + m.logger.ErrorContext(ctx, "notification dispatcher tick", "err", err) + } +} diff --git a/backend/internal/notification/dispatcher_test.go b/backend/internal/notification/dispatcher_test.go new file mode 100644 index 0000000000..9a9b0fc064 --- /dev/null +++ b/backend/internal/notification/dispatcher_test.go @@ -0,0 +1,135 @@ +package notification + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +type fakeRuntimeStore struct { + mu sync.Mutex + unrouted []domain.Notification + deliveries []DeliveryRow + routed []domain.NotificationID + releases int + failEnqueue map[domain.NotificationID]error +} + +func (f *fakeRuntimeStore) ListUnroutedNotifications(context.Context, int) ([]domain.Notification, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]domain.Notification(nil), f.unrouted...), nil +} + +func (f *fakeRuntimeStore) MarkNotificationRouted(_ context.Context, id domain.NotificationID, _ time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + f.routed = append(f.routed, id) + return nil +} + +func (f *fakeRuntimeStore) EnqueueDelivery(_ context.Context, row DeliveryRow) (DeliveryRow, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.failEnqueue[row.NotificationID]; err != nil { + return DeliveryRow{}, false, err + } + f.deliveries = append(f.deliveries, row) + return row, true, nil +} + +func (f *fakeRuntimeStore) ClaimDueDeliveries(context.Context, string, string, time.Time, int, time.Duration) ([]DeliveryRow, error) { + return nil, nil +} +func (f *fakeRuntimeStore) ReleaseExpiredDeliveryLeases(context.Context, time.Time) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.releases++ + return 0, nil +} +func (f *fakeRuntimeStore) MarkDeliverySent(context.Context, string, string, time.Time) error { + return nil +} +func (f *fakeRuntimeStore) MarkDeliveryRetry(context.Context, string, string, string, time.Time) error { + return nil +} +func (f *fakeRuntimeStore) MarkDeliveryFailed(context.Context, string, string, string, time.Time) error { + return nil +} +func (f *fakeRuntimeStore) MarkDeliverySkipped(context.Context, string, string, time.Time) error { + return nil +} + +func TestDispatcherStartReleasesAndStops(t *testing.T) { + store := &fakeRuntimeStore{} + mgr := NewManager(store, StaticSettings(config.DefaultNotificationConfig()), discardLogger()) + mgr.interval = 10 * time.Millisecond + ctx, cancel := context.WithCancel(context.Background()) + done := mgr.Start(ctx) + + deadline := time.After(time.Second) + for { + store.mu.Lock() + released := store.releases + store.mu.Unlock() + if released > 0 { + break + } + select { + case <-deadline: + t.Fatal("dispatcher did not run initial release") + case <-time.After(time.Millisecond): + } + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("dispatcher did not stop after context cancel") + } +} + +func TestRoutePendingDeliveryFailureDoesNotBlockOtherNotifications(t *testing.T) { + n1 := sampleDomainNotification("ntf_1", "urgent") + n2 := sampleDomainNotification("ntf_2", "urgent") + store := &fakeRuntimeStore{ + unrouted: []domain.Notification{n1, n2}, + failEnqueue: map[domain.NotificationID]error{n1.ID: errors.New("boom")}, + } + mgr := NewManager(store, StaticSettings(config.DefaultNotificationConfig()), discardLogger()) + + routed, err := mgr.RoutePending(context.Background(), 10) + if err == nil { + t.Fatal("RoutePending should return the first routing error") + } + if routed != 1 { + t.Fatalf("routed = %d, want one successful notification", routed) + } + store.mu.Lock() + defer store.mu.Unlock() + if len(store.routed) != 1 || store.routed[0] != n2.ID { + t.Fatalf("routed IDs = %v, want only %s", store.routed, n2.ID) + } +} + +func sampleDomainNotification(id domain.NotificationID, priority string) domain.Notification { + return domain.Notification{ + Seq: 1, + ID: id, + ProjectID: "ao", + SessionID: "ao-1", + Priority: priority, + Message: "hello", + } +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} diff --git a/backend/internal/notification/enqueuer.go b/backend/internal/notification/enqueuer.go index 79e902bf84..5953830537 100644 --- a/backend/internal/notification/enqueuer.go +++ b/backend/internal/notification/enqueuer.go @@ -8,23 +8,23 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// Store is the durable write-side used by the enqueuer. *sqlite.Store satisfies -// this interface. -type Store interface { +// EnqueueStore is the durable write-side used by the enqueuer. *sqlite.Store +// satisfies this interface. +type EnqueueStore interface { EnqueueNotification(ctx context.Context, row domain.Notification) (domain.Notification, bool, error) } // Enqueuer is a store-backed ports.Notifier. It does not deliver to external // sinks; it renders and persists the notification for later dashboard/app sinks. type Enqueuer struct { - store Store + store EnqueueStore renderer *Renderer logger *slog.Logger } var _ ports.Notifier = (*Enqueuer)(nil) -func NewEnqueuer(store Store, renderer *Renderer, logger *slog.Logger) *Enqueuer { +func NewEnqueuer(store EnqueueStore, renderer *Renderer, logger *slog.Logger) *Enqueuer { if logger == nil { logger = slog.Default() } diff --git a/backend/internal/notification/manager.go b/backend/internal/notification/manager.go new file mode 100644 index 0000000000..ea505ff8ca --- /dev/null +++ b/backend/internal/notification/manager.go @@ -0,0 +1,146 @@ +package notification + +import ( + "context" + "errors" + "log/slog" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type Manager struct { + store Store + settings SettingsProvider + clock func() time.Time + logger *slog.Logger + + interval time.Duration +} + +func NewManager(store Store, settings SettingsProvider, logger *slog.Logger) *Manager { + if logger == nil { + logger = slog.Default() + } + if settings == nil { + settings = StaticSettings(config.DefaultNotificationConfig()) + } + return &Manager{ + store: store, + settings: settings, + clock: time.Now, + logger: logger, + interval: time.Second, + } +} + +func (m *Manager) Start(ctx context.Context) <-chan struct{} { + return startDispatcher(ctx, m) +} + +// RunOnce performs one maintenance/routing pass. It is exposed for tests and +// for future API-triggered nudges; Start calls it on every dispatcher tick. +func (m *Manager) RunOnce(ctx context.Context) error { + settings := m.settings.Settings(ctx) + policy := RetryPolicyFromConfig(settings.Retry) + now := m.clock().UTC() + + if released, err := m.store.ReleaseExpiredDeliveryLeases(ctx, now); err != nil { + return err + } else if released > 0 { + m.logger.DebugContext(ctx, "notification delivery leases released", "count", released) + } + + _, err := m.RoutePending(ctx, policy.BatchSize) + return err +} + +func (m *Manager) RoutePending(ctx context.Context, limit int) (int, error) { + if limit <= 0 { + limit = RetryPolicyFromConfig(m.settings.Settings(ctx).Retry).BatchSize + } + rows, err := m.store.ListUnroutedNotifications(ctx, limit) + if err != nil { + return 0, err + } + var firstErr error + var routed int + for _, row := range rows { + if err := m.RouteNotification(ctx, row); err != nil { + m.logger.ErrorContext(ctx, "route notification", "notification", row.ID, "err", err) + if firstErr == nil { + firstErr = err + } else { + firstErr = errors.Join(firstErr, err) + } + continue + } + routed++ + } + return routed, firstErr +} + +func (m *Manager) RouteNotification(ctx context.Context, row domain.Notification) error { + settings := m.settings.Settings(ctx) + now := m.clock().UTC() + if !settings.Enabled || !row.ArchivedAt.IsZero() { + return m.store.MarkNotificationRouted(ctx, row.ID, now) + } + + decisions := ResolveRoutes(settings, ports.Priority(row.Priority)) + maxAttempts := RetryPolicyFromConfig(settings.Retry).MaxAttempts + for _, decision := range decisions { + if !decision.CreateDelivery { + continue + } + delivery := DeliveryRow{ + NotificationID: row.ID, + NotificationSeq: row.Seq, + ProjectID: row.ProjectID, + SessionID: row.SessionID, + RouteName: decision.RouteName, + Sink: decision.Sink, + DestinationKey: decision.DestinationKey, + Status: decision.Status, + MaxAttempts: maxAttempts, + NextAttemptAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if delivery.Status == "" { + delivery.Status = DeliveryQueued + } + if decision.Reason != "" { + delivery.LastErrorCode = "route_skipped" + delivery.LastError = decision.Reason + } + if _, _, err := m.store.EnqueueDelivery(ctx, delivery); err != nil { + return err + } + } + return m.store.MarkNotificationRouted(ctx, row.ID, now) +} + +func (m *Manager) ClaimDesktopDeliveries(ctx context.Context, owner string, limit int) ([]DeliveryRow, error) { + settings := m.settings.Settings(ctx) + policy := RetryPolicyFromConfig(settings.Retry) + return m.store.ClaimDueDeliveries(ctx, SinkAOApp, owner, m.clock().UTC(), limit, policy.LeaseTTL) +} + +func (m *Manager) MarkDeliverySent(ctx context.Context, id, externalID string) error { + return m.store.MarkDeliverySent(ctx, id, externalID, m.clock().UTC()) +} + +func (m *Manager) MarkDeliveryError(ctx context.Context, id, code, message string) error { + settings := m.settings.Settings(ctx) + policy := RetryPolicyFromConfig(settings.Retry) + // The store is the source of truth for attempts/max-attempt terminal + // handling. Permanent classification short-circuits to failed; otherwise we + // provide the next retry timestamp for retry_wait rows. + if ClassifyError(code) == ErrorPermanent { + return m.store.MarkDeliveryFailed(ctx, id, code, message, m.clock().UTC()) + } + return m.store.MarkDeliveryRetry(ctx, id, code, message, policy.NextAttemptAt(m.clock().UTC(), 1)) +} diff --git a/backend/internal/notification/retry.go b/backend/internal/notification/retry.go new file mode 100644 index 0000000000..cf62798c73 --- /dev/null +++ b/backend/internal/notification/retry.go @@ -0,0 +1,104 @@ +package notification + +import ( + "math" + "math/rand" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" +) + +const retryJitterFraction = 0.20 + +type RetryPolicy struct { + MaxAttempts int + BaseDelay time.Duration + MaxDelay time.Duration + LeaseTTL time.Duration + BatchSize int + Jitter float64 + RandFloat64 func() float64 +} + +func RetryPolicyFromConfig(cfg config.NotificationRetryConfig) RetryPolicy { + settings := NormalizeSettings(config.NotificationConfig{Enabled: true, Retry: cfg}) + return RetryPolicy{ + MaxAttempts: settings.Retry.MaxAttempts, + BaseDelay: settings.Retry.BaseDelay, + MaxDelay: settings.Retry.MaxDelay, + LeaseTTL: settings.Retry.LeaseTTL, + BatchSize: settings.Retry.BatchSize, + Jitter: retryJitterFraction, + RandFloat64: rand.Float64, + } +} + +func (p RetryPolicy) normalized() RetryPolicy { + cfg := config.NotificationRetryConfig{ + MaxAttempts: p.MaxAttempts, + BaseDelay: p.BaseDelay, + MaxDelay: p.MaxDelay, + LeaseTTL: p.LeaseTTL, + BatchSize: p.BatchSize, + } + out := RetryPolicyFromConfig(cfg) + if p.Jitter != 0 { + out.Jitter = p.Jitter + } + if p.RandFloat64 != nil { + out.RandFloat64 = p.RandFloat64 + } + return out +} + +// BackoffDelay returns exponential backoff for the already-recorded attempt +// count. attempt=1 returns the base delay; delays are capped before jitter. +func (p RetryPolicy) BackoffDelay(attempt int) time.Duration { + p = p.normalized() + if attempt < 1 { + attempt = 1 + } + mult := math.Pow(2, float64(attempt-1)) + delay := time.Duration(float64(p.BaseDelay) * mult) + if delay > p.MaxDelay || delay <= 0 { + delay = p.MaxDelay + } + if p.Jitter <= 0 { + return delay + } + randFloat := p.RandFloat64 + if randFloat == nil { + randFloat = rand.Float64 + } + // rand in [0,1) -> factor in [1-jitter, 1+jitter) + factor := 1 - p.Jitter + (2 * p.Jitter * randFloat()) + return time.Duration(float64(delay) * factor) +} + +func (p RetryPolicy) NextAttemptAt(now time.Time, attempt int) time.Time { + return now.Add(p.BackoffDelay(attempt)) +} + +type ErrorClass string + +const ( + ErrorTransient ErrorClass = "transient" + ErrorPermanent ErrorClass = "permanent" +) + +func ClassifyError(code string) ErrorClass { + switch strings.ToLower(strings.TrimSpace(code)) { + case "permanent", "invalid_request", "bad_request", "unauthorized", "forbidden", "not_found", "unsupported_route", "route_disabled": + return ErrorPermanent + default: + return ErrorTransient + } +} + +func ShouldRetry(code string, attempts, maxAttempts int) bool { + if maxAttempts <= 0 { + maxAttempts = 1 + } + return ClassifyError(code) != ErrorPermanent && attempts < maxAttempts +} diff --git a/backend/internal/notification/retry_test.go b/backend/internal/notification/retry_test.go new file mode 100644 index 0000000000..c6d37155c9 --- /dev/null +++ b/backend/internal/notification/retry_test.go @@ -0,0 +1,54 @@ +package notification + +import ( + "testing" + "time" +) + +func TestRetryBackoffExponentialCapped(t *testing.T) { + p := RetryPolicy{ + BaseDelay: time.Second, + MaxDelay: 5 * time.Second, + Jitter: retryJitterFraction, + RandFloat64: func() float64 { return 0.5 }, + } + if got := p.BackoffDelay(1); got != time.Second { + t.Fatalf("attempt 1 delay = %s, want 1s", got) + } + if got := p.BackoffDelay(2); got != 2*time.Second { + t.Fatalf("attempt 2 delay = %s, want 2s", got) + } + if got := p.BackoffDelay(4); got != 5*time.Second { + t.Fatalf("attempt 4 delay = %s, want capped 5s", got) + } +} + +func TestRetryJitterBounds(t *testing.T) { + base := RetryPolicy{BaseDelay: 10 * time.Second, MaxDelay: time.Minute, Jitter: retryJitterFraction} + low := base + low.RandFloat64 = func() float64 { return 0 } + high := base + high.RandFloat64 = func() float64 { return 1 } + + if got := low.BackoffDelay(1); got != 8*time.Second { + t.Fatalf("low jitter = %s, want 8s", got) + } + if got := high.BackoffDelay(1); got != 12*time.Second { + t.Fatalf("high jitter = %s, want 12s", got) + } +} + +func TestErrorClassificationRetry(t *testing.T) { + if ClassifyError("invalid_request") != ErrorPermanent { + t.Fatal("invalid_request should be permanent") + } + if ShouldRetry("invalid_request", 1, 5) { + t.Fatal("permanent errors should not retry") + } + if !ShouldRetry("timeout", 1, 5) { + t.Fatal("transient errors under max attempts should retry") + } + if ShouldRetry("timeout", 5, 5) { + t.Fatal("max attempts should stop retry") + } +} diff --git a/backend/internal/notification/routing.go b/backend/internal/notification/routing.go new file mode 100644 index 0000000000..36a13d9bd7 --- /dev/null +++ b/backend/internal/notification/routing.go @@ -0,0 +1,72 @@ +package notification + +import ( + "slices" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type RouteDecision struct { + RouteName string + Sink string + DestinationKey string + Status DeliveryStatus + Reason string + CreateDelivery bool +} + +// ResolveRoutes resolves the configured built-in routes for one notification. +// Dashboard is a read model over the notifications table, so it is represented +// in the decision list but never creates a delivery row. Unknown explicitly +// configured routes become skipped delivery rows for operator visibility. +func ResolveRoutes(settings config.NotificationConfig, priority ports.Priority) []RouteDecision { + settings = NormalizeSettings(settings) + if !settings.Enabled { + return nil + } + + routes, ok := settings.Routing.Priorities[priority] + if !ok { + return nil + } + out := make([]RouteDecision, 0, len(routes)) + for _, name := range routes { + switch name { + case RouteDashboard: + if settings.Dashboard.Enabled { + out = append(out, RouteDecision{RouteName: RouteDashboard}) + } + case RouteDesktop: + if settings.Desktop.Enabled && priorityAllowed(priority, settings.Desktop.Priorities) { + out = append(out, RouteDecision{ + RouteName: RouteDesktop, + Sink: SinkAOApp, + Status: DeliveryQueued, + CreateDelivery: true, + }) + } + case "": + // Ignore empty route names so a stray trailing separator in future + // config parsing does not create a permanent skipped delivery. + default: + out = append(out, RouteDecision{ + RouteName: name, + Sink: SinkUnknown, + Status: DeliverySkipped, + Reason: "unknown route", + CreateDelivery: true, + }) + } + } + return out +} + +func DesktopEligible(settings config.NotificationConfig, priority ports.Priority) bool { + settings = NormalizeSettings(settings) + return settings.Enabled && settings.Desktop.Enabled && priorityAllowed(priority, settings.Desktop.Priorities) +} + +func priorityAllowed(p ports.Priority, allowed []ports.Priority) bool { + return slices.Contains(allowed, p) +} diff --git a/backend/internal/notification/routing_test.go b/backend/internal/notification/routing_test.go new file mode 100644 index 0000000000..913da8c28d --- /dev/null +++ b/backend/internal/notification/routing_test.go @@ -0,0 +1,87 @@ +package notification + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestResolveRoutes_Defaults(t *testing.T) { + cfg := config.DefaultNotificationConfig() + tests := []struct { + priority ports.Priority + want []string + }{ + {ports.PriorityUrgent, []string{RouteDashboard, RouteDesktop}}, + {ports.PriorityAction, []string{RouteDashboard, RouteDesktop}}, + {ports.PriorityWarning, []string{RouteDashboard}}, + {ports.PriorityInfo, []string{RouteDashboard}}, + } + for _, tc := range tests { + t.Run(string(tc.priority), func(t *testing.T) { + got := routeNames(ResolveRoutes(cfg, tc.priority)) + if len(got) != len(tc.want) { + t.Fatalf("routes = %v, want %v", got, tc.want) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Fatalf("routes = %v, want %v", got, tc.want) + } + } + }) + } +} + +func TestResolveRoutes_DesktopDisabledOrIneligible(t *testing.T) { + cfg := config.DefaultNotificationConfig() + cfg.Desktop.Enabled = false + got := routeNames(ResolveRoutes(cfg, ports.PriorityUrgent)) + if len(got) != 1 || got[0] != RouteDashboard { + t.Fatalf("desktop disabled routes = %v, want dashboard only", got) + } + + cfg = config.DefaultNotificationConfig() + cfg.Routing.Priorities[ports.PriorityInfo] = []string{RouteDashboard, RouteDesktop} + got = routeNames(ResolveRoutes(cfg, ports.PriorityInfo)) + if len(got) != 1 || got[0] != RouteDashboard { + t.Fatalf("info desktop ineligible routes = %v, want dashboard only", got) + } +} + +func TestResolveRoutes_GlobalDisabled(t *testing.T) { + cfg := config.DefaultNotificationConfig() + cfg.Enabled = false + if got := ResolveRoutes(cfg, ports.PriorityUrgent); len(got) != 0 { + t.Fatalf("globally disabled routes = %+v, want none", got) + } +} + +func TestResolveRoutes_ExplicitEmptySuppressesPriority(t *testing.T) { + cfg := config.DefaultNotificationConfig() + cfg.Routing.Priorities[ports.PriorityUrgent] = []string{} + if got := ResolveRoutes(cfg, ports.PriorityUrgent); len(got) != 0 { + t.Fatalf("explicit empty routes = %+v, want none", got) + } +} + +func TestResolveRoutes_UnknownExplicitRouteSkipped(t *testing.T) { + cfg := config.DefaultNotificationConfig() + cfg.Routing.Priorities[ports.PriorityUrgent] = []string{RouteDashboard, "pager"} + got := ResolveRoutes(cfg, ports.PriorityUrgent) + if len(got) != 2 { + t.Fatalf("routes = %+v, want dashboard + skipped unknown", got) + } + unknown := got[1] + if unknown.RouteName != "pager" || unknown.Status != DeliverySkipped || !unknown.CreateDelivery || unknown.Sink != SinkUnknown { + t.Fatalf("unknown route decision = %+v", unknown) + } +} + +func routeNames(routes []RouteDecision) []string { + out := make([]string, len(routes)) + for i, r := range routes { + out[i] = r.RouteName + } + return out +} diff --git a/backend/internal/notification/settings.go b/backend/internal/notification/settings.go new file mode 100644 index 0000000000..623e4a7161 --- /dev/null +++ b/backend/internal/notification/settings.go @@ -0,0 +1,122 @@ +package notification + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type SettingsProvider interface { + Settings(ctx context.Context) config.NotificationConfig +} + +type staticSettings struct { + cfg config.NotificationConfig +} + +func SettingsFromConfig(cfg config.Config) SettingsProvider { + settings := cfg.Notifications + if isZeroNotificationConfig(settings) { + settings = config.DefaultNotificationConfig() + } + return staticSettings{cfg: NormalizeSettings(settings)} +} + +func StaticSettings(cfg config.NotificationConfig) SettingsProvider { + return staticSettings{cfg: NormalizeSettings(cfg)} +} + +func (s staticSettings) Settings(context.Context) config.NotificationConfig { + return cloneSettings(s.cfg) +} + +// NormalizeSettings fills unset settings with safe defaults while preserving +// explicit route overrides, including an explicit empty route list. +func NormalizeSettings(in config.NotificationConfig) config.NotificationConfig { + def := config.DefaultNotificationConfig() + if isZeroNotificationConfig(in) { + return cloneSettings(def) + } + out := in + + if isZeroDashboardConfig(in.Dashboard) { + out.Dashboard.Enabled = def.Dashboard.Enabled + } + if out.Dashboard.Limit == 0 { + out.Dashboard.Limit = def.Dashboard.Limit + } + if isZeroDesktopConfig(in.Desktop) { + out.Desktop.Enabled = def.Desktop.Enabled + } + if out.Desktop.Priorities == nil { + out.Desktop.Priorities = append([]ports.Priority(nil), def.Desktop.Priorities...) + } + if out.Desktop.SoundPriorities == nil { + out.Desktop.SoundPriorities = append([]ports.Priority(nil), def.Desktop.SoundPriorities...) + } + if out.Routing.Priorities == nil { + out.Routing.Priorities = cloneRoutes(def.Routing.Priorities) + } else { + merged := cloneRoutes(def.Routing.Priorities) + for p, routes := range out.Routing.Priorities { + merged[p] = append([]string(nil), routes...) + } + out.Routing.Priorities = merged + } + if out.Retry.MaxAttempts == 0 { + out.Retry.MaxAttempts = def.Retry.MaxAttempts + } + if out.Retry.BaseDelay == 0 { + out.Retry.BaseDelay = def.Retry.BaseDelay + } + if out.Retry.MaxDelay == 0 { + out.Retry.MaxDelay = def.Retry.MaxDelay + } + if out.Retry.LeaseTTL == 0 { + out.Retry.LeaseTTL = def.Retry.LeaseTTL + } + if out.Retry.BatchSize == 0 { + out.Retry.BatchSize = def.Retry.BatchSize + } + return cloneSettings(out) +} + +func cloneSettings(in config.NotificationConfig) config.NotificationConfig { + out := in + out.Desktop.Priorities = append([]ports.Priority(nil), in.Desktop.Priorities...) + out.Desktop.SoundPriorities = append([]ports.Priority(nil), in.Desktop.SoundPriorities...) + out.Routing.Priorities = cloneRoutes(in.Routing.Priorities) + return out +} + +func cloneRoutes(in map[ports.Priority][]string) map[ports.Priority][]string { + if in == nil { + return nil + } + out := make(map[ports.Priority][]string, len(in)) + for p, routes := range in { + out[p] = append([]string(nil), routes...) + } + return out +} + +func isZeroNotificationConfig(c config.NotificationConfig) bool { + return !c.Enabled && + isZeroDashboardConfig(c.Dashboard) && + isZeroDesktopConfig(c.Desktop) && + c.Routing.Priorities == nil && + c.Retry.MaxAttempts == 0 && + c.Retry.BaseDelay == 0 && + c.Retry.MaxDelay == 0 && + c.Retry.LeaseTTL == 0 && + c.Retry.BatchSize == 0 +} + +func isZeroDashboardConfig(c config.DashboardNotificationConfig) bool { + return !c.Enabled && c.Limit == 0 +} + +func isZeroDesktopConfig(c config.DesktopNotificationConfig) bool { + return !c.Enabled && len(c.Priorities) == 0 && len(c.SoundPriorities) == 0 +} diff --git a/backend/internal/notification/settings_test.go b/backend/internal/notification/settings_test.go new file mode 100644 index 0000000000..f3a6884e37 --- /dev/null +++ b/backend/internal/notification/settings_test.go @@ -0,0 +1,45 @@ +package notification + +import ( + "context" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestSettingsFromConfigDefaultsWhenUnset(t *testing.T) { + got := SettingsFromConfig(config.Config{}).Settings(context.Background()) + if !got.Enabled || !got.Desktop.Enabled || !got.Dashboard.Enabled { + t.Fatalf("zero config should resolve safe enabled defaults: %+v", got) + } + if got.Retry.MaxAttempts != 5 || got.Retry.BatchSize != 50 { + t.Fatalf("retry defaults = %+v", got.Retry) + } +} + +func TestNormalizeSettingsPreservesExplicitEmptyRoute(t *testing.T) { + cfg := config.DefaultNotificationConfig() + cfg.Routing.Priorities[ports.PriorityUrgent] = []string{} + + got := StaticSettings(cfg).Settings(context.Background()) + if routes := got.Routing.Priorities[ports.PriorityUrgent]; len(routes) != 0 { + t.Fatalf("explicit empty urgent route should be preserved, got %v", routes) + } +} + +func TestSettingsProviderReturnsClone(t *testing.T) { + cfg := config.DefaultNotificationConfig() + provider := StaticSettings(cfg) + first := provider.Settings(context.Background()) + first.Desktop.Priorities[0] = ports.PriorityInfo + first.Routing.Priorities[ports.PriorityUrgent][0] = "mutated" + + second := provider.Settings(context.Background()) + if second.Desktop.Priorities[0] != ports.PriorityUrgent { + t.Fatalf("desktop priorities were mutated through clone: %v", second.Desktop.Priorities) + } + if second.Routing.Priorities[ports.PriorityUrgent][0] != RouteDashboard { + t.Fatalf("routes were mutated through clone: %v", second.Routing.Priorities[ports.PriorityUrgent]) + } +} diff --git a/backend/internal/notification/store.go b/backend/internal/notification/store.go new file mode 100644 index 0000000000..9be4aef593 --- /dev/null +++ b/backend/internal/notification/store.go @@ -0,0 +1,24 @@ +package notification + +import ( + "context" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// Store is the central notifier runtime's durable interface. The lifecycle +// enqueuer writes notification rows; this interface routes them into durable +// delivery rows and lets AO-app/API code claim and complete desktop handoffs. +type Store interface { + ListUnroutedNotifications(ctx context.Context, limit int) ([]domain.Notification, error) + MarkNotificationRouted(ctx context.Context, id domain.NotificationID, at time.Time) error + + EnqueueDelivery(ctx context.Context, row DeliveryRow) (DeliveryRow, bool, error) + ClaimDueDeliveries(ctx context.Context, sink string, owner string, now time.Time, limit int, lease time.Duration) ([]DeliveryRow, error) + ReleaseExpiredDeliveryLeases(ctx context.Context, now time.Time) (int, error) + MarkDeliverySent(ctx context.Context, id string, externalID string, at time.Time) error + MarkDeliveryRetry(ctx context.Context, id string, errCode string, errMessage string, next time.Time) error + MarkDeliveryFailed(ctx context.Context, id string, errCode string, errMessage string, at time.Time) error + MarkDeliverySkipped(ctx context.Context, id string, reason string, at time.Time) error +} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 992c0ca03b..2887a87ec9 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -36,6 +36,43 @@ type Notification struct { ArchivedAt sql.NullTime CreatedAt time.Time UpdatedAt time.Time + RoutedAt sql.NullTime +} + +type NotificationDelivery struct { + ID string + NotificationID string + NotificationSeq int64 + ProjectID string + SessionID string + RouteName string + Sink string + DestinationKey string + RequestJson string + Status string + Attempts int64 + MaxAttempts int64 + NextAttemptAt time.Time + LeaseOwner string + LeaseExpiresAt sql.NullTime + LastErrorCode string + LastError string + ExternalID string + CreatedAt time.Time + UpdatedAt time.Time + DeliveredAt sql.NullTime +} + +type NotificationDeliveryAttempt struct { + ID int64 + DeliveryID string + AttemptNo int64 + Status string + StartedAt time.Time + FinishedAt sql.NullTime + ErrorCode string + Error string + ResponseJson string } type Pr struct { diff --git a/backend/internal/storage/sqlite/gen/notification_deliveries.sql.go b/backend/internal/storage/sqlite/gen/notification_deliveries.sql.go new file mode 100644 index 0000000000..ed2821ee1b --- /dev/null +++ b/backend/internal/storage/sqlite/gen/notification_deliveries.sql.go @@ -0,0 +1,256 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: notification_deliveries.sql + +package gen + +import ( + "context" + "database/sql" + "time" +) + +const getNotificationDelivery = `-- name: GetNotificationDelivery :one +SELECT id, notification_id, notification_seq, project_id, session_id, + route_name, sink, destination_key, request_json, + status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, + last_error_code, last_error, external_id, + created_at, updated_at, delivered_at +FROM notification_deliveries +WHERE id = ? +` + +func (q *Queries) GetNotificationDelivery(ctx context.Context, id string) (NotificationDelivery, error) { + row := q.db.QueryRowContext(ctx, getNotificationDelivery, id) + var i NotificationDelivery + err := row.Scan( + &i.ID, + &i.NotificationID, + &i.NotificationSeq, + &i.ProjectID, + &i.SessionID, + &i.RouteName, + &i.Sink, + &i.DestinationKey, + &i.RequestJson, + &i.Status, + &i.Attempts, + &i.MaxAttempts, + &i.NextAttemptAt, + &i.LeaseOwner, + &i.LeaseExpiresAt, + &i.LastErrorCode, + &i.LastError, + &i.ExternalID, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeliveredAt, + ) + return i, err +} + +const getNotificationDeliveryByUnique = `-- name: GetNotificationDeliveryByUnique :one +SELECT id, notification_id, notification_seq, project_id, session_id, + route_name, sink, destination_key, request_json, + status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, + last_error_code, last_error, external_id, + created_at, updated_at, delivered_at +FROM notification_deliveries +WHERE notification_id = ? AND route_name = ? AND destination_key = ? +` + +type GetNotificationDeliveryByUniqueParams struct { + NotificationID string + RouteName string + DestinationKey string +} + +func (q *Queries) GetNotificationDeliveryByUnique(ctx context.Context, arg GetNotificationDeliveryByUniqueParams) (NotificationDelivery, error) { + row := q.db.QueryRowContext(ctx, getNotificationDeliveryByUnique, arg.NotificationID, arg.RouteName, arg.DestinationKey) + var i NotificationDelivery + err := row.Scan( + &i.ID, + &i.NotificationID, + &i.NotificationSeq, + &i.ProjectID, + &i.SessionID, + &i.RouteName, + &i.Sink, + &i.DestinationKey, + &i.RequestJson, + &i.Status, + &i.Attempts, + &i.MaxAttempts, + &i.NextAttemptAt, + &i.LeaseOwner, + &i.LeaseExpiresAt, + &i.LastErrorCode, + &i.LastError, + &i.ExternalID, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeliveredAt, + ) + return i, err +} + +const insertNotificationDelivery = `-- name: InsertNotificationDelivery :one +INSERT INTO notification_deliveries ( + id, notification_id, notification_seq, project_id, session_id, + route_name, sink, destination_key, request_json, + status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, + last_error_code, last_error, external_id, + created_at, updated_at, delivered_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(notification_id, route_name, destination_key) DO NOTHING +RETURNING id, notification_id, notification_seq, project_id, session_id, + route_name, sink, destination_key, request_json, + status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, + last_error_code, last_error, external_id, + created_at, updated_at, delivered_at +` + +type InsertNotificationDeliveryParams struct { + ID string + NotificationID string + NotificationSeq int64 + ProjectID string + SessionID string + RouteName string + Sink string + DestinationKey string + RequestJson string + Status string + Attempts int64 + MaxAttempts int64 + NextAttemptAt time.Time + LeaseOwner string + LeaseExpiresAt sql.NullTime + LastErrorCode string + LastError string + ExternalID string + CreatedAt time.Time + UpdatedAt time.Time + DeliveredAt sql.NullTime +} + +func (q *Queries) InsertNotificationDelivery(ctx context.Context, arg InsertNotificationDeliveryParams) (NotificationDelivery, error) { + row := q.db.QueryRowContext(ctx, insertNotificationDelivery, + arg.ID, + arg.NotificationID, + arg.NotificationSeq, + arg.ProjectID, + arg.SessionID, + arg.RouteName, + arg.Sink, + arg.DestinationKey, + arg.RequestJson, + arg.Status, + arg.Attempts, + arg.MaxAttempts, + arg.NextAttemptAt, + arg.LeaseOwner, + arg.LeaseExpiresAt, + arg.LastErrorCode, + arg.LastError, + arg.ExternalID, + arg.CreatedAt, + arg.UpdatedAt, + arg.DeliveredAt, + ) + var i NotificationDelivery + err := row.Scan( + &i.ID, + &i.NotificationID, + &i.NotificationSeq, + &i.ProjectID, + &i.SessionID, + &i.RouteName, + &i.Sink, + &i.DestinationKey, + &i.RequestJson, + &i.Status, + &i.Attempts, + &i.MaxAttempts, + &i.NextAttemptAt, + &i.LeaseOwner, + &i.LeaseExpiresAt, + &i.LastErrorCode, + &i.LastError, + &i.ExternalID, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeliveredAt, + ) + return i, err +} + +const listUnroutedNotifications = `-- name: ListUnroutedNotifications :many +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at +FROM notifications +WHERE routed_at IS NULL +ORDER BY seq ASC +LIMIT ? +` + +func (q *Queries) ListUnroutedNotifications(ctx context.Context, limit int64) ([]Notification, error) { + rows, err := q.db.QueryContext(ctx, listUnroutedNotifications, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Notification{} + for rows.Next() { + var i Notification + if err := rows.Scan( + &i.Seq, + &i.ID, + &i.ProjectID, + &i.SessionID, + &i.Source, + &i.EventType, + &i.SemanticType, + &i.Priority, + &i.Message, + &i.PayloadJson, + &i.ActionsJson, + &i.DedupeKey, + &i.CauseKey, + &i.ReadAt, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.RoutedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markNotificationRouted = `-- name: MarkNotificationRouted :exec +UPDATE notifications +SET routed_at = COALESCE(routed_at, ?), + updated_at = CASE WHEN routed_at IS NULL THEN ? ELSE updated_at END +WHERE id = ? +` + +type MarkNotificationRoutedParams struct { + RoutedAt sql.NullTime + UpdatedAt time.Time + ID string +} + +func (q *Queries) MarkNotificationRouted(ctx context.Context, arg MarkNotificationRoutedParams) error { + _, err := q.db.ExecContext(ctx, markNotificationRouted, arg.RoutedAt, arg.UpdatedAt, arg.ID) + return err +} diff --git a/backend/internal/storage/sqlite/gen/notifications.sql.go b/backend/internal/storage/sqlite/gen/notifications.sql.go index 7b2b5493d9..47ca63d9e8 100644 --- a/backend/internal/storage/sqlite/gen/notifications.sql.go +++ b/backend/internal/storage/sqlite/gen/notifications.sql.go @@ -16,7 +16,7 @@ UPDATE notifications SET archived_at = ?, updated_at = ? WHERE id = ? AND archived_at IS NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at ` type ArchiveNotificationParams struct { @@ -46,13 +46,14 @@ func (q *Queries) ArchiveNotification(ctx context.Context, arg ArchiveNotificati &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, + &i.RoutedAt, ) return i, err } const getNotification = `-- name: GetNotification :one SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications WHERE id = ? ` @@ -77,13 +78,14 @@ func (q *Queries) GetNotification(ctx context.Context, id string) (Notification, &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, + &i.RoutedAt, ) return i, err } const getNotificationByDedupeKey = `-- name: GetNotificationByDedupeKey :one SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications WHERE dedupe_key = ? ` @@ -108,6 +110,7 @@ func (q *Queries) GetNotificationByDedupeKey(ctx context.Context, dedupeKey stri &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, + &i.RoutedAt, ) return i, err } @@ -119,7 +122,7 @@ INSERT INTO notifications ( ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dedupe_key) DO NOTHING RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at ` type InsertNotificationParams struct { @@ -173,13 +176,14 @@ func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotification &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, + &i.RoutedAt, ) return i, err } const listNotifications = `-- name: ListNotifications :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications ORDER BY seq DESC LIMIT ? @@ -212,6 +216,7 @@ func (q *Queries) ListNotifications(ctx context.Context, limit int64) ([]Notific &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, + &i.RoutedAt, ); err != nil { return nil, err } @@ -228,7 +233,7 @@ func (q *Queries) ListNotifications(ctx context.Context, limit int64) ([]Notific const listNotificationsByProject = `-- name: ListNotificationsByProject :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications WHERE project_id = ? ORDER BY seq DESC @@ -267,6 +272,7 @@ func (q *Queries) ListNotificationsByProject(ctx context.Context, arg ListNotifi &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, + &i.RoutedAt, ); err != nil { return nil, err } @@ -283,7 +289,7 @@ func (q *Queries) ListNotificationsByProject(ctx context.Context, arg ListNotifi const listNotificationsBySession = `-- name: ListNotificationsBySession :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications WHERE session_id = ? ORDER BY seq DESC @@ -322,6 +328,7 @@ func (q *Queries) ListNotificationsBySession(ctx context.Context, arg ListNotifi &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, + &i.RoutedAt, ); err != nil { return nil, err } @@ -338,7 +345,7 @@ func (q *Queries) ListNotificationsBySession(ctx context.Context, arg ListNotifi const listUnreadNotifications = `-- name: ListUnreadNotifications :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications WHERE read_at IS NULL AND archived_at IS NULL ORDER BY seq DESC @@ -372,6 +379,7 @@ func (q *Queries) ListUnreadNotifications(ctx context.Context, limit int64) ([]N &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, + &i.RoutedAt, ); err != nil { return nil, err } @@ -391,7 +399,7 @@ UPDATE notifications SET read_at = ?, updated_at = ? WHERE id = ? AND read_at IS NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at ` type MarkNotificationReadParams struct { @@ -421,6 +429,7 @@ func (q *Queries) MarkNotificationRead(ctx context.Context, arg MarkNotification &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, + &i.RoutedAt, ) return i, err } @@ -430,7 +439,7 @@ UPDATE notifications SET read_at = NULL, updated_at = ? WHERE id = ? AND read_at IS NOT NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at ` type MarkNotificationUnreadParams struct { @@ -459,6 +468,7 @@ func (q *Queries) MarkNotificationUnread(ctx context.Context, arg MarkNotificati &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, + &i.RoutedAt, ) return i, err } diff --git a/backend/internal/storage/sqlite/gen/querier.go b/backend/internal/storage/sqlite/gen/querier.go index 4f91a9d544..87550b12e9 100644 --- a/backend/internal/storage/sqlite/gen/querier.go +++ b/backend/internal/storage/sqlite/gen/querier.go @@ -16,10 +16,13 @@ type Querier interface { DeleteSession(ctx context.Context, id string) error GetNotification(ctx context.Context, id string) (Notification, error) GetNotificationByDedupeKey(ctx context.Context, dedupeKey string) (Notification, error) + GetNotificationDelivery(ctx context.Context, id string) (NotificationDelivery, error) + GetNotificationDeliveryByUnique(ctx context.Context, arg GetNotificationDeliveryByUniqueParams) (NotificationDelivery, error) GetPR(ctx context.Context, url string) (Pr, error) GetProject(ctx context.Context, id string) (Project, error) GetSession(ctx context.Context, id string) (Session, error) InsertNotification(ctx context.Context, arg InsertNotificationParams) (Notification, error) + InsertNotificationDelivery(ctx context.Context, arg InsertNotificationDeliveryParams) (NotificationDelivery, error) InsertSession(ctx context.Context, arg InsertSessionParams) error ListAllSessions(ctx context.Context) ([]Session, error) ListChecksByPR(ctx context.Context, prUrl string) ([]PrCheck, error) @@ -32,7 +35,9 @@ type Querier interface { ListRecentChecks(ctx context.Context, arg ListRecentChecksParams) ([]ListRecentChecksRow, error) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) ListUnreadNotifications(ctx context.Context, limit int64) ([]Notification, error) + ListUnroutedNotifications(ctx context.Context, limit int64) ([]Notification, error) MarkNotificationRead(ctx context.Context, arg MarkNotificationReadParams) (Notification, error) + MarkNotificationRouted(ctx context.Context, arg MarkNotificationRoutedParams) error MarkNotificationUnread(ctx context.Context, arg MarkNotificationUnreadParams) (Notification, error) MaxChangeLogSeq(ctx context.Context) (interface{}, error) NextSessionNum(ctx context.Context, projectID string) (int64, error) diff --git a/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql b/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql new file mode 100644 index 0000000000..5cf486887f --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql @@ -0,0 +1,119 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE notifications ADD COLUMN routed_at TIMESTAMP; + +CREATE TABLE notification_deliveries ( + id TEXT PRIMARY KEY, + notification_id TEXT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE, + notification_seq INTEGER NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id), + session_id TEXT NOT NULL REFERENCES sessions(id), + + route_name TEXT NOT NULL, + sink TEXT NOT NULL, + destination_key TEXT NOT NULL DEFAULT '', + request_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(request_json)), + + status TEXT NOT NULL CHECK (status IN ('queued','leased','sent','retry_wait','failed','skipped','cancelled')), + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 5, + next_attempt_at TIMESTAMP NOT NULL, + lease_owner TEXT NOT NULL DEFAULT '', + lease_expires_at TIMESTAMP, + + last_error_code TEXT NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + external_id TEXT NOT NULL DEFAULT '', + + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + delivered_at TIMESTAMP, + + UNIQUE(notification_id, route_name, destination_key) +); + +CREATE INDEX idx_notification_deliveries_due + ON notification_deliveries(status, next_attempt_at, lease_expires_at, created_at); + +CREATE INDEX idx_notification_deliveries_notification + ON notification_deliveries(notification_id, status); + +CREATE INDEX idx_notification_deliveries_project + ON notification_deliveries(project_id, created_at DESC); + +CREATE TABLE notification_delivery_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + delivery_id TEXT NOT NULL REFERENCES notification_deliveries(id) ON DELETE CASCADE, + attempt_no INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('started','sent','retryable_failed','failed')), + started_at TIMESTAMP NOT NULL, + finished_at TIMESTAMP, + error_code TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '', + response_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(response_json)), + UNIQUE(delivery_id, attempt_no) +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER notification_deliveries_cdc_insert +AFTER INSERT ON notification_deliveries +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + NEW.project_id, + NEW.session_id, + 'notification_delivery_created', + json_object( + 'id', NEW.id, + 'notificationId', NEW.notification_id, + 'routeName', NEW.route_name, + 'sink', NEW.sink, + 'status', NEW.status, + 'attempts', NEW.attempts, + 'lastErrorCode', NEW.last_error_code, + 'lastError', NEW.last_error + ), + NEW.created_at + ); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER notification_deliveries_cdc_update +AFTER UPDATE ON notification_deliveries +WHEN OLD.status <> NEW.status + OR OLD.attempts <> NEW.attempts + OR OLD.last_error_code <> NEW.last_error_code + OR OLD.last_error <> NEW.last_error + OR OLD.external_id <> NEW.external_id + OR OLD.delivered_at IS NOT NEW.delivered_at +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + NEW.project_id, + NEW.session_id, + 'notification_delivery_updated', + json_object( + 'id', NEW.id, + 'notificationId', NEW.notification_id, + 'routeName', NEW.route_name, + 'sink', NEW.sink, + 'status', NEW.status, + 'attempts', NEW.attempts, + 'lastErrorCode', NEW.last_error_code, + 'lastError', NEW.last_error + ), + NEW.updated_at + ); +END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TRIGGER IF EXISTS notification_deliveries_cdc_update; +DROP TRIGGER IF EXISTS notification_deliveries_cdc_insert; +DROP TABLE IF EXISTS notification_delivery_attempts; +DROP TABLE IF EXISTS notification_deliveries; +ALTER TABLE notifications DROP COLUMN routed_at; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/notification_delivery_store.go b/backend/internal/storage/sqlite/notification_delivery_store.go new file mode 100644 index 0000000000..18593b2406 --- /dev/null +++ b/backend/internal/storage/sqlite/notification_delivery_store.go @@ -0,0 +1,417 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/notification" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +const deliveryColumns = `id, notification_id, notification_seq, project_id, session_id, + route_name, sink, destination_key, request_json, + status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, + last_error_code, last_error, external_id, + created_at, updated_at, delivered_at` + +const defaultDeliveryLimit = 100 + +type DeliveryFilter struct { + NotificationID string + ProjectID string + Status notification.DeliveryStatus + Limit int +} + +func (s *Store) ListUnroutedNotifications(ctx context.Context, limit int) ([]domain.Notification, error) { + if limit <= 0 { + limit = defaultNotificationLimit + } + rows, err := s.qr.ListUnroutedNotifications(ctx, int64(limit)) + if err != nil { + return nil, fmt.Errorf("list unrouted notifications: %w", err) + } + return notificationsFromGen(rows) +} + +func (s *Store) MarkNotificationRouted(ctx context.Context, id domain.NotificationID, at time.Time) error { + if at.IsZero() { + at = time.Now().UTC() + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.qw.MarkNotificationRouted(ctx, gen.MarkNotificationRoutedParams{ + RoutedAt: nullTime(at), + UpdatedAt: at, + ID: string(id), + }); err != nil { + return fmt.Errorf("mark notification routed %s: %w", id, err) + } + return nil +} + +func (s *Store) EnqueueDelivery(ctx context.Context, row notification.DeliveryRow) (notification.DeliveryRow, bool, error) { + now := row.CreatedAt + if now.IsZero() { + now = time.Now().UTC() + } + row, err := notification.NormalizeDelivery(row, now, 5) + if err != nil { + return notification.DeliveryRow{}, false, err + } + + s.writeMu.Lock() + defer s.writeMu.Unlock() + + insert := `INSERT INTO notification_deliveries ( + id, notification_id, notification_seq, project_id, session_id, + route_name, sink, destination_key, request_json, + status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, + last_error_code, last_error, external_id, + created_at, updated_at, delivered_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(notification_id, route_name, destination_key) DO NOTHING +RETURNING ` + deliveryColumns + + got, err := scanDelivery(s.writeDB.QueryRowContext(ctx, insert, + row.ID, + string(row.NotificationID), + row.NotificationSeq, + string(row.ProjectID), + string(row.SessionID), + row.RouteName, + row.Sink, + row.DestinationKey, + string(row.RequestJSON), + string(row.Status), + row.Attempts, + row.MaxAttempts, + row.NextAttemptAt, + row.LeaseOwner, + nullTime(row.LeaseExpiresAt), + row.LastErrorCode, + row.LastError, + row.ExternalID, + row.CreatedAt, + row.UpdatedAt, + nullTime(row.DeliveredAt), + )) + if errors.Is(err, sql.ErrNoRows) { + existing, readErr := s.getDeliveryByUniqueLocked(ctx, row.NotificationID, row.RouteName, row.DestinationKey) + if readErr != nil { + return notification.DeliveryRow{}, false, readErr + } + return existing, false, nil + } + if err != nil { + return notification.DeliveryRow{}, false, fmt.Errorf("insert notification delivery: %w", err) + } + return got, true, nil +} + +func (s *Store) ClaimDueDeliveries(ctx context.Context, sink string, owner string, now time.Time, limit int, lease time.Duration) ([]notification.DeliveryRow, error) { + if now.IsZero() { + now = time.Now().UTC() + } + if limit <= 0 { + limit = defaultDeliveryLimit + } + if lease <= 0 { + lease = 30 * time.Second + } + expires := now.Add(lease) + + s.writeMu.Lock() + defer s.writeMu.Unlock() + + tx, err := s.writeDB.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin claim deliveries: %w", err) + } + defer tx.Rollback() + + rows, err := tx.QueryContext(ctx, `SELECT id +FROM notification_deliveries +WHERE sink = ? + AND status IN ('queued','retry_wait') + AND next_attempt_at <= ? + AND attempts < max_attempts +ORDER BY next_attempt_at ASC, created_at ASC, id ASC +LIMIT ?`, sink, now, limit) + if err != nil { + return nil, fmt.Errorf("select due deliveries: %w", err) + } + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + ids = append(ids, id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + + out := make([]notification.DeliveryRow, 0, len(ids)) + for _, id := range ids { + res, err := tx.ExecContext(ctx, `UPDATE notification_deliveries +SET status = 'leased', + lease_owner = ?, + lease_expires_at = ?, + updated_at = ? +WHERE id = ? + AND status IN ('queued','retry_wait') + AND next_attempt_at <= ? + AND attempts < max_attempts`, owner, expires, now, id, now) + if err != nil { + return nil, fmt.Errorf("lease delivery %s: %w", id, err) + } + changed, err := res.RowsAffected() + if err != nil { + return nil, err + } + if changed == 0 { + continue + } + row, err := scanDelivery(tx.QueryRowContext(ctx, `SELECT `+deliveryColumns+` FROM notification_deliveries WHERE id = ?`, id)) + if err != nil { + return nil, fmt.Errorf("read leased delivery %s: %w", id, err) + } + out = append(out, row) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit claim deliveries: %w", err) + } + return out, nil +} + +func (s *Store) ReleaseExpiredDeliveryLeases(ctx context.Context, now time.Time) (int, error) { + if now.IsZero() { + now = time.Now().UTC() + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + res, err := s.writeDB.ExecContext(ctx, `UPDATE notification_deliveries +SET attempts = attempts + 1, + status = CASE WHEN attempts + 1 >= max_attempts THEN 'failed' ELSE 'queued' END, + next_attempt_at = ?, + lease_owner = '', + lease_expires_at = NULL, + last_error_code = 'lease_expired', + last_error = 'delivery lease expired', + updated_at = ? +WHERE status = 'leased' + AND lease_expires_at IS NOT NULL + AND lease_expires_at <= ?`, now, now, now) + if err != nil { + return 0, fmt.Errorf("release expired delivery leases: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, err + } + return int(n), nil +} + +func (s *Store) MarkDeliverySent(ctx context.Context, id string, externalID string, at time.Time) error { + if at.IsZero() { + at = time.Now().UTC() + } + return s.updateDelivery(ctx, "mark delivery sent", `UPDATE notification_deliveries +SET status = 'sent', + attempts = attempts + 1, + lease_owner = '', + lease_expires_at = NULL, + external_id = ?, + delivered_at = ?, + updated_at = ? +WHERE id = ? AND status = 'leased'`, externalID, at, at, id) +} + +func (s *Store) MarkDeliveryRetry(ctx context.Context, id string, errCode string, errMessage string, next time.Time) error { + now := time.Now().UTC() + if next.IsZero() { + next = now + } + return s.updateDelivery(ctx, "mark delivery retry", `UPDATE notification_deliveries +SET attempts = attempts + 1, + status = CASE WHEN attempts + 1 >= max_attempts THEN 'failed' ELSE 'retry_wait' END, + next_attempt_at = ?, + lease_owner = '', + lease_expires_at = NULL, + last_error_code = ?, + last_error = ?, + updated_at = ? +WHERE id = ? AND status = 'leased'`, next, errCode, errMessage, now, id) +} + +func (s *Store) MarkDeliveryFailed(ctx context.Context, id string, errCode string, errMessage string, at time.Time) error { + if at.IsZero() { + at = time.Now().UTC() + } + return s.updateDelivery(ctx, "mark delivery failed", `UPDATE notification_deliveries +SET status = 'failed', + attempts = CASE WHEN status = 'leased' THEN attempts + 1 ELSE attempts END, + lease_owner = '', + lease_expires_at = NULL, + last_error_code = ?, + last_error = ?, + updated_at = ? +WHERE id = ? AND status NOT IN ('sent','failed','skipped','cancelled')`, errCode, errMessage, at, id) +} + +func (s *Store) MarkDeliverySkipped(ctx context.Context, id string, reason string, at time.Time) error { + if at.IsZero() { + at = time.Now().UTC() + } + return s.updateDelivery(ctx, "mark delivery skipped", `UPDATE notification_deliveries +SET status = 'skipped', + lease_owner = '', + lease_expires_at = NULL, + last_error_code = 'skipped', + last_error = ?, + updated_at = ? +WHERE id = ? AND status NOT IN ('sent','failed','skipped','cancelled')`, reason, at, id) +} + +func (s *Store) GetDelivery(ctx context.Context, id string) (notification.DeliveryRow, bool, error) { + row, err := scanDelivery(s.readDB.QueryRowContext(ctx, `SELECT `+deliveryColumns+` FROM notification_deliveries WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return notification.DeliveryRow{}, false, nil + } + if err != nil { + return notification.DeliveryRow{}, false, fmt.Errorf("get notification delivery %s: %w", id, err) + } + return row, true, nil +} + +func (s *Store) ListDeliveries(ctx context.Context, filter DeliveryFilter) ([]notification.DeliveryRow, error) { + limit := filter.Limit + if limit <= 0 { + limit = defaultDeliveryLimit + } + base := `SELECT ` + deliveryColumns + ` FROM notification_deliveries` + order := ` ORDER BY created_at ASC, id ASC LIMIT ?` + var ( + rows *sql.Rows + err error + ) + switch { + case filter.NotificationID != "": + if filter.Status != "" { + rows, err = s.readDB.QueryContext(ctx, base+` WHERE notification_id = ? AND status = ?`+order, filter.NotificationID, string(filter.Status), limit) + } else { + rows, err = s.readDB.QueryContext(ctx, base+` WHERE notification_id = ?`+order, filter.NotificationID, limit) + } + case filter.ProjectID != "": + if filter.Status != "" { + rows, err = s.readDB.QueryContext(ctx, base+` WHERE project_id = ? AND status = ?`+order, filter.ProjectID, string(filter.Status), limit) + } else { + rows, err = s.readDB.QueryContext(ctx, base+` WHERE project_id = ?`+order, filter.ProjectID, limit) + } + default: + if filter.Status != "" { + rows, err = s.readDB.QueryContext(ctx, base+` WHERE status = ?`+order, string(filter.Status), limit) + } else { + rows, err = s.readDB.QueryContext(ctx, base+order, limit) + } + } + if err != nil { + return nil, fmt.Errorf("list notification deliveries: %w", err) + } + defer rows.Close() + out := []notification.DeliveryRow{} + for rows.Next() { + row, err := scanDelivery(rows) + if err != nil { + return nil, err + } + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *Store) updateDelivery(ctx context.Context, what string, query string, args ...any) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + if _, err := s.writeDB.ExecContext(ctx, query, args...); err != nil { + return fmt.Errorf("%s: %w", what, err) + } + return nil +} + +func (s *Store) getDeliveryByUniqueLocked(ctx context.Context, id domain.NotificationID, routeName, destinationKey string) (notification.DeliveryRow, error) { + row, err := scanDelivery(s.writeDB.QueryRowContext(ctx, `SELECT `+deliveryColumns+` +FROM notification_deliveries +WHERE notification_id = ? AND route_name = ? AND destination_key = ?`, string(id), routeName, destinationKey)) + if err != nil { + return notification.DeliveryRow{}, fmt.Errorf("get notification delivery by unique key: %w", err) + } + return row, nil +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanDelivery(scanner rowScanner) (notification.DeliveryRow, error) { + var ( + row notification.DeliveryRow + notificationID string + projectID string + sessionID string + status string + requestJSON string + leaseExpires sql.NullTime + deliveredAt sql.NullTime + ) + if err := scanner.Scan( + &row.ID, + ¬ificationID, + &row.NotificationSeq, + &projectID, + &sessionID, + &row.RouteName, + &row.Sink, + &row.DestinationKey, + &requestJSON, + &status, + &row.Attempts, + &row.MaxAttempts, + &row.NextAttemptAt, + &row.LeaseOwner, + &leaseExpires, + &row.LastErrorCode, + &row.LastError, + &row.ExternalID, + &row.CreatedAt, + &row.UpdatedAt, + &deliveredAt, + ); err != nil { + return notification.DeliveryRow{}, err + } + row.NotificationID = domain.NotificationID(notificationID) + row.ProjectID = domain.ProjectID(projectID) + row.SessionID = domain.SessionID(sessionID) + row.RequestJSON = []byte(requestJSON) + row.Status = notification.DeliveryStatus(status) + if leaseExpires.Valid { + row.LeaseExpiresAt = leaseExpires.Time + } + if deliveredAt.Valid { + row.DeliveredAt = deliveredAt.Time + } + return row, nil +} diff --git a/backend/internal/storage/sqlite/notification_delivery_store_test.go b/backend/internal/storage/sqlite/notification_delivery_store_test.go new file mode 100644 index 0000000000..0666484ca0 --- /dev/null +++ b/backend/internal/storage/sqlite/notification_delivery_store_test.go @@ -0,0 +1,253 @@ +package sqlite + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/notification" +) + +func TestNotificationDeliveryEnqueueIdempotentAndCDC(t *testing.T) { + s, ntf := newDeliveryTestNotification(t, "delivery-dedupe") + ctx := context.Background() + startSeq, _ := s.MaxChangeLogSeq(ctx) + + row, created, err := s.EnqueueDelivery(ctx, sampleDelivery(ntf, "desktop")) + if err != nil { + t.Fatal(err) + } + if !created || row.ID == "" || row.Status != notification.DeliveryQueued { + t.Fatalf("created=%v row=%+v", created, row) + } + dup, created, err := s.EnqueueDelivery(ctx, sampleDelivery(ntf, "desktop")) + if err != nil { + t.Fatal(err) + } + if created || dup.ID != row.ID { + t.Fatalf("duplicate should return existing row created=false: created=%v dup=%+v row=%+v", created, dup, row) + } + evs, err := s.ReadChangeLogAfter(ctx, startSeq, 10) + if err != nil { + t.Fatal(err) + } + var createdEvents int + for _, ev := range evs { + if ev.EventType == string(cdc.EventNotificationDeliveryCreated) { + createdEvents++ + } + } + if createdEvents != 1 { + t.Fatalf("delivery created CDC count = %d, want 1 events=%+v", createdEvents, evs) + } +} + +func TestNotificationDeliveryClaimDueStableOrder(t *testing.T) { + s, ntf := newDeliveryTestNotification(t, "delivery-claim") + ctx := context.Background() + base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + for i, d := range []time.Duration{2 * time.Second, time.Second, 3 * time.Second} { + row := sampleDelivery(ntf, fmt.Sprintf("desktop-%d", i)) + row.DestinationKey = fmt.Sprintf("dest-%d", i) + row.NextAttemptAt = base.Add(d) + row.CreatedAt = base.Add(time.Duration(i) * time.Millisecond) + row.UpdatedAt = row.CreatedAt + if _, _, err := s.EnqueueDelivery(ctx, row); err != nil { + t.Fatal(err) + } + } + + claimed, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "electron", base.Add(10*time.Second), 2, time.Minute) + if err != nil { + t.Fatal(err) + } + if len(claimed) != 2 { + t.Fatalf("claimed = %d, want 2", len(claimed)) + } + if claimed[0].DestinationKey != "dest-1" || claimed[1].DestinationKey != "dest-0" { + t.Fatalf("claim order = %s, %s; want dest-1, dest-0", claimed[0].DestinationKey, claimed[1].DestinationKey) + } + if claimed[0].Status != notification.DeliveryLeased || claimed[0].LeaseOwner != "electron" || claimed[0].LeaseExpiresAt.IsZero() { + t.Fatalf("claimed row not leased: %+v", claimed[0]) + } +} + +func TestNotificationDeliveryLeaseExpiryAndMaxAttempts(t *testing.T) { + s, ntf := newDeliveryTestNotification(t, "delivery-expiry") + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + queued, _, err := s.EnqueueDelivery(ctx, sampleDueDelivery(ntf, "desktop", now)) + if err != nil { + t.Fatal(err) + } + claimed, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 1, time.Second) + if err != nil || len(claimed) != 1 { + t.Fatalf("claim len=%d err=%v", len(claimed), err) + } + released, err := s.ReleaseExpiredDeliveryLeases(ctx, now.Add(2*time.Second)) + if err != nil || released != 1 { + t.Fatalf("release = %d err=%v", released, err) + } + got, ok, _ := s.GetDelivery(ctx, queued.ID) + if !ok || got.Status != notification.DeliveryQueued || got.Attempts != 1 || got.LeaseOwner != "" { + t.Fatalf("expired lease should return queued with attempts=1: ok=%v row=%+v", ok, got) + } + + maxOne := sampleDueDelivery(ntf, "desktop-max", now) + maxOne.DestinationKey = "max" + maxOne.MaxAttempts = 1 + maxOne, _, err = s.EnqueueDelivery(ctx, maxOne) + if err != nil { + t.Fatal(err) + } + if _, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 1, time.Second); err != nil { + t.Fatal(err) + } + released, err = s.ReleaseExpiredDeliveryLeases(ctx, now.Add(2*time.Second)) + if err != nil || released != 1 { + t.Fatalf("release max = %d err=%v", released, err) + } + got, ok, _ = s.GetDelivery(ctx, maxOne.ID) + if !ok || got.Status != notification.DeliveryFailed || got.Attempts != 1 { + t.Fatalf("max attempts expired lease should fail: ok=%v row=%+v", ok, got) + } +} + +func TestNotificationDeliveryMarkSentRetryFailedAndSkipped(t *testing.T) { + s, ntf := newDeliveryTestNotification(t, "delivery-mark") + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + sent, _, _ := s.EnqueueDelivery(ctx, sampleDueDelivery(ntf, "desktop-sent", now)) + claimed, _ := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 1, time.Minute) + if len(claimed) != 1 { + t.Fatalf("claim sent row len=%d", len(claimed)) + } + if err := s.MarkDeliverySent(ctx, sent.ID, "native-1", now.Add(time.Second)); err != nil { + t.Fatal(err) + } + got, _, _ := s.GetDelivery(ctx, sent.ID) + if got.Status != notification.DeliverySent || got.ExternalID != "native-1" || got.Attempts != 1 || got.DeliveredAt.IsZero() { + t.Fatalf("sent row = %+v", got) + } + + retry := sampleDueDelivery(ntf, "desktop-retry", now) + retry.DestinationKey = "retry" + retry, _, _ = s.EnqueueDelivery(ctx, retry) + claimed, _ = s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 1, time.Minute) + if len(claimed) != 1 { + t.Fatalf("claim retry row len=%d", len(claimed)) + } + next := now.Add(30 * time.Second) + if err := s.MarkDeliveryRetry(ctx, retry.ID, "timeout", "timed out", next); err != nil { + t.Fatal(err) + } + got, _, _ = s.GetDelivery(ctx, retry.ID) + if got.Status != notification.DeliveryRetryWait || got.Attempts != 1 || !got.NextAttemptAt.Equal(next) { + t.Fatalf("retry row = %+v", got) + } + + fail := sampleDueDelivery(ntf, "desktop-fail", now) + fail.DestinationKey = "fail" + fail.MaxAttempts = 1 + fail, _, _ = s.EnqueueDelivery(ctx, fail) + claimed, _ = s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 1, time.Minute) + if len(claimed) != 1 { + t.Fatalf("claim fail row len=%d", len(claimed)) + } + if err := s.MarkDeliveryRetry(ctx, fail.ID, "timeout", "timed out", next); err != nil { + t.Fatal(err) + } + got, _, _ = s.GetDelivery(ctx, fail.ID) + if got.Status != notification.DeliveryFailed || got.Attempts != 1 { + t.Fatalf("retry at max should fail: %+v", got) + } + + skipped := sampleDueDelivery(ntf, "desktop-skip", now) + skipped.DestinationKey = "skip" + skipped.Status = notification.DeliverySkipped + skipped, _, _ = s.EnqueueDelivery(ctx, skipped) + claimed, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 10, time.Minute) + if err != nil { + t.Fatal(err) + } + for _, row := range claimed { + if row.ID == skipped.ID { + t.Fatalf("skipped row should not be claimable: %+v", claimed) + } + } + if err := s.MarkDeliveryRetry(ctx, skipped.ID, "timeout", "timed out", next); err != nil { + t.Fatal(err) + } + got, _, _ = s.GetDelivery(ctx, skipped.ID) + if got.Status != notification.DeliverySkipped || got.Attempts != 0 { + t.Fatalf("skipped row should be terminal: %+v", got) + } +} + +func TestNotificationDeliveryUpdateCDC(t *testing.T) { + s, ntf := newDeliveryTestNotification(t, "delivery-cdc-update") + ctx := context.Background() + row, _, err := s.EnqueueDelivery(ctx, sampleDelivery(ntf, "desktop")) + if err != nil { + t.Fatal(err) + } + startSeq, _ := s.MaxChangeLogSeq(ctx) + if _, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", time.Now().UTC(), 1, time.Minute); err != nil { + t.Fatal(err) + } + if err := s.MarkDeliveryFailed(ctx, row.ID, "permanent", "bad route", time.Now().UTC()); err != nil { + t.Fatal(err) + } + evs, err := s.ReadChangeLogAfter(ctx, startSeq, 10) + if err != nil { + t.Fatal(err) + } + var updates int + for _, ev := range evs { + if ev.EventType == string(cdc.EventNotificationDeliveryUpdated) { + updates++ + } + } + if updates < 2 { + t.Fatalf("expected claim + failed update CDC events, got %d in %+v", updates, evs) + } +} + +func newDeliveryTestNotification(t *testing.T, dedupe string) (*Store, domain.Notification) { + t.Helper() + s, rec := newNotificationTestSession(t) + row, _, err := s.EnqueueNotification(context.Background(), sampleNotification(rec, dedupe)) + if err != nil { + t.Fatalf("enqueue notification: %v", err) + } + return s, row +} + +func sampleDelivery(ntf domain.Notification, route string) notification.DeliveryRow { + now := time.Now().UTC().Truncate(time.Second) + return notification.DeliveryRow{ + NotificationID: ntf.ID, + NotificationSeq: ntf.Seq, + ProjectID: ntf.ProjectID, + SessionID: ntf.SessionID, + RouteName: route, + Sink: notification.SinkAOApp, + Status: notification.DeliveryQueued, + MaxAttempts: 5, + NextAttemptAt: now, + CreatedAt: now, + UpdatedAt: now, + } +} + +func sampleDueDelivery(ntf domain.Notification, route string, due time.Time) notification.DeliveryRow { + row := sampleDelivery(ntf, route) + row.NextAttemptAt = due + row.CreatedAt = due + row.UpdatedAt = due + return row +} diff --git a/backend/internal/storage/sqlite/notification_store.go b/backend/internal/storage/sqlite/notification_store.go index 90b84331c7..8e3e1b0e0c 100644 --- a/backend/internal/storage/sqlite/notification_store.go +++ b/backend/internal/storage/sqlite/notification_store.go @@ -238,5 +238,8 @@ func notificationFromGen(r gen.Notification) (NotificationRow, error) { if r.ArchivedAt.Valid { row.ArchivedAt = r.ArchivedAt.Time } + if r.RoutedAt.Valid { + row.RoutedAt = r.RoutedAt.Time + } return row, nil } diff --git a/backend/internal/storage/sqlite/queries/notification_deliveries.sql b/backend/internal/storage/sqlite/queries/notification_deliveries.sql new file mode 100644 index 0000000000..02f403afc1 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/notification_deliveries.sql @@ -0,0 +1,46 @@ +-- name: ListUnroutedNotifications :many +SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at +FROM notifications +WHERE routed_at IS NULL +ORDER BY seq ASC +LIMIT ?; + +-- name: MarkNotificationRouted :exec +UPDATE notifications +SET routed_at = COALESCE(routed_at, ?), + updated_at = CASE WHEN routed_at IS NULL THEN ? ELSE updated_at END +WHERE id = ?; + +-- name: InsertNotificationDelivery :one +INSERT INTO notification_deliveries ( + id, notification_id, notification_seq, project_id, session_id, + route_name, sink, destination_key, request_json, + status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, + last_error_code, last_error, external_id, + created_at, updated_at, delivered_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(notification_id, route_name, destination_key) DO NOTHING +RETURNING id, notification_id, notification_seq, project_id, session_id, + route_name, sink, destination_key, request_json, + status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, + last_error_code, last_error, external_id, + created_at, updated_at, delivered_at; + +-- name: GetNotificationDelivery :one +SELECT id, notification_id, notification_seq, project_id, session_id, + route_name, sink, destination_key, request_json, + status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, + last_error_code, last_error, external_id, + created_at, updated_at, delivered_at +FROM notification_deliveries +WHERE id = ?; + +-- name: GetNotificationDeliveryByUnique :one +SELECT id, notification_id, notification_seq, project_id, session_id, + route_name, sink, destination_key, request_json, + status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, + last_error_code, last_error, external_id, + created_at, updated_at, delivered_at +FROM notification_deliveries +WHERE notification_id = ? AND route_name = ? AND destination_key = ?; diff --git a/backend/internal/storage/sqlite/queries/notifications.sql b/backend/internal/storage/sqlite/queries/notifications.sql index a896b43c91..96732bf5ef 100644 --- a/backend/internal/storage/sqlite/queries/notifications.sql +++ b/backend/internal/storage/sqlite/queries/notifications.sql @@ -5,28 +5,28 @@ INSERT INTO notifications ( ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dedupe_key) DO NOTHING RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at; -- name: GetNotification :one SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications WHERE id = ?; -- name: GetNotificationByDedupeKey :one SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications WHERE dedupe_key = ?; -- name: ListNotifications :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications ORDER BY seq DESC LIMIT ?; -- name: ListNotificationsByProject :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications WHERE project_id = ? ORDER BY seq DESC @@ -34,7 +34,7 @@ LIMIT ?; -- name: ListNotificationsBySession :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications WHERE session_id = ? ORDER BY seq DESC @@ -42,7 +42,7 @@ LIMIT ?; -- name: ListUnreadNotifications :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at FROM notifications WHERE read_at IS NULL AND archived_at IS NULL ORDER BY seq DESC @@ -53,18 +53,18 @@ UPDATE notifications SET read_at = ?, updated_at = ? WHERE id = ? AND read_at IS NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at; -- name: MarkNotificationUnread :one UPDATE notifications SET read_at = NULL, updated_at = ? WHERE id = ? AND read_at IS NOT NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at; -- name: ArchiveNotification :one UPDATE notifications SET archived_at = ?, updated_at = ? WHERE id = ? AND archived_at IS NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at; diff --git a/backend/notifier_wiring.go b/backend/notifier_wiring.go new file mode 100644 index 0000000000..06ed53b847 --- /dev/null +++ b/backend/notifier_wiring.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "log/slog" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/notification" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +type notifierStack struct { + Manager *notification.Manager + done <-chan struct{} +} + +func startNotifier(ctx context.Context, cfg config.Config, store *sqlite.Store, log *slog.Logger) (*notifierStack, error) { + mgr := notification.NewManager(store, notification.SettingsFromConfig(cfg), log) + done := mgr.Start(ctx) + return ¬ifierStack{Manager: mgr, done: done}, nil +} + +func (s *notifierStack) Stop() { + if s == nil || s.done == nil { + return + } + <-s.done +} From d4622fe223ec6b2b1c2686c02808ff7653659813 Mon Sep 17 00:00:00 2001 From: whoisasx Date: Mon, 1 Jun 2026 01:26:26 +0530 Subject: [PATCH 090/250] fix: address notifier delivery review feedback --- .../internal/notification/dispatcher_test.go | 37 ++++++++++++++++++- backend/internal/notification/manager.go | 17 +++++++-- backend/internal/notification/store.go | 1 + .../0003_notification_deliveries.sql | 4 ++ .../sqlite/notification_delivery_store.go | 2 +- 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/backend/internal/notification/dispatcher_test.go b/backend/internal/notification/dispatcher_test.go index 9a9b0fc064..96a91e3882 100644 --- a/backend/internal/notification/dispatcher_test.go +++ b/backend/internal/notification/dispatcher_test.go @@ -17,8 +17,10 @@ type fakeRuntimeStore struct { mu sync.Mutex unrouted []domain.Notification deliveries []DeliveryRow + byID map[string]DeliveryRow routed []domain.NotificationID releases int + retryNext time.Time failEnqueue map[domain.NotificationID]error } @@ -45,6 +47,13 @@ func (f *fakeRuntimeStore) EnqueueDelivery(_ context.Context, row DeliveryRow) ( return row, true, nil } +func (f *fakeRuntimeStore) GetDelivery(_ context.Context, id string) (DeliveryRow, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + row, ok := f.byID[id] + return row, ok, nil +} + func (f *fakeRuntimeStore) ClaimDueDeliveries(context.Context, string, string, time.Time, int, time.Duration) ([]DeliveryRow, error) { return nil, nil } @@ -57,7 +66,10 @@ func (f *fakeRuntimeStore) ReleaseExpiredDeliveryLeases(context.Context, time.Ti func (f *fakeRuntimeStore) MarkDeliverySent(context.Context, string, string, time.Time) error { return nil } -func (f *fakeRuntimeStore) MarkDeliveryRetry(context.Context, string, string, string, time.Time) error { +func (f *fakeRuntimeStore) MarkDeliveryRetry(_ context.Context, _ string, _ string, _ string, next time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + f.retryNext = next return nil } func (f *fakeRuntimeStore) MarkDeliveryFailed(context.Context, string, string, string, time.Time) error { @@ -119,6 +131,29 @@ func TestRoutePendingDeliveryFailureDoesNotBlockOtherNotifications(t *testing.T) } } +func TestMarkDeliveryErrorUsesAttemptAwareBackoff(t *testing.T) { + now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + cfg := config.DefaultNotificationConfig() + cfg.Retry.BaseDelay = time.Second + cfg.Retry.MaxDelay = time.Minute + store := &fakeRuntimeStore{byID: map[string]DeliveryRow{ + "del_1": {ID: "del_1", Attempts: 2, MaxAttempts: 5}, + }} + mgr := NewManager(store, StaticSettings(cfg), discardLogger()) + mgr.clock = func() time.Time { return now } + + if err := mgr.MarkDeliveryError(context.Background(), "del_1", "timeout", "timed out"); err != nil { + t.Fatal(err) + } + store.mu.Lock() + next := store.retryNext + store.mu.Unlock() + delay := next.Sub(now) + if delay < 3200*time.Millisecond || delay > 4800*time.Millisecond { + t.Fatalf("retry delay for third attempt = %s, want jittered 4s backoff", delay) + } +} + func sampleDomainNotification(id domain.NotificationID, priority string) domain.Notification { return domain.Notification{ Seq: 1, diff --git a/backend/internal/notification/manager.go b/backend/internal/notification/manager.go index ea505ff8ca..80f23f7dbe 100644 --- a/backend/internal/notification/manager.go +++ b/backend/internal/notification/manager.go @@ -3,6 +3,7 @@ package notification import ( "context" "errors" + "fmt" "log/slog" "time" @@ -136,11 +137,21 @@ func (m *Manager) MarkDeliverySent(ctx context.Context, id, externalID string) e func (m *Manager) MarkDeliveryError(ctx context.Context, id, code, message string) error { settings := m.settings.Settings(ctx) policy := RetryPolicyFromConfig(settings.Retry) + now := m.clock().UTC() // The store is the source of truth for attempts/max-attempt terminal // handling. Permanent classification short-circuits to failed; otherwise we - // provide the next retry timestamp for retry_wait rows. + // fetch the current delivery attempts and provide the attempt-aware next + // retry timestamp for retry_wait rows. if ClassifyError(code) == ErrorPermanent { - return m.store.MarkDeliveryFailed(ctx, id, code, message, m.clock().UTC()) + return m.store.MarkDeliveryFailed(ctx, id, code, message, now) + } + row, ok, err := m.store.GetDelivery(ctx, id) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("notification delivery %s not found", id) } - return m.store.MarkDeliveryRetry(ctx, id, code, message, policy.NextAttemptAt(m.clock().UTC(), 1)) + nextAttemptNo := row.Attempts + 1 + return m.store.MarkDeliveryRetry(ctx, id, code, message, policy.NextAttemptAt(now, nextAttemptNo)) } diff --git a/backend/internal/notification/store.go b/backend/internal/notification/store.go index 9be4aef593..b29d1ebc6c 100644 --- a/backend/internal/notification/store.go +++ b/backend/internal/notification/store.go @@ -14,6 +14,7 @@ type Store interface { ListUnroutedNotifications(ctx context.Context, limit int) ([]domain.Notification, error) MarkNotificationRouted(ctx context.Context, id domain.NotificationID, at time.Time) error + GetDelivery(ctx context.Context, id string) (DeliveryRow, bool, error) EnqueueDelivery(ctx context.Context, row DeliveryRow) (DeliveryRow, bool, error) ClaimDueDeliveries(ctx context.Context, sink string, owner string, now time.Time, limit int, lease time.Duration) ([]DeliveryRow, error) ReleaseExpiredDeliveryLeases(ctx context.Context, now time.Time) (int, error) diff --git a/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql b/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql index 5cf486887f..e1e8e85e21 100644 --- a/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql +++ b/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql @@ -1,6 +1,9 @@ -- +goose Up -- +goose StatementBegin ALTER TABLE notifications ADD COLUMN routed_at TIMESTAMP; +CREATE INDEX idx_notifications_unrouted + ON notifications(seq) + WHERE routed_at IS NULL; CREATE TABLE notification_deliveries ( id TEXT PRIMARY KEY, @@ -115,5 +118,6 @@ DROP TRIGGER IF EXISTS notification_deliveries_cdc_update; DROP TRIGGER IF EXISTS notification_deliveries_cdc_insert; DROP TABLE IF EXISTS notification_delivery_attempts; DROP TABLE IF EXISTS notification_deliveries; +DROP INDEX IF EXISTS idx_notifications_unrouted; ALTER TABLE notifications DROP COLUMN routed_at; -- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/notification_delivery_store.go b/backend/internal/storage/sqlite/notification_delivery_store.go index 18593b2406..c2b8a62a91 100644 --- a/backend/internal/storage/sqlite/notification_delivery_store.go +++ b/backend/internal/storage/sqlite/notification_delivery_store.go @@ -59,7 +59,7 @@ func (s *Store) EnqueueDelivery(ctx context.Context, row notification.DeliveryRo if now.IsZero() { now = time.Now().UTC() } - row, err := notification.NormalizeDelivery(row, now, 5) + row, err := notification.NormalizeDelivery(row, now, row.MaxAttempts) if err != nil { return notification.DeliveryRow{}, false, err } From 041c8c8f7f649acc59f3886353d6eb0f1dd7f1f8 Mon Sep 17 00:00:00 2001 From: whoisasx Date: Mon, 1 Jun 2026 03:01:05 +0530 Subject: [PATCH 091/250] fix: harden notification delivery leases --- backend/internal/notification/delivery.go | 3 + .../internal/notification/dispatcher_test.go | 8 +-- backend/internal/notification/manager.go | 10 +-- backend/internal/notification/store.go | 6 +- .../0003_notification_deliveries.sql | 1 + .../sqlite/notification_delivery_store.go | 44 +++++++++---- .../notification_delivery_store_test.go | 61 +++++++++++++++++-- 7 files changed, 104 insertions(+), 29 deletions(-) diff --git a/backend/internal/notification/delivery.go b/backend/internal/notification/delivery.go index e33c85e700..c7fa031160 100644 --- a/backend/internal/notification/delivery.go +++ b/backend/internal/notification/delivery.go @@ -4,12 +4,15 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) +var ErrDeliveryUpdateConflict = errors.New("notification delivery update conflict") + const ( RouteDashboard = "dashboard" RouteDesktop = "desktop" diff --git a/backend/internal/notification/dispatcher_test.go b/backend/internal/notification/dispatcher_test.go index 96a91e3882..34b7b23e69 100644 --- a/backend/internal/notification/dispatcher_test.go +++ b/backend/internal/notification/dispatcher_test.go @@ -63,16 +63,16 @@ func (f *fakeRuntimeStore) ReleaseExpiredDeliveryLeases(context.Context, time.Ti f.releases++ return 0, nil } -func (f *fakeRuntimeStore) MarkDeliverySent(context.Context, string, string, time.Time) error { +func (f *fakeRuntimeStore) MarkDeliverySent(context.Context, string, string, string, time.Time) error { return nil } -func (f *fakeRuntimeStore) MarkDeliveryRetry(_ context.Context, _ string, _ string, _ string, next time.Time) error { +func (f *fakeRuntimeStore) MarkDeliveryRetry(_ context.Context, _ string, _ string, _ string, _ string, next time.Time, _ time.Time) error { f.mu.Lock() defer f.mu.Unlock() f.retryNext = next return nil } -func (f *fakeRuntimeStore) MarkDeliveryFailed(context.Context, string, string, string, time.Time) error { +func (f *fakeRuntimeStore) MarkDeliveryFailed(context.Context, string, string, string, string, time.Time) error { return nil } func (f *fakeRuntimeStore) MarkDeliverySkipped(context.Context, string, string, time.Time) error { @@ -142,7 +142,7 @@ func TestMarkDeliveryErrorUsesAttemptAwareBackoff(t *testing.T) { mgr := NewManager(store, StaticSettings(cfg), discardLogger()) mgr.clock = func() time.Time { return now } - if err := mgr.MarkDeliveryError(context.Background(), "del_1", "timeout", "timed out"); err != nil { + if err := mgr.MarkDeliveryError(context.Background(), "del_1", "electron", "timeout", "timed out"); err != nil { t.Fatal(err) } store.mu.Lock() diff --git a/backend/internal/notification/manager.go b/backend/internal/notification/manager.go index 80f23f7dbe..235f893e7b 100644 --- a/backend/internal/notification/manager.go +++ b/backend/internal/notification/manager.go @@ -130,11 +130,11 @@ func (m *Manager) ClaimDesktopDeliveries(ctx context.Context, owner string, limi return m.store.ClaimDueDeliveries(ctx, SinkAOApp, owner, m.clock().UTC(), limit, policy.LeaseTTL) } -func (m *Manager) MarkDeliverySent(ctx context.Context, id, externalID string) error { - return m.store.MarkDeliverySent(ctx, id, externalID, m.clock().UTC()) +func (m *Manager) MarkDeliverySent(ctx context.Context, id, owner, externalID string) error { + return m.store.MarkDeliverySent(ctx, id, owner, externalID, m.clock().UTC()) } -func (m *Manager) MarkDeliveryError(ctx context.Context, id, code, message string) error { +func (m *Manager) MarkDeliveryError(ctx context.Context, id, owner, code, message string) error { settings := m.settings.Settings(ctx) policy := RetryPolicyFromConfig(settings.Retry) now := m.clock().UTC() @@ -143,7 +143,7 @@ func (m *Manager) MarkDeliveryError(ctx context.Context, id, code, message strin // fetch the current delivery attempts and provide the attempt-aware next // retry timestamp for retry_wait rows. if ClassifyError(code) == ErrorPermanent { - return m.store.MarkDeliveryFailed(ctx, id, code, message, now) + return m.store.MarkDeliveryFailed(ctx, id, owner, code, message, now) } row, ok, err := m.store.GetDelivery(ctx, id) if err != nil { @@ -153,5 +153,5 @@ func (m *Manager) MarkDeliveryError(ctx context.Context, id, code, message strin return fmt.Errorf("notification delivery %s not found", id) } nextAttemptNo := row.Attempts + 1 - return m.store.MarkDeliveryRetry(ctx, id, code, message, policy.NextAttemptAt(now, nextAttemptNo)) + return m.store.MarkDeliveryRetry(ctx, id, owner, code, message, policy.NextAttemptAt(now, nextAttemptNo), now) } diff --git a/backend/internal/notification/store.go b/backend/internal/notification/store.go index b29d1ebc6c..e2cdf511b6 100644 --- a/backend/internal/notification/store.go +++ b/backend/internal/notification/store.go @@ -18,8 +18,8 @@ type Store interface { EnqueueDelivery(ctx context.Context, row DeliveryRow) (DeliveryRow, bool, error) ClaimDueDeliveries(ctx context.Context, sink string, owner string, now time.Time, limit int, lease time.Duration) ([]DeliveryRow, error) ReleaseExpiredDeliveryLeases(ctx context.Context, now time.Time) (int, error) - MarkDeliverySent(ctx context.Context, id string, externalID string, at time.Time) error - MarkDeliveryRetry(ctx context.Context, id string, errCode string, errMessage string, next time.Time) error - MarkDeliveryFailed(ctx context.Context, id string, errCode string, errMessage string, at time.Time) error + MarkDeliverySent(ctx context.Context, id string, owner string, externalID string, at time.Time) error + MarkDeliveryRetry(ctx context.Context, id string, owner string, errCode string, errMessage string, next time.Time, at time.Time) error + MarkDeliveryFailed(ctx context.Context, id string, owner string, errCode string, errMessage string, at time.Time) error MarkDeliverySkipped(ctx context.Context, id string, reason string, at time.Time) error } diff --git a/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql b/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql index e1e8e85e21..66a97f100d 100644 --- a/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql +++ b/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql @@ -1,6 +1,7 @@ -- +goose Up -- +goose StatementBegin ALTER TABLE notifications ADD COLUMN routed_at TIMESTAMP; +UPDATE notifications SET routed_at = updated_at WHERE routed_at IS NULL; CREATE INDEX idx_notifications_unrouted ON notifications(seq) WHERE routed_at IS NULL; diff --git a/backend/internal/storage/sqlite/notification_delivery_store.go b/backend/internal/storage/sqlite/notification_delivery_store.go index c2b8a62a91..7a7503b6a3 100644 --- a/backend/internal/storage/sqlite/notification_delivery_store.go +++ b/backend/internal/storage/sqlite/notification_delivery_store.go @@ -18,7 +18,10 @@ const deliveryColumns = `id, notification_id, notification_seq, project_id, sess last_error_code, last_error, external_id, created_at, updated_at, delivered_at` -const defaultDeliveryLimit = 100 +const ( + defaultDeliveryLimit = 100 + defaultDeliveryMaxAttempts = 5 // mirrors notification_deliveries.max_attempts schema default +) type DeliveryFilter struct { NotificationID string @@ -59,7 +62,7 @@ func (s *Store) EnqueueDelivery(ctx context.Context, row notification.DeliveryRo if now.IsZero() { now = time.Now().UTC() } - row, err := notification.NormalizeDelivery(row, now, row.MaxAttempts) + row, err := notification.NormalizeDelivery(row, now, defaultDeliveryMaxAttempts) if err != nil { return notification.DeliveryRow{}, false, err } @@ -222,7 +225,7 @@ WHERE status = 'leased' return int(n), nil } -func (s *Store) MarkDeliverySent(ctx context.Context, id string, externalID string, at time.Time) error { +func (s *Store) MarkDeliverySent(ctx context.Context, id string, owner string, externalID string, at time.Time) error { if at.IsZero() { at = time.Now().UTC() } @@ -234,13 +237,18 @@ SET status = 'sent', external_id = ?, delivered_at = ?, updated_at = ? -WHERE id = ? AND status = 'leased'`, externalID, at, at, id) +WHERE id = ? + AND status = 'leased' + AND lease_owner = ? + AND lease_expires_at > ?`, externalID, at, at, id, owner, at) } -func (s *Store) MarkDeliveryRetry(ctx context.Context, id string, errCode string, errMessage string, next time.Time) error { - now := time.Now().UTC() +func (s *Store) MarkDeliveryRetry(ctx context.Context, id string, owner string, errCode string, errMessage string, next time.Time, at time.Time) error { + if at.IsZero() { + at = time.Now().UTC() + } if next.IsZero() { - next = now + next = at } return s.updateDelivery(ctx, "mark delivery retry", `UPDATE notification_deliveries SET attempts = attempts + 1, @@ -251,10 +259,13 @@ SET attempts = attempts + 1, last_error_code = ?, last_error = ?, updated_at = ? -WHERE id = ? AND status = 'leased'`, next, errCode, errMessage, now, id) +WHERE id = ? + AND status = 'leased' + AND lease_owner = ? + AND lease_expires_at > ?`, next, errCode, errMessage, at, id, owner, at) } -func (s *Store) MarkDeliveryFailed(ctx context.Context, id string, errCode string, errMessage string, at time.Time) error { +func (s *Store) MarkDeliveryFailed(ctx context.Context, id string, owner string, errCode string, errMessage string, at time.Time) error { if at.IsZero() { at = time.Now().UTC() } @@ -266,7 +277,10 @@ SET status = 'failed', last_error_code = ?, last_error = ?, updated_at = ? -WHERE id = ? AND status NOT IN ('sent','failed','skipped','cancelled')`, errCode, errMessage, at, id) +WHERE id = ? + AND status = 'leased' + AND lease_owner = ? + AND lease_expires_at > ?`, errCode, errMessage, at, id, owner, at) } func (s *Store) MarkDeliverySkipped(ctx context.Context, id string, reason string, at time.Time) error { @@ -346,9 +360,17 @@ func (s *Store) ListDeliveries(ctx context.Context, filter DeliveryFilter) ([]no func (s *Store) updateDelivery(ctx context.Context, what string, query string, args ...any) error { s.writeMu.Lock() defer s.writeMu.Unlock() - if _, err := s.writeDB.ExecContext(ctx, query, args...); err != nil { + res, err := s.writeDB.ExecContext(ctx, query, args...) + if err != nil { return fmt.Errorf("%s: %w", what, err) } + affected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("%s rows affected: %w", what, err) + } + if affected == 0 { + return fmt.Errorf("%s: %w", what, notification.ErrDeliveryUpdateConflict) + } return nil } diff --git a/backend/internal/storage/sqlite/notification_delivery_store_test.go b/backend/internal/storage/sqlite/notification_delivery_store_test.go index 0666484ca0..2c675466a4 100644 --- a/backend/internal/storage/sqlite/notification_delivery_store_test.go +++ b/backend/internal/storage/sqlite/notification_delivery_store_test.go @@ -2,6 +2,7 @@ package sqlite import ( "context" + "errors" "fmt" "testing" "time" @@ -45,6 +46,20 @@ func TestNotificationDeliveryEnqueueIdempotentAndCDC(t *testing.T) { } } +func TestNotificationDeliveryEnqueueDefaultMaxAttempts(t *testing.T) { + s, ntf := newDeliveryTestNotification(t, "delivery-default-max") + ctx := context.Background() + row := sampleDelivery(ntf, "desktop") + row.MaxAttempts = 0 + got, _, err := s.EnqueueDelivery(ctx, row) + if err != nil { + t.Fatal(err) + } + if got.MaxAttempts != 5 { + t.Fatalf("default max attempts = %d, want 5", got.MaxAttempts) + } +} + func TestNotificationDeliveryClaimDueStableOrder(t *testing.T) { s, ntf := newDeliveryTestNotification(t, "delivery-claim") ctx := context.Background() @@ -126,7 +141,7 @@ func TestNotificationDeliveryMarkSentRetryFailedAndSkipped(t *testing.T) { if len(claimed) != 1 { t.Fatalf("claim sent row len=%d", len(claimed)) } - if err := s.MarkDeliverySent(ctx, sent.ID, "native-1", now.Add(time.Second)); err != nil { + if err := s.MarkDeliverySent(ctx, sent.ID, "owner", "native-1", now.Add(time.Second)); err != nil { t.Fatal(err) } got, _, _ := s.GetDelivery(ctx, sent.ID) @@ -142,7 +157,7 @@ func TestNotificationDeliveryMarkSentRetryFailedAndSkipped(t *testing.T) { t.Fatalf("claim retry row len=%d", len(claimed)) } next := now.Add(30 * time.Second) - if err := s.MarkDeliveryRetry(ctx, retry.ID, "timeout", "timed out", next); err != nil { + if err := s.MarkDeliveryRetry(ctx, retry.ID, "owner", "timeout", "timed out", next, now.Add(time.Second)); err != nil { t.Fatal(err) } got, _, _ = s.GetDelivery(ctx, retry.ID) @@ -158,7 +173,7 @@ func TestNotificationDeliveryMarkSentRetryFailedAndSkipped(t *testing.T) { if len(claimed) != 1 { t.Fatalf("claim fail row len=%d", len(claimed)) } - if err := s.MarkDeliveryRetry(ctx, fail.ID, "timeout", "timed out", next); err != nil { + if err := s.MarkDeliveryRetry(ctx, fail.ID, "owner", "timeout", "timed out", next, now.Add(time.Second)); err != nil { t.Fatal(err) } got, _, _ = s.GetDelivery(ctx, fail.ID) @@ -179,8 +194,8 @@ func TestNotificationDeliveryMarkSentRetryFailedAndSkipped(t *testing.T) { t.Fatalf("skipped row should not be claimable: %+v", claimed) } } - if err := s.MarkDeliveryRetry(ctx, skipped.ID, "timeout", "timed out", next); err != nil { - t.Fatal(err) + if err := s.MarkDeliveryRetry(ctx, skipped.ID, "owner", "timeout", "timed out", next, now.Add(time.Second)); !errors.Is(err, notification.ErrDeliveryUpdateConflict) { + t.Fatalf("retry skipped row err = %v, want update conflict", err) } got, _, _ = s.GetDelivery(ctx, skipped.ID) if got.Status != notification.DeliverySkipped || got.Attempts != 0 { @@ -188,6 +203,40 @@ func TestNotificationDeliveryMarkSentRetryFailedAndSkipped(t *testing.T) { } } +func TestNotificationDeliveryCompletionFencedByLeaseOwner(t *testing.T) { + s, ntf := newDeliveryTestNotification(t, "delivery-owner-fence") + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + row, _, err := s.EnqueueDelivery(ctx, sampleDueDelivery(ntf, "desktop", now)) + if err != nil { + t.Fatal(err) + } + if _, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner-1", now, 1, time.Second); err != nil { + t.Fatal(err) + } + if released, err := s.ReleaseExpiredDeliveryLeases(ctx, now.Add(2*time.Second)); err != nil || released != 1 { + t.Fatalf("release = %d err=%v", released, err) + } + if _, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner-2", now.Add(2*time.Second), 1, time.Second); err != nil { + t.Fatal(err) + } + + if err := s.MarkDeliverySent(ctx, row.ID, "owner-1", "stale", now.Add(2500*time.Millisecond)); !errors.Is(err, notification.ErrDeliveryUpdateConflict) { + t.Fatalf("stale owner MarkDeliverySent err = %v, want update conflict", err) + } + got, _, _ := s.GetDelivery(ctx, row.ID) + if got.Status != notification.DeliveryLeased || got.LeaseOwner != "owner-2" || got.ExternalID != "" { + t.Fatalf("stale owner should not change active lease: %+v", got) + } + if err := s.MarkDeliverySent(ctx, row.ID, "owner-2", "native-2", now.Add(2500*time.Millisecond)); err != nil { + t.Fatalf("current owner sent: %v", err) + } + got, _, _ = s.GetDelivery(ctx, row.ID) + if got.Status != notification.DeliverySent || got.ExternalID != "native-2" { + t.Fatalf("current owner should complete delivery: %+v", got) + } +} + func TestNotificationDeliveryUpdateCDC(t *testing.T) { s, ntf := newDeliveryTestNotification(t, "delivery-cdc-update") ctx := context.Background() @@ -199,7 +248,7 @@ func TestNotificationDeliveryUpdateCDC(t *testing.T) { if _, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", time.Now().UTC(), 1, time.Minute); err != nil { t.Fatal(err) } - if err := s.MarkDeliveryFailed(ctx, row.ID, "permanent", "bad route", time.Now().UTC()); err != nil { + if err := s.MarkDeliveryFailed(ctx, row.ID, "owner", "permanent", "bad route", time.Now().UTC()); err != nil { t.Fatal(err) } evs, err := s.ReadChangeLogAfter(ctx, startSeq, 10) From d39e8e0da3d30ae84e7f7f393cec6ad058695eec Mon Sep 17 00:00:00 2001 From: whoisasx Date: Mon, 1 Jun 2026 03:09:55 +0530 Subject: [PATCH 092/250] fix: address notifier review cleanup --- backend/internal/notification/retry.go | 18 ++- backend/internal/notification/settings.go | 9 +- .../internal/notification/settings_test.go | 14 ++- .../sqlite/notification_delivery_store.go | 103 +++++++++++------- backend/notifier_wiring.go | 4 +- 5 files changed, 93 insertions(+), 55 deletions(-) diff --git a/backend/internal/notification/retry.go b/backend/internal/notification/retry.go index cf62798c73..72c54775a3 100644 --- a/backend/internal/notification/retry.go +++ b/backend/internal/notification/retry.go @@ -1,8 +1,9 @@ package notification import ( + crand "crypto/rand" + "encoding/binary" "math" - "math/rand" "strings" "time" @@ -30,7 +31,7 @@ func RetryPolicyFromConfig(cfg config.NotificationRetryConfig) RetryPolicy { LeaseTTL: settings.Retry.LeaseTTL, BatchSize: settings.Retry.BatchSize, Jitter: retryJitterFraction, - RandFloat64: rand.Float64, + RandFloat64: cryptoRandFloat64, } } @@ -69,13 +70,24 @@ func (p RetryPolicy) BackoffDelay(attempt int) time.Duration { } randFloat := p.RandFloat64 if randFloat == nil { - randFloat = rand.Float64 + randFloat = cryptoRandFloat64 } // rand in [0,1) -> factor in [1-jitter, 1+jitter) factor := 1 - p.Jitter + (2 * p.Jitter * randFloat()) return time.Duration(float64(delay) * factor) } +func cryptoRandFloat64() float64 { + var b [8]byte + if _, err := crand.Read(b[:]); err != nil { + // Fall back to a time-derived value only if the OS CSPRNG fails. The + // fallback still avoids math/rand's deterministic process seed. + return float64(time.Now().UnixNano()&((1<<53)-1)) / float64(1<<53) + } + // Match math/rand.Float64's 53 bits of precision in [0,1). + return float64(binary.BigEndian.Uint64(b[:])>>11) / float64(1<<53) +} + func (p RetryPolicy) NextAttemptAt(now time.Time, attempt int) time.Time { return now.Add(p.BackoffDelay(attempt)) } diff --git a/backend/internal/notification/settings.go b/backend/internal/notification/settings.go index 623e4a7161..abb8b65317 100644 --- a/backend/internal/notification/settings.go +++ b/backend/internal/notification/settings.go @@ -16,11 +16,7 @@ type staticSettings struct { } func SettingsFromConfig(cfg config.Config) SettingsProvider { - settings := cfg.Notifications - if isZeroNotificationConfig(settings) { - settings = config.DefaultNotificationConfig() - } - return staticSettings{cfg: NormalizeSettings(settings)} + return staticSettings{cfg: NormalizeSettings(cfg.Notifications)} } func StaticSettings(cfg config.NotificationConfig) SettingsProvider { @@ -35,9 +31,6 @@ func (s staticSettings) Settings(context.Context) config.NotificationConfig { // explicit route overrides, including an explicit empty route list. func NormalizeSettings(in config.NotificationConfig) config.NotificationConfig { def := config.DefaultNotificationConfig() - if isZeroNotificationConfig(in) { - return cloneSettings(def) - } out := in if isZeroDashboardConfig(in.Dashboard) { diff --git a/backend/internal/notification/settings_test.go b/backend/internal/notification/settings_test.go index f3a6884e37..bd854e9881 100644 --- a/backend/internal/notification/settings_test.go +++ b/backend/internal/notification/settings_test.go @@ -9,15 +9,25 @@ import ( ) func TestSettingsFromConfigDefaultsWhenUnset(t *testing.T) { - got := SettingsFromConfig(config.Config{}).Settings(context.Background()) + got := SettingsFromConfig(config.Config{Notifications: config.DefaultNotificationConfig()}).Settings(context.Background()) if !got.Enabled || !got.Desktop.Enabled || !got.Dashboard.Enabled { - t.Fatalf("zero config should resolve safe enabled defaults: %+v", got) + t.Fatalf("default config should resolve safe enabled defaults: %+v", got) } if got.Retry.MaxAttempts != 5 || got.Retry.BatchSize != 50 { t.Fatalf("retry defaults = %+v", got.Retry) } } +func TestSettingsFromConfigPreservesExplicitGlobalDisable(t *testing.T) { + got := SettingsFromConfig(config.Config{Notifications: config.NotificationConfig{Enabled: false}}).Settings(context.Background()) + if got.Enabled { + t.Fatalf("explicit disabled notifications should stay disabled: %+v", got) + } + if got.Retry.MaxAttempts != 5 || got.Routing.Priorities == nil { + t.Fatalf("disabled config should still receive non-global defaults: %+v", got) + } +} + func TestNormalizeSettingsPreservesExplicitEmptyRoute(t *testing.T) { cfg := config.DefaultNotificationConfig() cfg.Routing.Priorities[ports.PriorityUrgent] = []string{} diff --git a/backend/internal/storage/sqlite/notification_delivery_store.go b/backend/internal/storage/sqlite/notification_delivery_store.go index 7a7503b6a3..9cdddc743b 100644 --- a/backend/internal/storage/sqlite/notification_delivery_store.go +++ b/backend/internal/storage/sqlite/notification_delivery_store.go @@ -70,39 +70,29 @@ func (s *Store) EnqueueDelivery(ctx context.Context, row notification.DeliveryRo s.writeMu.Lock() defer s.writeMu.Unlock() - insert := `INSERT INTO notification_deliveries ( - id, notification_id, notification_seq, project_id, session_id, - route_name, sink, destination_key, request_json, - status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, - last_error_code, last_error, external_id, - created_at, updated_at, delivered_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(notification_id, route_name, destination_key) DO NOTHING -RETURNING ` + deliveryColumns - - got, err := scanDelivery(s.writeDB.QueryRowContext(ctx, insert, - row.ID, - string(row.NotificationID), - row.NotificationSeq, - string(row.ProjectID), - string(row.SessionID), - row.RouteName, - row.Sink, - row.DestinationKey, - string(row.RequestJSON), - string(row.Status), - row.Attempts, - row.MaxAttempts, - row.NextAttemptAt, - row.LeaseOwner, - nullTime(row.LeaseExpiresAt), - row.LastErrorCode, - row.LastError, - row.ExternalID, - row.CreatedAt, - row.UpdatedAt, - nullTime(row.DeliveredAt), - )) + got, err := s.qw.InsertNotificationDelivery(ctx, gen.InsertNotificationDeliveryParams{ + ID: row.ID, + NotificationID: string(row.NotificationID), + NotificationSeq: row.NotificationSeq, + ProjectID: string(row.ProjectID), + SessionID: string(row.SessionID), + RouteName: row.RouteName, + Sink: row.Sink, + DestinationKey: row.DestinationKey, + RequestJson: string(row.RequestJSON), + Status: string(row.Status), + Attempts: int64(row.Attempts), + MaxAttempts: int64(row.MaxAttempts), + NextAttemptAt: row.NextAttemptAt, + LeaseOwner: row.LeaseOwner, + LeaseExpiresAt: nullTime(row.LeaseExpiresAt), + LastErrorCode: row.LastErrorCode, + LastError: row.LastError, + ExternalID: row.ExternalID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + DeliveredAt: nullTime(row.DeliveredAt), + }) if errors.Is(err, sql.ErrNoRows) { existing, readErr := s.getDeliveryByUniqueLocked(ctx, row.NotificationID, row.RouteName, row.DestinationKey) if readErr != nil { @@ -113,7 +103,7 @@ RETURNING ` + deliveryColumns if err != nil { return notification.DeliveryRow{}, false, fmt.Errorf("insert notification delivery: %w", err) } - return got, true, nil + return deliveryFromGen(got), true, nil } func (s *Store) ClaimDueDeliveries(ctx context.Context, sink string, owner string, now time.Time, limit int, lease time.Duration) ([]notification.DeliveryRow, error) { @@ -298,14 +288,14 @@ WHERE id = ? AND status NOT IN ('sent','failed','skipped','cancelled')`, reason, } func (s *Store) GetDelivery(ctx context.Context, id string) (notification.DeliveryRow, bool, error) { - row, err := scanDelivery(s.readDB.QueryRowContext(ctx, `SELECT `+deliveryColumns+` FROM notification_deliveries WHERE id = ?`, id)) + row, err := s.qr.GetNotificationDelivery(ctx, id) if errors.Is(err, sql.ErrNoRows) { return notification.DeliveryRow{}, false, nil } if err != nil { return notification.DeliveryRow{}, false, fmt.Errorf("get notification delivery %s: %w", id, err) } - return row, true, nil + return deliveryFromGen(row), true, nil } func (s *Store) ListDeliveries(ctx context.Context, filter DeliveryFilter) ([]notification.DeliveryRow, error) { @@ -375,13 +365,15 @@ func (s *Store) updateDelivery(ctx context.Context, what string, query string, a } func (s *Store) getDeliveryByUniqueLocked(ctx context.Context, id domain.NotificationID, routeName, destinationKey string) (notification.DeliveryRow, error) { - row, err := scanDelivery(s.writeDB.QueryRowContext(ctx, `SELECT `+deliveryColumns+` -FROM notification_deliveries -WHERE notification_id = ? AND route_name = ? AND destination_key = ?`, string(id), routeName, destinationKey)) + row, err := s.qw.GetNotificationDeliveryByUnique(ctx, gen.GetNotificationDeliveryByUniqueParams{ + NotificationID: string(id), + RouteName: routeName, + DestinationKey: destinationKey, + }) if err != nil { return notification.DeliveryRow{}, fmt.Errorf("get notification delivery by unique key: %w", err) } - return row, nil + return deliveryFromGen(row), nil } type rowScanner interface { @@ -437,3 +429,34 @@ func scanDelivery(scanner rowScanner) (notification.DeliveryRow, error) { } return row, nil } + +func deliveryFromGen(r gen.NotificationDelivery) notification.DeliveryRow { + row := notification.DeliveryRow{ + ID: r.ID, + NotificationID: domain.NotificationID(r.NotificationID), + NotificationSeq: r.NotificationSeq, + ProjectID: domain.ProjectID(r.ProjectID), + SessionID: domain.SessionID(r.SessionID), + RouteName: r.RouteName, + Sink: r.Sink, + DestinationKey: r.DestinationKey, + RequestJSON: []byte(r.RequestJson), + Status: notification.DeliveryStatus(r.Status), + Attempts: int(r.Attempts), + MaxAttempts: int(r.MaxAttempts), + NextAttemptAt: r.NextAttemptAt, + LeaseOwner: r.LeaseOwner, + LastErrorCode: r.LastErrorCode, + LastError: r.LastError, + ExternalID: r.ExternalID, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } + if r.LeaseExpiresAt.Valid { + row.LeaseExpiresAt = r.LeaseExpiresAt.Time + } + if r.DeliveredAt.Valid { + row.DeliveredAt = r.DeliveredAt.Time + } + return row +} diff --git a/backend/notifier_wiring.go b/backend/notifier_wiring.go index 06ed53b847..5463859dd0 100644 --- a/backend/notifier_wiring.go +++ b/backend/notifier_wiring.go @@ -14,10 +14,10 @@ type notifierStack struct { done <-chan struct{} } -func startNotifier(ctx context.Context, cfg config.Config, store *sqlite.Store, log *slog.Logger) (*notifierStack, error) { +func startNotifier(ctx context.Context, cfg config.Config, store *sqlite.Store, log *slog.Logger) *notifierStack { mgr := notification.NewManager(store, notification.SettingsFromConfig(cfg), log) done := mgr.Start(ctx) - return ¬ifierStack{Manager: mgr, done: done}, nil + return ¬ifierStack{Manager: mgr, done: done} } func (s *notifierStack) Stop() { From f0c57ac2e2edd2a8995458287fdaf88dd2afad5d Mon Sep 17 00:00:00 2001 From: whoisasx Date: Mon, 1 Jun 2026 03:24:44 +0530 Subject: [PATCH 093/250] docs: clarify notification routing migration --- .../sqlite/migrations/0003_notification_deliveries.sql | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql b/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql index 66a97f100d..4c7cca1dba 100644 --- a/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql +++ b/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql @@ -1,6 +1,10 @@ -- +goose Up -- +goose StatementBegin ALTER TABLE notifications ADD COLUMN routed_at TIMESTAMP; +-- Notifications that already exist when this migration runs predate the +-- delivery runtime. Treat them as already routed so an upgrade does not +-- synthesize new AO-app desktop toasts for historical notification rows; the +-- dashboard read model still sees those rows directly from notifications. UPDATE notifications SET routed_at = updated_at WHERE routed_at IS NULL; CREATE INDEX idx_notifications_unrouted ON notifications(seq) From 5c07e818a17c8ebfd11cf339244e695eb78077ee Mon Sep 17 00:00:00 2001 From: whoisasx Date: Mon, 1 Jun 2026 03:49:31 +0530 Subject: [PATCH 094/250] fix: preserve notification surface disables --- backend/internal/daemon/daemon.go | 14 ++++++++++ .../{ => internal/daemon}/notifier_wiring.go | 2 +- backend/internal/notification/settings.go | 26 ------------------- .../internal/notification/settings_test.go | 21 +++++++++++++++ 4 files changed, 36 insertions(+), 27 deletions(-) rename backend/{ => internal/daemon}/notifier_wiring.go (97%) diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 556fe5f095..fd426e7c04 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -61,6 +61,8 @@ func Run() error { return err } + notifier := startNotifier(ctx, cfg, store, log) + // Terminal streaming: the tmux runtime supplies the PTY-attach command and // liveness; the CDC broadcaster feeds the session-state channel. The manager // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow @@ -71,6 +73,11 @@ func Run() error { srv, err := httpd.New(cfg, log, termMgr) if err != nil { + stop() + notifier.Stop() + if cdcErr := cdcPipe.Stop(); cdcErr != nil { + log.Error("cdc pipeline shutdown", "err", cdcErr) + } return err } @@ -79,6 +86,11 @@ func Run() error { // trigger -> change_log -> poller -> broadcaster. lcStack, err := startLifecycle(ctx, store, log) if err != nil { + stop() + notifier.Stop() + if cdcErr := cdcPipe.Stop(); cdcErr != nil { + log.Error("cdc pipeline shutdown", "err", cdcErr) + } return err } @@ -98,6 +110,7 @@ func Run() error { // the LIFO trap (see comment after srv.Run), hence explicit. stop() lcStack.Stop() + notifier.Stop() if cdcErr := cdcPipe.Stop(); cdcErr != nil { log.Error("cdc pipeline shutdown", "err", cdcErr) } @@ -113,6 +126,7 @@ func Run() error { // runs before the cancel — which would hang any non-signal exit path. stop() lcStack.Stop() + notifier.Stop() if err := cdcPipe.Stop(); err != nil { log.Error("cdc pipeline shutdown", "err", err) } diff --git a/backend/notifier_wiring.go b/backend/internal/daemon/notifier_wiring.go similarity index 97% rename from backend/notifier_wiring.go rename to backend/internal/daemon/notifier_wiring.go index 5463859dd0..5864b1e95d 100644 --- a/backend/notifier_wiring.go +++ b/backend/internal/daemon/notifier_wiring.go @@ -1,4 +1,4 @@ -package main +package daemon import ( "context" diff --git a/backend/internal/notification/settings.go b/backend/internal/notification/settings.go index abb8b65317..457c5dc277 100644 --- a/backend/internal/notification/settings.go +++ b/backend/internal/notification/settings.go @@ -33,15 +33,9 @@ func NormalizeSettings(in config.NotificationConfig) config.NotificationConfig { def := config.DefaultNotificationConfig() out := in - if isZeroDashboardConfig(in.Dashboard) { - out.Dashboard.Enabled = def.Dashboard.Enabled - } if out.Dashboard.Limit == 0 { out.Dashboard.Limit = def.Dashboard.Limit } - if isZeroDesktopConfig(in.Desktop) { - out.Desktop.Enabled = def.Desktop.Enabled - } if out.Desktop.Priorities == nil { out.Desktop.Priorities = append([]ports.Priority(nil), def.Desktop.Priorities...) } @@ -93,23 +87,3 @@ func cloneRoutes(in map[ports.Priority][]string) map[ports.Priority][]string { } return out } - -func isZeroNotificationConfig(c config.NotificationConfig) bool { - return !c.Enabled && - isZeroDashboardConfig(c.Dashboard) && - isZeroDesktopConfig(c.Desktop) && - c.Routing.Priorities == nil && - c.Retry.MaxAttempts == 0 && - c.Retry.BaseDelay == 0 && - c.Retry.MaxDelay == 0 && - c.Retry.LeaseTTL == 0 && - c.Retry.BatchSize == 0 -} - -func isZeroDashboardConfig(c config.DashboardNotificationConfig) bool { - return !c.Enabled && c.Limit == 0 -} - -func isZeroDesktopConfig(c config.DesktopNotificationConfig) bool { - return !c.Enabled && len(c.Priorities) == 0 && len(c.SoundPriorities) == 0 -} diff --git a/backend/internal/notification/settings_test.go b/backend/internal/notification/settings_test.go index bd854e9881..9fb9590f8d 100644 --- a/backend/internal/notification/settings_test.go +++ b/backend/internal/notification/settings_test.go @@ -28,6 +28,27 @@ func TestSettingsFromConfigPreservesExplicitGlobalDisable(t *testing.T) { } } +func TestNormalizeSettingsPreservesExplicitSurfaceDisables(t *testing.T) { + got := StaticSettings(config.NotificationConfig{ + Enabled: true, + Dashboard: config.DashboardNotificationConfig{Enabled: false}, + Desktop: config.DesktopNotificationConfig{Enabled: false}, + }).Settings(context.Background()) + + if !got.Enabled { + t.Fatalf("global notifications should remain enabled: %+v", got) + } + if got.Dashboard.Enabled { + t.Fatalf("explicit dashboard disable should stay disabled: %+v", got.Dashboard) + } + if got.Desktop.Enabled { + t.Fatalf("explicit desktop disable should stay disabled: %+v", got.Desktop) + } + if got.Dashboard.Limit != 50 || len(got.Desktop.Priorities) == 0 || got.Retry.MaxAttempts != 5 { + t.Fatalf("disabled surfaces should still receive non-bool defaults: %+v", got) + } +} + func TestNormalizeSettingsPreservesExplicitEmptyRoute(t *testing.T) { cfg := config.DefaultNotificationConfig() cfg.Routing.Priorities[ports.PriorityUrgent] = []string{} From cb2a00a0c24a8f4983bbe20ddbf4bc046309c6a6 Mon Sep 17 00:00:00 2001 From: prateek Date: Mon, 1 Jun 2026 04:46:38 +0530 Subject: [PATCH 095/250] Revert "feat: add notifier delivery runtime" --- backend/internal/cdc/event.go | 16 +- backend/internal/config/config.go | 72 --- backend/internal/config/config_test.go | 14 - backend/internal/daemon/daemon.go | 14 - backend/internal/daemon/notifier_wiring.go | 28 -- backend/internal/domain/notification.go | 1 - .../integration/notification_runtime_test.go | 82 ---- backend/internal/notification/delivery.go | 118 ----- backend/internal/notification/dispatcher.go | 36 -- .../internal/notification/dispatcher_test.go | 170 ------- backend/internal/notification/enqueuer.go | 10 +- backend/internal/notification/manager.go | 157 ------ backend/internal/notification/retry.go | 116 ----- backend/internal/notification/retry_test.go | 54 -- backend/internal/notification/routing.go | 72 --- backend/internal/notification/routing_test.go | 87 ---- backend/internal/notification/settings.go | 89 ---- .../internal/notification/settings_test.go | 76 --- backend/internal/notification/store.go | 25 - backend/internal/storage/sqlite/gen/models.go | 37 -- .../sqlite/gen/notification_deliveries.sql.go | 256 ---------- .../storage/sqlite/gen/notifications.sql.go | 30 +- .../internal/storage/sqlite/gen/querier.go | 5 - .../0003_notification_deliveries.sql | 128 ----- .../sqlite/notification_delivery_store.go | 462 ------------------ .../notification_delivery_store_test.go | 302 ------------ .../storage/sqlite/notification_store.go | 3 - .../queries/notification_deliveries.sql | 46 -- .../storage/sqlite/queries/notifications.sql | 20 +- 29 files changed, 32 insertions(+), 2494 deletions(-) delete mode 100644 backend/internal/daemon/notifier_wiring.go delete mode 100644 backend/internal/integration/notification_runtime_test.go delete mode 100644 backend/internal/notification/delivery.go delete mode 100644 backend/internal/notification/dispatcher.go delete mode 100644 backend/internal/notification/dispatcher_test.go delete mode 100644 backend/internal/notification/manager.go delete mode 100644 backend/internal/notification/retry.go delete mode 100644 backend/internal/notification/retry_test.go delete mode 100644 backend/internal/notification/routing.go delete mode 100644 backend/internal/notification/routing_test.go delete mode 100644 backend/internal/notification/settings.go delete mode 100644 backend/internal/notification/settings_test.go delete mode 100644 backend/internal/notification/store.go delete mode 100644 backend/internal/storage/sqlite/gen/notification_deliveries.sql.go delete mode 100644 backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql delete mode 100644 backend/internal/storage/sqlite/notification_delivery_store.go delete mode 100644 backend/internal/storage/sqlite/notification_delivery_store_test.go delete mode 100644 backend/internal/storage/sqlite/queries/notification_deliveries.sql diff --git a/backend/internal/cdc/event.go b/backend/internal/cdc/event.go index 35576cf6d1..5d37f47e26 100644 --- a/backend/internal/cdc/event.go +++ b/backend/internal/cdc/event.go @@ -18,15 +18,13 @@ import ( type EventType string const ( - EventSessionCreated EventType = "session_created" - EventSessionUpdated EventType = "session_updated" - EventPRCreated EventType = "pr_created" - EventPRUpdated EventType = "pr_updated" - EventPRCheckRecorded EventType = "pr_check_recorded" - EventNotificationCreated EventType = "notification_created" - EventNotificationUpdated EventType = "notification_updated" - EventNotificationDeliveryCreated EventType = "notification_delivery_created" - EventNotificationDeliveryUpdated EventType = "notification_delivery_updated" + EventSessionCreated EventType = "session_created" + EventSessionUpdated EventType = "session_updated" + EventPRCreated EventType = "pr_created" + EventPRUpdated EventType = "pr_updated" + EventPRCheckRecorded EventType = "pr_check_recorded" + EventNotificationCreated EventType = "notification_created" + EventNotificationUpdated EventType = "notification_updated" ) // Event is one CDC change read from change_log. Seq is the monotonic ordering + diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 62eb41a2e1..719e75244f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -11,8 +11,6 @@ import ( "path/filepath" "strconv" "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( @@ -52,46 +50,6 @@ type Config struct { // DataDir is the directory holding durable state (the SQLite database and // the CDC JSONL log). It is created on first use by the storage layer. DataDir string - // Notifications controls the central notifier runtime. The dashboard is the - // durable notifications table itself; desktop delivery is handed off to the - // AO Electron app via notification_deliveries rows. - Notifications NotificationConfig -} - -// NotificationConfig contains the global notification settings used by the -// central notifier runtime. It intentionally starts global (not per-project) so -// the routing model can grow without changing lifecycle reactions. -type NotificationConfig struct { - Enabled bool - Dashboard DashboardNotificationConfig - Desktop DesktopNotificationConfig - Routing NotificationRoutingConfig - Retry NotificationRetryConfig -} - -type DashboardNotificationConfig struct { - Enabled bool - Limit int -} - -type DesktopNotificationConfig struct { - Enabled bool - Priorities []ports.Priority - SoundPriorities []ports.Priority -} - -type NotificationRoutingConfig struct { - // Priorities maps notification priority to built-in route names. The - // notifier currently implements dashboard and desktop only. - Priorities map[ports.Priority][]string -} - -type NotificationRetryConfig struct { - MaxAttempts int - BaseDelay time.Duration - MaxDelay time.Duration - LeaseTTL time.Duration - BatchSize int } // Addr returns the host:port the HTTP server binds. It uses net.JoinHostPort so @@ -119,7 +77,6 @@ func Load() (Config, error) { Port: DefaultPort, RequestTimeout: DefaultRequestTimeout, ShutdownTimeout: DefaultShutdownTimeout, - Notifications: DefaultNotificationConfig(), } if raw := os.Getenv("AO_PORT"); raw != "" { @@ -164,35 +121,6 @@ func Load() (Config, error) { return cfg, nil } -// DefaultNotificationConfig returns the safe zero-setup notification settings. -func DefaultNotificationConfig() NotificationConfig { - return NotificationConfig{ - Enabled: true, - Dashboard: DashboardNotificationConfig{ - Enabled: true, - Limit: 50, - }, - Desktop: DesktopNotificationConfig{ - Enabled: true, - Priorities: []ports.Priority{ports.PriorityUrgent, ports.PriorityAction}, - SoundPriorities: []ports.Priority{ports.PriorityUrgent}, - }, - Routing: NotificationRoutingConfig{Priorities: map[ports.Priority][]string{ - ports.PriorityUrgent: []string{"dashboard", "desktop"}, - ports.PriorityAction: []string{"dashboard", "desktop"}, - ports.PriorityWarning: []string{"dashboard"}, - ports.PriorityInfo: []string{"dashboard"}, - }}, - Retry: NotificationRetryConfig{ - MaxAttempts: 5, - BaseDelay: time.Second, - MaxDelay: 5 * time.Minute, - LeaseTTL: 30 * time.Second, - BatchSize: 50, - }, - } -} - // parsePositiveDuration rejects zero and negative durations: a zero // RequestTimeout would expire every request instantly, and a non-positive // ShutdownTimeout would defeat graceful shutdown. diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 88f0d927b7..dfcb5b8af7 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -3,8 +3,6 @@ package config import ( "testing" "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) func TestLoadDefaults(t *testing.T) { @@ -33,18 +31,6 @@ func TestLoadDefaults(t *testing.T) { if cfg.RunFilePath == "" { t.Error("RunFilePath is empty, want a resolved default path") } - if !cfg.Notifications.Enabled || !cfg.Notifications.Dashboard.Enabled || !cfg.Notifications.Desktop.Enabled { - t.Fatalf("notification defaults should be enabled: %+v", cfg.Notifications) - } - if cfg.Notifications.Dashboard.Limit != 50 { - t.Fatalf("dashboard limit = %d, want 50", cfg.Notifications.Dashboard.Limit) - } - if got := cfg.Notifications.Routing.Priorities[ports.PriorityUrgent]; len(got) != 2 || got[0] != "dashboard" || got[1] != "desktop" { - t.Fatalf("urgent routes = %v, want dashboard+desktop", got) - } - if cfg.Notifications.Retry.MaxAttempts != 5 || cfg.Notifications.Retry.LeaseTTL != 30*time.Second { - t.Fatalf("retry defaults = %+v", cfg.Notifications.Retry) - } } func TestLoadOverrides(t *testing.T) { diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index fd426e7c04..556fe5f095 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -61,8 +61,6 @@ func Run() error { return err } - notifier := startNotifier(ctx, cfg, store, log) - // Terminal streaming: the tmux runtime supplies the PTY-attach command and // liveness; the CDC broadcaster feeds the session-state channel. The manager // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow @@ -73,11 +71,6 @@ func Run() error { srv, err := httpd.New(cfg, log, termMgr) if err != nil { - stop() - notifier.Stop() - if cdcErr := cdcPipe.Stop(); cdcErr != nil { - log.Error("cdc pipeline shutdown", "err", cdcErr) - } return err } @@ -86,11 +79,6 @@ func Run() error { // trigger -> change_log -> poller -> broadcaster. lcStack, err := startLifecycle(ctx, store, log) if err != nil { - stop() - notifier.Stop() - if cdcErr := cdcPipe.Stop(); cdcErr != nil { - log.Error("cdc pipeline shutdown", "err", cdcErr) - } return err } @@ -110,7 +98,6 @@ func Run() error { // the LIFO trap (see comment after srv.Run), hence explicit. stop() lcStack.Stop() - notifier.Stop() if cdcErr := cdcPipe.Stop(); cdcErr != nil { log.Error("cdc pipeline shutdown", "err", cdcErr) } @@ -126,7 +113,6 @@ func Run() error { // runs before the cancel — which would hang any non-signal exit path. stop() lcStack.Stop() - notifier.Stop() if err := cdcPipe.Stop(); err != nil { log.Error("cdc pipeline shutdown", "err", err) } diff --git a/backend/internal/daemon/notifier_wiring.go b/backend/internal/daemon/notifier_wiring.go deleted file mode 100644 index 5864b1e95d..0000000000 --- a/backend/internal/daemon/notifier_wiring.go +++ /dev/null @@ -1,28 +0,0 @@ -package daemon - -import ( - "context" - "log/slog" - - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/notification" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" -) - -type notifierStack struct { - Manager *notification.Manager - done <-chan struct{} -} - -func startNotifier(ctx context.Context, cfg config.Config, store *sqlite.Store, log *slog.Logger) *notifierStack { - mgr := notification.NewManager(store, notification.SettingsFromConfig(cfg), log) - done := mgr.Start(ctx) - return ¬ifierStack{Manager: mgr, done: done} -} - -func (s *notifierStack) Stop() { - if s == nil || s.done == nil { - return - } - <-s.done -} diff --git a/backend/internal/domain/notification.go b/backend/internal/domain/notification.go index 8af4955019..8c64c9bcde 100644 --- a/backend/internal/domain/notification.go +++ b/backend/internal/domain/notification.go @@ -27,7 +27,6 @@ type Notification struct { CauseKey string ReadAt time.Time ArchivedAt time.Time - RoutedAt time.Time CreatedAt time.Time UpdatedAt time.Time } diff --git a/backend/internal/integration/notification_runtime_test.go b/backend/internal/integration/notification_runtime_test.go deleted file mode 100644 index c837bf293d..0000000000 --- a/backend/internal/integration/notification_runtime_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "io" - "log/slog" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/notification" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" -) - -func TestNotificationRuntimeRoutesDesktopEligiblePriorities(t *testing.T) { - t.Parallel() - ctx := context.Background() - store, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatal(err) - } - defer store.Close() - seedProject(t, store, "mer") - rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-55", "feat/notifier")) - if err != nil { - t.Fatal(err) - } - - urgent := enqueueRuntimeNotification(t, store, rec, "urgent", "urgent") - action := enqueueRuntimeNotification(t, store, rec, "action", "action") - info := enqueueRuntimeNotification(t, store, rec, "info", "info") - - mgr := notification.NewManager(store, notification.StaticSettings(config.DefaultNotificationConfig()), slog.New(slog.NewTextHandler(io.Discard, nil))) - routed, err := mgr.RoutePending(ctx, 50) - if err != nil { - t.Fatal(err) - } - if routed != 3 { - t.Fatalf("routed = %d, want 3", routed) - } - - for _, ntf := range []domain.Notification{urgent, action} { - rows, err := store.ListDeliveries(ctx, sqlite.DeliveryFilter{NotificationID: string(ntf.ID), Limit: 10}) - if err != nil { - t.Fatal(err) - } - if len(rows) != 1 || rows[0].Sink != notification.SinkAOApp || rows[0].RouteName != notification.RouteDesktop { - t.Fatalf("%s should have one AO-app desktop delivery, got %+v", ntf.Priority, rows) - } - } - rows, err := store.ListDeliveries(ctx, sqlite.DeliveryFilter{NotificationID: string(info.ID), Limit: 10}) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("info should remain dashboard/read-model only, got deliveries %+v", rows) - } -} - -func enqueueRuntimeNotification(t *testing.T, store *sqlite.Store, rec domain.SessionRecord, priority, dedupe string) domain.Notification { - t.Helper() - now := time.Now().UTC().Truncate(time.Second) - row, _, err := store.EnqueueNotification(context.Background(), domain.Notification{ - ProjectID: rec.ProjectID, - SessionID: rec.ID, - Source: "lifecycle", - EventType: "reaction.test", - SemanticType: "test." + priority, - Priority: priority, - Message: "test " + priority, - Payload: json.RawMessage(`{}`), - DedupeKey: "runtime-" + dedupe, - CreatedAt: now, - UpdatedAt: now, - }) - if err != nil { - t.Fatalf("enqueue notification: %v", err) - } - return row -} diff --git a/backend/internal/notification/delivery.go b/backend/internal/notification/delivery.go deleted file mode 100644 index c7fa031160..0000000000 --- a/backend/internal/notification/delivery.go +++ /dev/null @@ -1,118 +0,0 @@ -package notification - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -var ErrDeliveryUpdateConflict = errors.New("notification delivery update conflict") - -const ( - RouteDashboard = "dashboard" - RouteDesktop = "desktop" - - SinkAOApp = "ao-app" - SinkUnknown = "unknown" -) - -type DeliveryStatus string - -const ( - DeliveryQueued DeliveryStatus = "queued" - DeliveryLeased DeliveryStatus = "leased" - DeliverySent DeliveryStatus = "sent" - DeliveryRetryWait DeliveryStatus = "retry_wait" - DeliveryFailed DeliveryStatus = "failed" - DeliverySkipped DeliveryStatus = "skipped" - DeliveryCancelled DeliveryStatus = "cancelled" -) - -// DeliveryRow is the durable handoff state for one notification route. The -// backend creates AO-app rows; Electron claims them later and reports success or -// failure. External sinks can use the same shape in future issues. -type DeliveryRow struct { - ID string - NotificationID domain.NotificationID - NotificationSeq int64 - ProjectID domain.ProjectID - SessionID domain.SessionID - - RouteName string - Sink string - DestinationKey string - RequestJSON json.RawMessage - - Status DeliveryStatus - Attempts int - MaxAttempts int - NextAttemptAt time.Time - LeaseOwner string - // LeaseExpiresAt is zero when the row is not leased. - LeaseExpiresAt time.Time - - LastErrorCode string - LastError string - ExternalID string - - CreatedAt time.Time - UpdatedAt time.Time - DeliveredAt time.Time -} - -func NewDeliveryID() (string, error) { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - return "", fmt.Errorf("generate delivery id: %w", err) - } - return "del_" + hex.EncodeToString(b[:]), nil -} - -func NormalizeDelivery(row DeliveryRow, now time.Time, maxAttempts int) (DeliveryRow, error) { - if row.ID == "" { - id, err := NewDeliveryID() - if err != nil { - return DeliveryRow{}, err - } - row.ID = id - } - if len(row.RequestJSON) == 0 { - row.RequestJSON = json.RawMessage(`{}`) - } - if !json.Valid(row.RequestJSON) { - return DeliveryRow{}, fmt.Errorf("invalid delivery request JSON for %s", row.ID) - } - if row.Status == "" { - row.Status = DeliveryQueued - } - if row.MaxAttempts <= 0 { - row.MaxAttempts = maxAttempts - } - if row.MaxAttempts <= 0 { - row.MaxAttempts = 1 - } - if row.NextAttemptAt.IsZero() { - row.NextAttemptAt = now - } - if row.CreatedAt.IsZero() { - row.CreatedAt = now - } - if row.UpdatedAt.IsZero() { - row.UpdatedAt = row.CreatedAt - } - return row, nil -} - -func TerminalStatus(s DeliveryStatus) bool { - switch s { - case DeliverySent, DeliveryFailed, DeliverySkipped, DeliveryCancelled: - return true - default: - return false - } -} diff --git a/backend/internal/notification/dispatcher.go b/backend/internal/notification/dispatcher.go deleted file mode 100644 index 19d0cbf554..0000000000 --- a/backend/internal/notification/dispatcher.go +++ /dev/null @@ -1,36 +0,0 @@ -package notification - -import ( - "context" - "time" -) - -func startDispatcher(ctx context.Context, m *Manager) <-chan struct{} { - done := make(chan struct{}) - go func() { - defer close(done) - runDispatcherOnce(ctx, m) - - interval := m.interval - if interval <= 0 { - interval = time.Second - } - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - runDispatcherOnce(ctx, m) - } - } - }() - return done -} - -func runDispatcherOnce(ctx context.Context, m *Manager) { - if err := m.RunOnce(ctx); err != nil { - m.logger.ErrorContext(ctx, "notification dispatcher tick", "err", err) - } -} diff --git a/backend/internal/notification/dispatcher_test.go b/backend/internal/notification/dispatcher_test.go deleted file mode 100644 index 34b7b23e69..0000000000 --- a/backend/internal/notification/dispatcher_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package notification - -import ( - "context" - "errors" - "io" - "log/slog" - "sync" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -type fakeRuntimeStore struct { - mu sync.Mutex - unrouted []domain.Notification - deliveries []DeliveryRow - byID map[string]DeliveryRow - routed []domain.NotificationID - releases int - retryNext time.Time - failEnqueue map[domain.NotificationID]error -} - -func (f *fakeRuntimeStore) ListUnroutedNotifications(context.Context, int) ([]domain.Notification, error) { - f.mu.Lock() - defer f.mu.Unlock() - return append([]domain.Notification(nil), f.unrouted...), nil -} - -func (f *fakeRuntimeStore) MarkNotificationRouted(_ context.Context, id domain.NotificationID, _ time.Time) error { - f.mu.Lock() - defer f.mu.Unlock() - f.routed = append(f.routed, id) - return nil -} - -func (f *fakeRuntimeStore) EnqueueDelivery(_ context.Context, row DeliveryRow) (DeliveryRow, bool, error) { - f.mu.Lock() - defer f.mu.Unlock() - if err := f.failEnqueue[row.NotificationID]; err != nil { - return DeliveryRow{}, false, err - } - f.deliveries = append(f.deliveries, row) - return row, true, nil -} - -func (f *fakeRuntimeStore) GetDelivery(_ context.Context, id string) (DeliveryRow, bool, error) { - f.mu.Lock() - defer f.mu.Unlock() - row, ok := f.byID[id] - return row, ok, nil -} - -func (f *fakeRuntimeStore) ClaimDueDeliveries(context.Context, string, string, time.Time, int, time.Duration) ([]DeliveryRow, error) { - return nil, nil -} -func (f *fakeRuntimeStore) ReleaseExpiredDeliveryLeases(context.Context, time.Time) (int, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.releases++ - return 0, nil -} -func (f *fakeRuntimeStore) MarkDeliverySent(context.Context, string, string, string, time.Time) error { - return nil -} -func (f *fakeRuntimeStore) MarkDeliveryRetry(_ context.Context, _ string, _ string, _ string, _ string, next time.Time, _ time.Time) error { - f.mu.Lock() - defer f.mu.Unlock() - f.retryNext = next - return nil -} -func (f *fakeRuntimeStore) MarkDeliveryFailed(context.Context, string, string, string, string, time.Time) error { - return nil -} -func (f *fakeRuntimeStore) MarkDeliverySkipped(context.Context, string, string, time.Time) error { - return nil -} - -func TestDispatcherStartReleasesAndStops(t *testing.T) { - store := &fakeRuntimeStore{} - mgr := NewManager(store, StaticSettings(config.DefaultNotificationConfig()), discardLogger()) - mgr.interval = 10 * time.Millisecond - ctx, cancel := context.WithCancel(context.Background()) - done := mgr.Start(ctx) - - deadline := time.After(time.Second) - for { - store.mu.Lock() - released := store.releases - store.mu.Unlock() - if released > 0 { - break - } - select { - case <-deadline: - t.Fatal("dispatcher did not run initial release") - case <-time.After(time.Millisecond): - } - } - cancel() - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("dispatcher did not stop after context cancel") - } -} - -func TestRoutePendingDeliveryFailureDoesNotBlockOtherNotifications(t *testing.T) { - n1 := sampleDomainNotification("ntf_1", "urgent") - n2 := sampleDomainNotification("ntf_2", "urgent") - store := &fakeRuntimeStore{ - unrouted: []domain.Notification{n1, n2}, - failEnqueue: map[domain.NotificationID]error{n1.ID: errors.New("boom")}, - } - mgr := NewManager(store, StaticSettings(config.DefaultNotificationConfig()), discardLogger()) - - routed, err := mgr.RoutePending(context.Background(), 10) - if err == nil { - t.Fatal("RoutePending should return the first routing error") - } - if routed != 1 { - t.Fatalf("routed = %d, want one successful notification", routed) - } - store.mu.Lock() - defer store.mu.Unlock() - if len(store.routed) != 1 || store.routed[0] != n2.ID { - t.Fatalf("routed IDs = %v, want only %s", store.routed, n2.ID) - } -} - -func TestMarkDeliveryErrorUsesAttemptAwareBackoff(t *testing.T) { - now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - cfg := config.DefaultNotificationConfig() - cfg.Retry.BaseDelay = time.Second - cfg.Retry.MaxDelay = time.Minute - store := &fakeRuntimeStore{byID: map[string]DeliveryRow{ - "del_1": {ID: "del_1", Attempts: 2, MaxAttempts: 5}, - }} - mgr := NewManager(store, StaticSettings(cfg), discardLogger()) - mgr.clock = func() time.Time { return now } - - if err := mgr.MarkDeliveryError(context.Background(), "del_1", "electron", "timeout", "timed out"); err != nil { - t.Fatal(err) - } - store.mu.Lock() - next := store.retryNext - store.mu.Unlock() - delay := next.Sub(now) - if delay < 3200*time.Millisecond || delay > 4800*time.Millisecond { - t.Fatalf("retry delay for third attempt = %s, want jittered 4s backoff", delay) - } -} - -func sampleDomainNotification(id domain.NotificationID, priority string) domain.Notification { - return domain.Notification{ - Seq: 1, - ID: id, - ProjectID: "ao", - SessionID: "ao-1", - Priority: priority, - Message: "hello", - } -} - -func discardLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} diff --git a/backend/internal/notification/enqueuer.go b/backend/internal/notification/enqueuer.go index 5953830537..79e902bf84 100644 --- a/backend/internal/notification/enqueuer.go +++ b/backend/internal/notification/enqueuer.go @@ -8,23 +8,23 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// EnqueueStore is the durable write-side used by the enqueuer. *sqlite.Store -// satisfies this interface. -type EnqueueStore interface { +// Store is the durable write-side used by the enqueuer. *sqlite.Store satisfies +// this interface. +type Store interface { EnqueueNotification(ctx context.Context, row domain.Notification) (domain.Notification, bool, error) } // Enqueuer is a store-backed ports.Notifier. It does not deliver to external // sinks; it renders and persists the notification for later dashboard/app sinks. type Enqueuer struct { - store EnqueueStore + store Store renderer *Renderer logger *slog.Logger } var _ ports.Notifier = (*Enqueuer)(nil) -func NewEnqueuer(store EnqueueStore, renderer *Renderer, logger *slog.Logger) *Enqueuer { +func NewEnqueuer(store Store, renderer *Renderer, logger *slog.Logger) *Enqueuer { if logger == nil { logger = slog.Default() } diff --git a/backend/internal/notification/manager.go b/backend/internal/notification/manager.go deleted file mode 100644 index 235f893e7b..0000000000 --- a/backend/internal/notification/manager.go +++ /dev/null @@ -1,157 +0,0 @@ -package notification - -import ( - "context" - "errors" - "fmt" - "log/slog" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -type Manager struct { - store Store - settings SettingsProvider - clock func() time.Time - logger *slog.Logger - - interval time.Duration -} - -func NewManager(store Store, settings SettingsProvider, logger *slog.Logger) *Manager { - if logger == nil { - logger = slog.Default() - } - if settings == nil { - settings = StaticSettings(config.DefaultNotificationConfig()) - } - return &Manager{ - store: store, - settings: settings, - clock: time.Now, - logger: logger, - interval: time.Second, - } -} - -func (m *Manager) Start(ctx context.Context) <-chan struct{} { - return startDispatcher(ctx, m) -} - -// RunOnce performs one maintenance/routing pass. It is exposed for tests and -// for future API-triggered nudges; Start calls it on every dispatcher tick. -func (m *Manager) RunOnce(ctx context.Context) error { - settings := m.settings.Settings(ctx) - policy := RetryPolicyFromConfig(settings.Retry) - now := m.clock().UTC() - - if released, err := m.store.ReleaseExpiredDeliveryLeases(ctx, now); err != nil { - return err - } else if released > 0 { - m.logger.DebugContext(ctx, "notification delivery leases released", "count", released) - } - - _, err := m.RoutePending(ctx, policy.BatchSize) - return err -} - -func (m *Manager) RoutePending(ctx context.Context, limit int) (int, error) { - if limit <= 0 { - limit = RetryPolicyFromConfig(m.settings.Settings(ctx).Retry).BatchSize - } - rows, err := m.store.ListUnroutedNotifications(ctx, limit) - if err != nil { - return 0, err - } - var firstErr error - var routed int - for _, row := range rows { - if err := m.RouteNotification(ctx, row); err != nil { - m.logger.ErrorContext(ctx, "route notification", "notification", row.ID, "err", err) - if firstErr == nil { - firstErr = err - } else { - firstErr = errors.Join(firstErr, err) - } - continue - } - routed++ - } - return routed, firstErr -} - -func (m *Manager) RouteNotification(ctx context.Context, row domain.Notification) error { - settings := m.settings.Settings(ctx) - now := m.clock().UTC() - if !settings.Enabled || !row.ArchivedAt.IsZero() { - return m.store.MarkNotificationRouted(ctx, row.ID, now) - } - - decisions := ResolveRoutes(settings, ports.Priority(row.Priority)) - maxAttempts := RetryPolicyFromConfig(settings.Retry).MaxAttempts - for _, decision := range decisions { - if !decision.CreateDelivery { - continue - } - delivery := DeliveryRow{ - NotificationID: row.ID, - NotificationSeq: row.Seq, - ProjectID: row.ProjectID, - SessionID: row.SessionID, - RouteName: decision.RouteName, - Sink: decision.Sink, - DestinationKey: decision.DestinationKey, - Status: decision.Status, - MaxAttempts: maxAttempts, - NextAttemptAt: now, - CreatedAt: now, - UpdatedAt: now, - } - if delivery.Status == "" { - delivery.Status = DeliveryQueued - } - if decision.Reason != "" { - delivery.LastErrorCode = "route_skipped" - delivery.LastError = decision.Reason - } - if _, _, err := m.store.EnqueueDelivery(ctx, delivery); err != nil { - return err - } - } - return m.store.MarkNotificationRouted(ctx, row.ID, now) -} - -func (m *Manager) ClaimDesktopDeliveries(ctx context.Context, owner string, limit int) ([]DeliveryRow, error) { - settings := m.settings.Settings(ctx) - policy := RetryPolicyFromConfig(settings.Retry) - return m.store.ClaimDueDeliveries(ctx, SinkAOApp, owner, m.clock().UTC(), limit, policy.LeaseTTL) -} - -func (m *Manager) MarkDeliverySent(ctx context.Context, id, owner, externalID string) error { - return m.store.MarkDeliverySent(ctx, id, owner, externalID, m.clock().UTC()) -} - -func (m *Manager) MarkDeliveryError(ctx context.Context, id, owner, code, message string) error { - settings := m.settings.Settings(ctx) - policy := RetryPolicyFromConfig(settings.Retry) - now := m.clock().UTC() - // The store is the source of truth for attempts/max-attempt terminal - // handling. Permanent classification short-circuits to failed; otherwise we - // fetch the current delivery attempts and provide the attempt-aware next - // retry timestamp for retry_wait rows. - if ClassifyError(code) == ErrorPermanent { - return m.store.MarkDeliveryFailed(ctx, id, owner, code, message, now) - } - row, ok, err := m.store.GetDelivery(ctx, id) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("notification delivery %s not found", id) - } - nextAttemptNo := row.Attempts + 1 - return m.store.MarkDeliveryRetry(ctx, id, owner, code, message, policy.NextAttemptAt(now, nextAttemptNo), now) -} diff --git a/backend/internal/notification/retry.go b/backend/internal/notification/retry.go deleted file mode 100644 index 72c54775a3..0000000000 --- a/backend/internal/notification/retry.go +++ /dev/null @@ -1,116 +0,0 @@ -package notification - -import ( - crand "crypto/rand" - "encoding/binary" - "math" - "strings" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/config" -) - -const retryJitterFraction = 0.20 - -type RetryPolicy struct { - MaxAttempts int - BaseDelay time.Duration - MaxDelay time.Duration - LeaseTTL time.Duration - BatchSize int - Jitter float64 - RandFloat64 func() float64 -} - -func RetryPolicyFromConfig(cfg config.NotificationRetryConfig) RetryPolicy { - settings := NormalizeSettings(config.NotificationConfig{Enabled: true, Retry: cfg}) - return RetryPolicy{ - MaxAttempts: settings.Retry.MaxAttempts, - BaseDelay: settings.Retry.BaseDelay, - MaxDelay: settings.Retry.MaxDelay, - LeaseTTL: settings.Retry.LeaseTTL, - BatchSize: settings.Retry.BatchSize, - Jitter: retryJitterFraction, - RandFloat64: cryptoRandFloat64, - } -} - -func (p RetryPolicy) normalized() RetryPolicy { - cfg := config.NotificationRetryConfig{ - MaxAttempts: p.MaxAttempts, - BaseDelay: p.BaseDelay, - MaxDelay: p.MaxDelay, - LeaseTTL: p.LeaseTTL, - BatchSize: p.BatchSize, - } - out := RetryPolicyFromConfig(cfg) - if p.Jitter != 0 { - out.Jitter = p.Jitter - } - if p.RandFloat64 != nil { - out.RandFloat64 = p.RandFloat64 - } - return out -} - -// BackoffDelay returns exponential backoff for the already-recorded attempt -// count. attempt=1 returns the base delay; delays are capped before jitter. -func (p RetryPolicy) BackoffDelay(attempt int) time.Duration { - p = p.normalized() - if attempt < 1 { - attempt = 1 - } - mult := math.Pow(2, float64(attempt-1)) - delay := time.Duration(float64(p.BaseDelay) * mult) - if delay > p.MaxDelay || delay <= 0 { - delay = p.MaxDelay - } - if p.Jitter <= 0 { - return delay - } - randFloat := p.RandFloat64 - if randFloat == nil { - randFloat = cryptoRandFloat64 - } - // rand in [0,1) -> factor in [1-jitter, 1+jitter) - factor := 1 - p.Jitter + (2 * p.Jitter * randFloat()) - return time.Duration(float64(delay) * factor) -} - -func cryptoRandFloat64() float64 { - var b [8]byte - if _, err := crand.Read(b[:]); err != nil { - // Fall back to a time-derived value only if the OS CSPRNG fails. The - // fallback still avoids math/rand's deterministic process seed. - return float64(time.Now().UnixNano()&((1<<53)-1)) / float64(1<<53) - } - // Match math/rand.Float64's 53 bits of precision in [0,1). - return float64(binary.BigEndian.Uint64(b[:])>>11) / float64(1<<53) -} - -func (p RetryPolicy) NextAttemptAt(now time.Time, attempt int) time.Time { - return now.Add(p.BackoffDelay(attempt)) -} - -type ErrorClass string - -const ( - ErrorTransient ErrorClass = "transient" - ErrorPermanent ErrorClass = "permanent" -) - -func ClassifyError(code string) ErrorClass { - switch strings.ToLower(strings.TrimSpace(code)) { - case "permanent", "invalid_request", "bad_request", "unauthorized", "forbidden", "not_found", "unsupported_route", "route_disabled": - return ErrorPermanent - default: - return ErrorTransient - } -} - -func ShouldRetry(code string, attempts, maxAttempts int) bool { - if maxAttempts <= 0 { - maxAttempts = 1 - } - return ClassifyError(code) != ErrorPermanent && attempts < maxAttempts -} diff --git a/backend/internal/notification/retry_test.go b/backend/internal/notification/retry_test.go deleted file mode 100644 index c6d37155c9..0000000000 --- a/backend/internal/notification/retry_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package notification - -import ( - "testing" - "time" -) - -func TestRetryBackoffExponentialCapped(t *testing.T) { - p := RetryPolicy{ - BaseDelay: time.Second, - MaxDelay: 5 * time.Second, - Jitter: retryJitterFraction, - RandFloat64: func() float64 { return 0.5 }, - } - if got := p.BackoffDelay(1); got != time.Second { - t.Fatalf("attempt 1 delay = %s, want 1s", got) - } - if got := p.BackoffDelay(2); got != 2*time.Second { - t.Fatalf("attempt 2 delay = %s, want 2s", got) - } - if got := p.BackoffDelay(4); got != 5*time.Second { - t.Fatalf("attempt 4 delay = %s, want capped 5s", got) - } -} - -func TestRetryJitterBounds(t *testing.T) { - base := RetryPolicy{BaseDelay: 10 * time.Second, MaxDelay: time.Minute, Jitter: retryJitterFraction} - low := base - low.RandFloat64 = func() float64 { return 0 } - high := base - high.RandFloat64 = func() float64 { return 1 } - - if got := low.BackoffDelay(1); got != 8*time.Second { - t.Fatalf("low jitter = %s, want 8s", got) - } - if got := high.BackoffDelay(1); got != 12*time.Second { - t.Fatalf("high jitter = %s, want 12s", got) - } -} - -func TestErrorClassificationRetry(t *testing.T) { - if ClassifyError("invalid_request") != ErrorPermanent { - t.Fatal("invalid_request should be permanent") - } - if ShouldRetry("invalid_request", 1, 5) { - t.Fatal("permanent errors should not retry") - } - if !ShouldRetry("timeout", 1, 5) { - t.Fatal("transient errors under max attempts should retry") - } - if ShouldRetry("timeout", 5, 5) { - t.Fatal("max attempts should stop retry") - } -} diff --git a/backend/internal/notification/routing.go b/backend/internal/notification/routing.go deleted file mode 100644 index 36a13d9bd7..0000000000 --- a/backend/internal/notification/routing.go +++ /dev/null @@ -1,72 +0,0 @@ -package notification - -import ( - "slices" - - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -type RouteDecision struct { - RouteName string - Sink string - DestinationKey string - Status DeliveryStatus - Reason string - CreateDelivery bool -} - -// ResolveRoutes resolves the configured built-in routes for one notification. -// Dashboard is a read model over the notifications table, so it is represented -// in the decision list but never creates a delivery row. Unknown explicitly -// configured routes become skipped delivery rows for operator visibility. -func ResolveRoutes(settings config.NotificationConfig, priority ports.Priority) []RouteDecision { - settings = NormalizeSettings(settings) - if !settings.Enabled { - return nil - } - - routes, ok := settings.Routing.Priorities[priority] - if !ok { - return nil - } - out := make([]RouteDecision, 0, len(routes)) - for _, name := range routes { - switch name { - case RouteDashboard: - if settings.Dashboard.Enabled { - out = append(out, RouteDecision{RouteName: RouteDashboard}) - } - case RouteDesktop: - if settings.Desktop.Enabled && priorityAllowed(priority, settings.Desktop.Priorities) { - out = append(out, RouteDecision{ - RouteName: RouteDesktop, - Sink: SinkAOApp, - Status: DeliveryQueued, - CreateDelivery: true, - }) - } - case "": - // Ignore empty route names so a stray trailing separator in future - // config parsing does not create a permanent skipped delivery. - default: - out = append(out, RouteDecision{ - RouteName: name, - Sink: SinkUnknown, - Status: DeliverySkipped, - Reason: "unknown route", - CreateDelivery: true, - }) - } - } - return out -} - -func DesktopEligible(settings config.NotificationConfig, priority ports.Priority) bool { - settings = NormalizeSettings(settings) - return settings.Enabled && settings.Desktop.Enabled && priorityAllowed(priority, settings.Desktop.Priorities) -} - -func priorityAllowed(p ports.Priority, allowed []ports.Priority) bool { - return slices.Contains(allowed, p) -} diff --git a/backend/internal/notification/routing_test.go b/backend/internal/notification/routing_test.go deleted file mode 100644 index 913da8c28d..0000000000 --- a/backend/internal/notification/routing_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package notification - -import ( - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -func TestResolveRoutes_Defaults(t *testing.T) { - cfg := config.DefaultNotificationConfig() - tests := []struct { - priority ports.Priority - want []string - }{ - {ports.PriorityUrgent, []string{RouteDashboard, RouteDesktop}}, - {ports.PriorityAction, []string{RouteDashboard, RouteDesktop}}, - {ports.PriorityWarning, []string{RouteDashboard}}, - {ports.PriorityInfo, []string{RouteDashboard}}, - } - for _, tc := range tests { - t.Run(string(tc.priority), func(t *testing.T) { - got := routeNames(ResolveRoutes(cfg, tc.priority)) - if len(got) != len(tc.want) { - t.Fatalf("routes = %v, want %v", got, tc.want) - } - for i := range tc.want { - if got[i] != tc.want[i] { - t.Fatalf("routes = %v, want %v", got, tc.want) - } - } - }) - } -} - -func TestResolveRoutes_DesktopDisabledOrIneligible(t *testing.T) { - cfg := config.DefaultNotificationConfig() - cfg.Desktop.Enabled = false - got := routeNames(ResolveRoutes(cfg, ports.PriorityUrgent)) - if len(got) != 1 || got[0] != RouteDashboard { - t.Fatalf("desktop disabled routes = %v, want dashboard only", got) - } - - cfg = config.DefaultNotificationConfig() - cfg.Routing.Priorities[ports.PriorityInfo] = []string{RouteDashboard, RouteDesktop} - got = routeNames(ResolveRoutes(cfg, ports.PriorityInfo)) - if len(got) != 1 || got[0] != RouteDashboard { - t.Fatalf("info desktop ineligible routes = %v, want dashboard only", got) - } -} - -func TestResolveRoutes_GlobalDisabled(t *testing.T) { - cfg := config.DefaultNotificationConfig() - cfg.Enabled = false - if got := ResolveRoutes(cfg, ports.PriorityUrgent); len(got) != 0 { - t.Fatalf("globally disabled routes = %+v, want none", got) - } -} - -func TestResolveRoutes_ExplicitEmptySuppressesPriority(t *testing.T) { - cfg := config.DefaultNotificationConfig() - cfg.Routing.Priorities[ports.PriorityUrgent] = []string{} - if got := ResolveRoutes(cfg, ports.PriorityUrgent); len(got) != 0 { - t.Fatalf("explicit empty routes = %+v, want none", got) - } -} - -func TestResolveRoutes_UnknownExplicitRouteSkipped(t *testing.T) { - cfg := config.DefaultNotificationConfig() - cfg.Routing.Priorities[ports.PriorityUrgent] = []string{RouteDashboard, "pager"} - got := ResolveRoutes(cfg, ports.PriorityUrgent) - if len(got) != 2 { - t.Fatalf("routes = %+v, want dashboard + skipped unknown", got) - } - unknown := got[1] - if unknown.RouteName != "pager" || unknown.Status != DeliverySkipped || !unknown.CreateDelivery || unknown.Sink != SinkUnknown { - t.Fatalf("unknown route decision = %+v", unknown) - } -} - -func routeNames(routes []RouteDecision) []string { - out := make([]string, len(routes)) - for i, r := range routes { - out[i] = r.RouteName - } - return out -} diff --git a/backend/internal/notification/settings.go b/backend/internal/notification/settings.go deleted file mode 100644 index 457c5dc277..0000000000 --- a/backend/internal/notification/settings.go +++ /dev/null @@ -1,89 +0,0 @@ -package notification - -import ( - "context" - - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -type SettingsProvider interface { - Settings(ctx context.Context) config.NotificationConfig -} - -type staticSettings struct { - cfg config.NotificationConfig -} - -func SettingsFromConfig(cfg config.Config) SettingsProvider { - return staticSettings{cfg: NormalizeSettings(cfg.Notifications)} -} - -func StaticSettings(cfg config.NotificationConfig) SettingsProvider { - return staticSettings{cfg: NormalizeSettings(cfg)} -} - -func (s staticSettings) Settings(context.Context) config.NotificationConfig { - return cloneSettings(s.cfg) -} - -// NormalizeSettings fills unset settings with safe defaults while preserving -// explicit route overrides, including an explicit empty route list. -func NormalizeSettings(in config.NotificationConfig) config.NotificationConfig { - def := config.DefaultNotificationConfig() - out := in - - if out.Dashboard.Limit == 0 { - out.Dashboard.Limit = def.Dashboard.Limit - } - if out.Desktop.Priorities == nil { - out.Desktop.Priorities = append([]ports.Priority(nil), def.Desktop.Priorities...) - } - if out.Desktop.SoundPriorities == nil { - out.Desktop.SoundPriorities = append([]ports.Priority(nil), def.Desktop.SoundPriorities...) - } - if out.Routing.Priorities == nil { - out.Routing.Priorities = cloneRoutes(def.Routing.Priorities) - } else { - merged := cloneRoutes(def.Routing.Priorities) - for p, routes := range out.Routing.Priorities { - merged[p] = append([]string(nil), routes...) - } - out.Routing.Priorities = merged - } - if out.Retry.MaxAttempts == 0 { - out.Retry.MaxAttempts = def.Retry.MaxAttempts - } - if out.Retry.BaseDelay == 0 { - out.Retry.BaseDelay = def.Retry.BaseDelay - } - if out.Retry.MaxDelay == 0 { - out.Retry.MaxDelay = def.Retry.MaxDelay - } - if out.Retry.LeaseTTL == 0 { - out.Retry.LeaseTTL = def.Retry.LeaseTTL - } - if out.Retry.BatchSize == 0 { - out.Retry.BatchSize = def.Retry.BatchSize - } - return cloneSettings(out) -} - -func cloneSettings(in config.NotificationConfig) config.NotificationConfig { - out := in - out.Desktop.Priorities = append([]ports.Priority(nil), in.Desktop.Priorities...) - out.Desktop.SoundPriorities = append([]ports.Priority(nil), in.Desktop.SoundPriorities...) - out.Routing.Priorities = cloneRoutes(in.Routing.Priorities) - return out -} - -func cloneRoutes(in map[ports.Priority][]string) map[ports.Priority][]string { - if in == nil { - return nil - } - out := make(map[ports.Priority][]string, len(in)) - for p, routes := range in { - out[p] = append([]string(nil), routes...) - } - return out -} diff --git a/backend/internal/notification/settings_test.go b/backend/internal/notification/settings_test.go deleted file mode 100644 index 9fb9590f8d..0000000000 --- a/backend/internal/notification/settings_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package notification - -import ( - "context" - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -func TestSettingsFromConfigDefaultsWhenUnset(t *testing.T) { - got := SettingsFromConfig(config.Config{Notifications: config.DefaultNotificationConfig()}).Settings(context.Background()) - if !got.Enabled || !got.Desktop.Enabled || !got.Dashboard.Enabled { - t.Fatalf("default config should resolve safe enabled defaults: %+v", got) - } - if got.Retry.MaxAttempts != 5 || got.Retry.BatchSize != 50 { - t.Fatalf("retry defaults = %+v", got.Retry) - } -} - -func TestSettingsFromConfigPreservesExplicitGlobalDisable(t *testing.T) { - got := SettingsFromConfig(config.Config{Notifications: config.NotificationConfig{Enabled: false}}).Settings(context.Background()) - if got.Enabled { - t.Fatalf("explicit disabled notifications should stay disabled: %+v", got) - } - if got.Retry.MaxAttempts != 5 || got.Routing.Priorities == nil { - t.Fatalf("disabled config should still receive non-global defaults: %+v", got) - } -} - -func TestNormalizeSettingsPreservesExplicitSurfaceDisables(t *testing.T) { - got := StaticSettings(config.NotificationConfig{ - Enabled: true, - Dashboard: config.DashboardNotificationConfig{Enabled: false}, - Desktop: config.DesktopNotificationConfig{Enabled: false}, - }).Settings(context.Background()) - - if !got.Enabled { - t.Fatalf("global notifications should remain enabled: %+v", got) - } - if got.Dashboard.Enabled { - t.Fatalf("explicit dashboard disable should stay disabled: %+v", got.Dashboard) - } - if got.Desktop.Enabled { - t.Fatalf("explicit desktop disable should stay disabled: %+v", got.Desktop) - } - if got.Dashboard.Limit != 50 || len(got.Desktop.Priorities) == 0 || got.Retry.MaxAttempts != 5 { - t.Fatalf("disabled surfaces should still receive non-bool defaults: %+v", got) - } -} - -func TestNormalizeSettingsPreservesExplicitEmptyRoute(t *testing.T) { - cfg := config.DefaultNotificationConfig() - cfg.Routing.Priorities[ports.PriorityUrgent] = []string{} - - got := StaticSettings(cfg).Settings(context.Background()) - if routes := got.Routing.Priorities[ports.PriorityUrgent]; len(routes) != 0 { - t.Fatalf("explicit empty urgent route should be preserved, got %v", routes) - } -} - -func TestSettingsProviderReturnsClone(t *testing.T) { - cfg := config.DefaultNotificationConfig() - provider := StaticSettings(cfg) - first := provider.Settings(context.Background()) - first.Desktop.Priorities[0] = ports.PriorityInfo - first.Routing.Priorities[ports.PriorityUrgent][0] = "mutated" - - second := provider.Settings(context.Background()) - if second.Desktop.Priorities[0] != ports.PriorityUrgent { - t.Fatalf("desktop priorities were mutated through clone: %v", second.Desktop.Priorities) - } - if second.Routing.Priorities[ports.PriorityUrgent][0] != RouteDashboard { - t.Fatalf("routes were mutated through clone: %v", second.Routing.Priorities[ports.PriorityUrgent]) - } -} diff --git a/backend/internal/notification/store.go b/backend/internal/notification/store.go deleted file mode 100644 index e2cdf511b6..0000000000 --- a/backend/internal/notification/store.go +++ /dev/null @@ -1,25 +0,0 @@ -package notification - -import ( - "context" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// Store is the central notifier runtime's durable interface. The lifecycle -// enqueuer writes notification rows; this interface routes them into durable -// delivery rows and lets AO-app/API code claim and complete desktop handoffs. -type Store interface { - ListUnroutedNotifications(ctx context.Context, limit int) ([]domain.Notification, error) - MarkNotificationRouted(ctx context.Context, id domain.NotificationID, at time.Time) error - - GetDelivery(ctx context.Context, id string) (DeliveryRow, bool, error) - EnqueueDelivery(ctx context.Context, row DeliveryRow) (DeliveryRow, bool, error) - ClaimDueDeliveries(ctx context.Context, sink string, owner string, now time.Time, limit int, lease time.Duration) ([]DeliveryRow, error) - ReleaseExpiredDeliveryLeases(ctx context.Context, now time.Time) (int, error) - MarkDeliverySent(ctx context.Context, id string, owner string, externalID string, at time.Time) error - MarkDeliveryRetry(ctx context.Context, id string, owner string, errCode string, errMessage string, next time.Time, at time.Time) error - MarkDeliveryFailed(ctx context.Context, id string, owner string, errCode string, errMessage string, at time.Time) error - MarkDeliverySkipped(ctx context.Context, id string, reason string, at time.Time) error -} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 2887a87ec9..992c0ca03b 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -36,43 +36,6 @@ type Notification struct { ArchivedAt sql.NullTime CreatedAt time.Time UpdatedAt time.Time - RoutedAt sql.NullTime -} - -type NotificationDelivery struct { - ID string - NotificationID string - NotificationSeq int64 - ProjectID string - SessionID string - RouteName string - Sink string - DestinationKey string - RequestJson string - Status string - Attempts int64 - MaxAttempts int64 - NextAttemptAt time.Time - LeaseOwner string - LeaseExpiresAt sql.NullTime - LastErrorCode string - LastError string - ExternalID string - CreatedAt time.Time - UpdatedAt time.Time - DeliveredAt sql.NullTime -} - -type NotificationDeliveryAttempt struct { - ID int64 - DeliveryID string - AttemptNo int64 - Status string - StartedAt time.Time - FinishedAt sql.NullTime - ErrorCode string - Error string - ResponseJson string } type Pr struct { diff --git a/backend/internal/storage/sqlite/gen/notification_deliveries.sql.go b/backend/internal/storage/sqlite/gen/notification_deliveries.sql.go deleted file mode 100644 index ed2821ee1b..0000000000 --- a/backend/internal/storage/sqlite/gen/notification_deliveries.sql.go +++ /dev/null @@ -1,256 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: notification_deliveries.sql - -package gen - -import ( - "context" - "database/sql" - "time" -) - -const getNotificationDelivery = `-- name: GetNotificationDelivery :one -SELECT id, notification_id, notification_seq, project_id, session_id, - route_name, sink, destination_key, request_json, - status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, - last_error_code, last_error, external_id, - created_at, updated_at, delivered_at -FROM notification_deliveries -WHERE id = ? -` - -func (q *Queries) GetNotificationDelivery(ctx context.Context, id string) (NotificationDelivery, error) { - row := q.db.QueryRowContext(ctx, getNotificationDelivery, id) - var i NotificationDelivery - err := row.Scan( - &i.ID, - &i.NotificationID, - &i.NotificationSeq, - &i.ProjectID, - &i.SessionID, - &i.RouteName, - &i.Sink, - &i.DestinationKey, - &i.RequestJson, - &i.Status, - &i.Attempts, - &i.MaxAttempts, - &i.NextAttemptAt, - &i.LeaseOwner, - &i.LeaseExpiresAt, - &i.LastErrorCode, - &i.LastError, - &i.ExternalID, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeliveredAt, - ) - return i, err -} - -const getNotificationDeliveryByUnique = `-- name: GetNotificationDeliveryByUnique :one -SELECT id, notification_id, notification_seq, project_id, session_id, - route_name, sink, destination_key, request_json, - status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, - last_error_code, last_error, external_id, - created_at, updated_at, delivered_at -FROM notification_deliveries -WHERE notification_id = ? AND route_name = ? AND destination_key = ? -` - -type GetNotificationDeliveryByUniqueParams struct { - NotificationID string - RouteName string - DestinationKey string -} - -func (q *Queries) GetNotificationDeliveryByUnique(ctx context.Context, arg GetNotificationDeliveryByUniqueParams) (NotificationDelivery, error) { - row := q.db.QueryRowContext(ctx, getNotificationDeliveryByUnique, arg.NotificationID, arg.RouteName, arg.DestinationKey) - var i NotificationDelivery - err := row.Scan( - &i.ID, - &i.NotificationID, - &i.NotificationSeq, - &i.ProjectID, - &i.SessionID, - &i.RouteName, - &i.Sink, - &i.DestinationKey, - &i.RequestJson, - &i.Status, - &i.Attempts, - &i.MaxAttempts, - &i.NextAttemptAt, - &i.LeaseOwner, - &i.LeaseExpiresAt, - &i.LastErrorCode, - &i.LastError, - &i.ExternalID, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeliveredAt, - ) - return i, err -} - -const insertNotificationDelivery = `-- name: InsertNotificationDelivery :one -INSERT INTO notification_deliveries ( - id, notification_id, notification_seq, project_id, session_id, - route_name, sink, destination_key, request_json, - status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, - last_error_code, last_error, external_id, - created_at, updated_at, delivered_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(notification_id, route_name, destination_key) DO NOTHING -RETURNING id, notification_id, notification_seq, project_id, session_id, - route_name, sink, destination_key, request_json, - status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, - last_error_code, last_error, external_id, - created_at, updated_at, delivered_at -` - -type InsertNotificationDeliveryParams struct { - ID string - NotificationID string - NotificationSeq int64 - ProjectID string - SessionID string - RouteName string - Sink string - DestinationKey string - RequestJson string - Status string - Attempts int64 - MaxAttempts int64 - NextAttemptAt time.Time - LeaseOwner string - LeaseExpiresAt sql.NullTime - LastErrorCode string - LastError string - ExternalID string - CreatedAt time.Time - UpdatedAt time.Time - DeliveredAt sql.NullTime -} - -func (q *Queries) InsertNotificationDelivery(ctx context.Context, arg InsertNotificationDeliveryParams) (NotificationDelivery, error) { - row := q.db.QueryRowContext(ctx, insertNotificationDelivery, - arg.ID, - arg.NotificationID, - arg.NotificationSeq, - arg.ProjectID, - arg.SessionID, - arg.RouteName, - arg.Sink, - arg.DestinationKey, - arg.RequestJson, - arg.Status, - arg.Attempts, - arg.MaxAttempts, - arg.NextAttemptAt, - arg.LeaseOwner, - arg.LeaseExpiresAt, - arg.LastErrorCode, - arg.LastError, - arg.ExternalID, - arg.CreatedAt, - arg.UpdatedAt, - arg.DeliveredAt, - ) - var i NotificationDelivery - err := row.Scan( - &i.ID, - &i.NotificationID, - &i.NotificationSeq, - &i.ProjectID, - &i.SessionID, - &i.RouteName, - &i.Sink, - &i.DestinationKey, - &i.RequestJson, - &i.Status, - &i.Attempts, - &i.MaxAttempts, - &i.NextAttemptAt, - &i.LeaseOwner, - &i.LeaseExpiresAt, - &i.LastErrorCode, - &i.LastError, - &i.ExternalID, - &i.CreatedAt, - &i.UpdatedAt, - &i.DeliveredAt, - ) - return i, err -} - -const listUnroutedNotifications = `-- name: ListUnroutedNotifications :many -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at -FROM notifications -WHERE routed_at IS NULL -ORDER BY seq ASC -LIMIT ? -` - -func (q *Queries) ListUnroutedNotifications(ctx context.Context, limit int64) ([]Notification, error) { - rows, err := q.db.QueryContext(ctx, listUnroutedNotifications, limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Notification{} - for rows.Next() { - var i Notification - if err := rows.Scan( - &i.Seq, - &i.ID, - &i.ProjectID, - &i.SessionID, - &i.Source, - &i.EventType, - &i.SemanticType, - &i.Priority, - &i.Message, - &i.PayloadJson, - &i.ActionsJson, - &i.DedupeKey, - &i.CauseKey, - &i.ReadAt, - &i.ArchivedAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.RoutedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const markNotificationRouted = `-- name: MarkNotificationRouted :exec -UPDATE notifications -SET routed_at = COALESCE(routed_at, ?), - updated_at = CASE WHEN routed_at IS NULL THEN ? ELSE updated_at END -WHERE id = ? -` - -type MarkNotificationRoutedParams struct { - RoutedAt sql.NullTime - UpdatedAt time.Time - ID string -} - -func (q *Queries) MarkNotificationRouted(ctx context.Context, arg MarkNotificationRoutedParams) error { - _, err := q.db.ExecContext(ctx, markNotificationRouted, arg.RoutedAt, arg.UpdatedAt, arg.ID) - return err -} diff --git a/backend/internal/storage/sqlite/gen/notifications.sql.go b/backend/internal/storage/sqlite/gen/notifications.sql.go index 47ca63d9e8..7b2b5493d9 100644 --- a/backend/internal/storage/sqlite/gen/notifications.sql.go +++ b/backend/internal/storage/sqlite/gen/notifications.sql.go @@ -16,7 +16,7 @@ UPDATE notifications SET archived_at = ?, updated_at = ? WHERE id = ? AND archived_at IS NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at ` type ArchiveNotificationParams struct { @@ -46,14 +46,13 @@ func (q *Queries) ArchiveNotification(ctx context.Context, arg ArchiveNotificati &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.RoutedAt, ) return i, err } const getNotification = `-- name: GetNotification :one SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications WHERE id = ? ` @@ -78,14 +77,13 @@ func (q *Queries) GetNotification(ctx context.Context, id string) (Notification, &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.RoutedAt, ) return i, err } const getNotificationByDedupeKey = `-- name: GetNotificationByDedupeKey :one SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications WHERE dedupe_key = ? ` @@ -110,7 +108,6 @@ func (q *Queries) GetNotificationByDedupeKey(ctx context.Context, dedupeKey stri &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.RoutedAt, ) return i, err } @@ -122,7 +119,7 @@ INSERT INTO notifications ( ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dedupe_key) DO NOTHING RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at ` type InsertNotificationParams struct { @@ -176,14 +173,13 @@ func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotification &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.RoutedAt, ) return i, err } const listNotifications = `-- name: ListNotifications :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications ORDER BY seq DESC LIMIT ? @@ -216,7 +212,6 @@ func (q *Queries) ListNotifications(ctx context.Context, limit int64) ([]Notific &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.RoutedAt, ); err != nil { return nil, err } @@ -233,7 +228,7 @@ func (q *Queries) ListNotifications(ctx context.Context, limit int64) ([]Notific const listNotificationsByProject = `-- name: ListNotificationsByProject :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications WHERE project_id = ? ORDER BY seq DESC @@ -272,7 +267,6 @@ func (q *Queries) ListNotificationsByProject(ctx context.Context, arg ListNotifi &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.RoutedAt, ); err != nil { return nil, err } @@ -289,7 +283,7 @@ func (q *Queries) ListNotificationsByProject(ctx context.Context, arg ListNotifi const listNotificationsBySession = `-- name: ListNotificationsBySession :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications WHERE session_id = ? ORDER BY seq DESC @@ -328,7 +322,6 @@ func (q *Queries) ListNotificationsBySession(ctx context.Context, arg ListNotifi &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.RoutedAt, ); err != nil { return nil, err } @@ -345,7 +338,7 @@ func (q *Queries) ListNotificationsBySession(ctx context.Context, arg ListNotifi const listUnreadNotifications = `-- name: ListUnreadNotifications :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications WHERE read_at IS NULL AND archived_at IS NULL ORDER BY seq DESC @@ -379,7 +372,6 @@ func (q *Queries) ListUnreadNotifications(ctx context.Context, limit int64) ([]N &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.RoutedAt, ); err != nil { return nil, err } @@ -399,7 +391,7 @@ UPDATE notifications SET read_at = ?, updated_at = ? WHERE id = ? AND read_at IS NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at ` type MarkNotificationReadParams struct { @@ -429,7 +421,6 @@ func (q *Queries) MarkNotificationRead(ctx context.Context, arg MarkNotification &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.RoutedAt, ) return i, err } @@ -439,7 +430,7 @@ UPDATE notifications SET read_at = NULL, updated_at = ? WHERE id = ? AND read_at IS NOT NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at ` type MarkNotificationUnreadParams struct { @@ -468,7 +459,6 @@ func (q *Queries) MarkNotificationUnread(ctx context.Context, arg MarkNotificati &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.RoutedAt, ) return i, err } diff --git a/backend/internal/storage/sqlite/gen/querier.go b/backend/internal/storage/sqlite/gen/querier.go index 87550b12e9..4f91a9d544 100644 --- a/backend/internal/storage/sqlite/gen/querier.go +++ b/backend/internal/storage/sqlite/gen/querier.go @@ -16,13 +16,10 @@ type Querier interface { DeleteSession(ctx context.Context, id string) error GetNotification(ctx context.Context, id string) (Notification, error) GetNotificationByDedupeKey(ctx context.Context, dedupeKey string) (Notification, error) - GetNotificationDelivery(ctx context.Context, id string) (NotificationDelivery, error) - GetNotificationDeliveryByUnique(ctx context.Context, arg GetNotificationDeliveryByUniqueParams) (NotificationDelivery, error) GetPR(ctx context.Context, url string) (Pr, error) GetProject(ctx context.Context, id string) (Project, error) GetSession(ctx context.Context, id string) (Session, error) InsertNotification(ctx context.Context, arg InsertNotificationParams) (Notification, error) - InsertNotificationDelivery(ctx context.Context, arg InsertNotificationDeliveryParams) (NotificationDelivery, error) InsertSession(ctx context.Context, arg InsertSessionParams) error ListAllSessions(ctx context.Context) ([]Session, error) ListChecksByPR(ctx context.Context, prUrl string) ([]PrCheck, error) @@ -35,9 +32,7 @@ type Querier interface { ListRecentChecks(ctx context.Context, arg ListRecentChecksParams) ([]ListRecentChecksRow, error) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) ListUnreadNotifications(ctx context.Context, limit int64) ([]Notification, error) - ListUnroutedNotifications(ctx context.Context, limit int64) ([]Notification, error) MarkNotificationRead(ctx context.Context, arg MarkNotificationReadParams) (Notification, error) - MarkNotificationRouted(ctx context.Context, arg MarkNotificationRoutedParams) error MarkNotificationUnread(ctx context.Context, arg MarkNotificationUnreadParams) (Notification, error) MaxChangeLogSeq(ctx context.Context) (interface{}, error) NextSessionNum(ctx context.Context, projectID string) (int64, error) diff --git a/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql b/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql deleted file mode 100644 index 4c7cca1dba..0000000000 --- a/backend/internal/storage/sqlite/migrations/0003_notification_deliveries.sql +++ /dev/null @@ -1,128 +0,0 @@ --- +goose Up --- +goose StatementBegin -ALTER TABLE notifications ADD COLUMN routed_at TIMESTAMP; --- Notifications that already exist when this migration runs predate the --- delivery runtime. Treat them as already routed so an upgrade does not --- synthesize new AO-app desktop toasts for historical notification rows; the --- dashboard read model still sees those rows directly from notifications. -UPDATE notifications SET routed_at = updated_at WHERE routed_at IS NULL; -CREATE INDEX idx_notifications_unrouted - ON notifications(seq) - WHERE routed_at IS NULL; - -CREATE TABLE notification_deliveries ( - id TEXT PRIMARY KEY, - notification_id TEXT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE, - notification_seq INTEGER NOT NULL, - project_id TEXT NOT NULL REFERENCES projects(id), - session_id TEXT NOT NULL REFERENCES sessions(id), - - route_name TEXT NOT NULL, - sink TEXT NOT NULL, - destination_key TEXT NOT NULL DEFAULT '', - request_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(request_json)), - - status TEXT NOT NULL CHECK (status IN ('queued','leased','sent','retry_wait','failed','skipped','cancelled')), - attempts INTEGER NOT NULL DEFAULT 0, - max_attempts INTEGER NOT NULL DEFAULT 5, - next_attempt_at TIMESTAMP NOT NULL, - lease_owner TEXT NOT NULL DEFAULT '', - lease_expires_at TIMESTAMP, - - last_error_code TEXT NOT NULL DEFAULT '', - last_error TEXT NOT NULL DEFAULT '', - external_id TEXT NOT NULL DEFAULT '', - - created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), - updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), - delivered_at TIMESTAMP, - - UNIQUE(notification_id, route_name, destination_key) -); - -CREATE INDEX idx_notification_deliveries_due - ON notification_deliveries(status, next_attempt_at, lease_expires_at, created_at); - -CREATE INDEX idx_notification_deliveries_notification - ON notification_deliveries(notification_id, status); - -CREATE INDEX idx_notification_deliveries_project - ON notification_deliveries(project_id, created_at DESC); - -CREATE TABLE notification_delivery_attempts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - delivery_id TEXT NOT NULL REFERENCES notification_deliveries(id) ON DELETE CASCADE, - attempt_no INTEGER NOT NULL, - status TEXT NOT NULL CHECK (status IN ('started','sent','retryable_failed','failed')), - started_at TIMESTAMP NOT NULL, - finished_at TIMESTAMP, - error_code TEXT NOT NULL DEFAULT '', - error TEXT NOT NULL DEFAULT '', - response_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(response_json)), - UNIQUE(delivery_id, attempt_no) -); --- +goose StatementEnd - --- +goose StatementBegin -CREATE TRIGGER notification_deliveries_cdc_insert -AFTER INSERT ON notification_deliveries -BEGIN - INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) - VALUES ( - NEW.project_id, - NEW.session_id, - 'notification_delivery_created', - json_object( - 'id', NEW.id, - 'notificationId', NEW.notification_id, - 'routeName', NEW.route_name, - 'sink', NEW.sink, - 'status', NEW.status, - 'attempts', NEW.attempts, - 'lastErrorCode', NEW.last_error_code, - 'lastError', NEW.last_error - ), - NEW.created_at - ); -END; --- +goose StatementEnd - --- +goose StatementBegin -CREATE TRIGGER notification_deliveries_cdc_update -AFTER UPDATE ON notification_deliveries -WHEN OLD.status <> NEW.status - OR OLD.attempts <> NEW.attempts - OR OLD.last_error_code <> NEW.last_error_code - OR OLD.last_error <> NEW.last_error - OR OLD.external_id <> NEW.external_id - OR OLD.delivered_at IS NOT NEW.delivered_at -BEGIN - INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) - VALUES ( - NEW.project_id, - NEW.session_id, - 'notification_delivery_updated', - json_object( - 'id', NEW.id, - 'notificationId', NEW.notification_id, - 'routeName', NEW.route_name, - 'sink', NEW.sink, - 'status', NEW.status, - 'attempts', NEW.attempts, - 'lastErrorCode', NEW.last_error_code, - 'lastError', NEW.last_error - ), - NEW.updated_at - ); -END; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TRIGGER IF EXISTS notification_deliveries_cdc_update; -DROP TRIGGER IF EXISTS notification_deliveries_cdc_insert; -DROP TABLE IF EXISTS notification_delivery_attempts; -DROP TABLE IF EXISTS notification_deliveries; -DROP INDEX IF EXISTS idx_notifications_unrouted; -ALTER TABLE notifications DROP COLUMN routed_at; --- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/notification_delivery_store.go b/backend/internal/storage/sqlite/notification_delivery_store.go deleted file mode 100644 index 9cdddc743b..0000000000 --- a/backend/internal/storage/sqlite/notification_delivery_store.go +++ /dev/null @@ -1,462 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/notification" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -const deliveryColumns = `id, notification_id, notification_seq, project_id, session_id, - route_name, sink, destination_key, request_json, - status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, - last_error_code, last_error, external_id, - created_at, updated_at, delivered_at` - -const ( - defaultDeliveryLimit = 100 - defaultDeliveryMaxAttempts = 5 // mirrors notification_deliveries.max_attempts schema default -) - -type DeliveryFilter struct { - NotificationID string - ProjectID string - Status notification.DeliveryStatus - Limit int -} - -func (s *Store) ListUnroutedNotifications(ctx context.Context, limit int) ([]domain.Notification, error) { - if limit <= 0 { - limit = defaultNotificationLimit - } - rows, err := s.qr.ListUnroutedNotifications(ctx, int64(limit)) - if err != nil { - return nil, fmt.Errorf("list unrouted notifications: %w", err) - } - return notificationsFromGen(rows) -} - -func (s *Store) MarkNotificationRouted(ctx context.Context, id domain.NotificationID, at time.Time) error { - if at.IsZero() { - at = time.Now().UTC() - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - if err := s.qw.MarkNotificationRouted(ctx, gen.MarkNotificationRoutedParams{ - RoutedAt: nullTime(at), - UpdatedAt: at, - ID: string(id), - }); err != nil { - return fmt.Errorf("mark notification routed %s: %w", id, err) - } - return nil -} - -func (s *Store) EnqueueDelivery(ctx context.Context, row notification.DeliveryRow) (notification.DeliveryRow, bool, error) { - now := row.CreatedAt - if now.IsZero() { - now = time.Now().UTC() - } - row, err := notification.NormalizeDelivery(row, now, defaultDeliveryMaxAttempts) - if err != nil { - return notification.DeliveryRow{}, false, err - } - - s.writeMu.Lock() - defer s.writeMu.Unlock() - - got, err := s.qw.InsertNotificationDelivery(ctx, gen.InsertNotificationDeliveryParams{ - ID: row.ID, - NotificationID: string(row.NotificationID), - NotificationSeq: row.NotificationSeq, - ProjectID: string(row.ProjectID), - SessionID: string(row.SessionID), - RouteName: row.RouteName, - Sink: row.Sink, - DestinationKey: row.DestinationKey, - RequestJson: string(row.RequestJSON), - Status: string(row.Status), - Attempts: int64(row.Attempts), - MaxAttempts: int64(row.MaxAttempts), - NextAttemptAt: row.NextAttemptAt, - LeaseOwner: row.LeaseOwner, - LeaseExpiresAt: nullTime(row.LeaseExpiresAt), - LastErrorCode: row.LastErrorCode, - LastError: row.LastError, - ExternalID: row.ExternalID, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - DeliveredAt: nullTime(row.DeliveredAt), - }) - if errors.Is(err, sql.ErrNoRows) { - existing, readErr := s.getDeliveryByUniqueLocked(ctx, row.NotificationID, row.RouteName, row.DestinationKey) - if readErr != nil { - return notification.DeliveryRow{}, false, readErr - } - return existing, false, nil - } - if err != nil { - return notification.DeliveryRow{}, false, fmt.Errorf("insert notification delivery: %w", err) - } - return deliveryFromGen(got), true, nil -} - -func (s *Store) ClaimDueDeliveries(ctx context.Context, sink string, owner string, now time.Time, limit int, lease time.Duration) ([]notification.DeliveryRow, error) { - if now.IsZero() { - now = time.Now().UTC() - } - if limit <= 0 { - limit = defaultDeliveryLimit - } - if lease <= 0 { - lease = 30 * time.Second - } - expires := now.Add(lease) - - s.writeMu.Lock() - defer s.writeMu.Unlock() - - tx, err := s.writeDB.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("begin claim deliveries: %w", err) - } - defer tx.Rollback() - - rows, err := tx.QueryContext(ctx, `SELECT id -FROM notification_deliveries -WHERE sink = ? - AND status IN ('queued','retry_wait') - AND next_attempt_at <= ? - AND attempts < max_attempts -ORDER BY next_attempt_at ASC, created_at ASC, id ASC -LIMIT ?`, sink, now, limit) - if err != nil { - return nil, fmt.Errorf("select due deliveries: %w", err) - } - var ids []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return nil, err - } - ids = append(ids, id) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - - out := make([]notification.DeliveryRow, 0, len(ids)) - for _, id := range ids { - res, err := tx.ExecContext(ctx, `UPDATE notification_deliveries -SET status = 'leased', - lease_owner = ?, - lease_expires_at = ?, - updated_at = ? -WHERE id = ? - AND status IN ('queued','retry_wait') - AND next_attempt_at <= ? - AND attempts < max_attempts`, owner, expires, now, id, now) - if err != nil { - return nil, fmt.Errorf("lease delivery %s: %w", id, err) - } - changed, err := res.RowsAffected() - if err != nil { - return nil, err - } - if changed == 0 { - continue - } - row, err := scanDelivery(tx.QueryRowContext(ctx, `SELECT `+deliveryColumns+` FROM notification_deliveries WHERE id = ?`, id)) - if err != nil { - return nil, fmt.Errorf("read leased delivery %s: %w", id, err) - } - out = append(out, row) - } - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit claim deliveries: %w", err) - } - return out, nil -} - -func (s *Store) ReleaseExpiredDeliveryLeases(ctx context.Context, now time.Time) (int, error) { - if now.IsZero() { - now = time.Now().UTC() - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - res, err := s.writeDB.ExecContext(ctx, `UPDATE notification_deliveries -SET attempts = attempts + 1, - status = CASE WHEN attempts + 1 >= max_attempts THEN 'failed' ELSE 'queued' END, - next_attempt_at = ?, - lease_owner = '', - lease_expires_at = NULL, - last_error_code = 'lease_expired', - last_error = 'delivery lease expired', - updated_at = ? -WHERE status = 'leased' - AND lease_expires_at IS NOT NULL - AND lease_expires_at <= ?`, now, now, now) - if err != nil { - return 0, fmt.Errorf("release expired delivery leases: %w", err) - } - n, err := res.RowsAffected() - if err != nil { - return 0, err - } - return int(n), nil -} - -func (s *Store) MarkDeliverySent(ctx context.Context, id string, owner string, externalID string, at time.Time) error { - if at.IsZero() { - at = time.Now().UTC() - } - return s.updateDelivery(ctx, "mark delivery sent", `UPDATE notification_deliveries -SET status = 'sent', - attempts = attempts + 1, - lease_owner = '', - lease_expires_at = NULL, - external_id = ?, - delivered_at = ?, - updated_at = ? -WHERE id = ? - AND status = 'leased' - AND lease_owner = ? - AND lease_expires_at > ?`, externalID, at, at, id, owner, at) -} - -func (s *Store) MarkDeliveryRetry(ctx context.Context, id string, owner string, errCode string, errMessage string, next time.Time, at time.Time) error { - if at.IsZero() { - at = time.Now().UTC() - } - if next.IsZero() { - next = at - } - return s.updateDelivery(ctx, "mark delivery retry", `UPDATE notification_deliveries -SET attempts = attempts + 1, - status = CASE WHEN attempts + 1 >= max_attempts THEN 'failed' ELSE 'retry_wait' END, - next_attempt_at = ?, - lease_owner = '', - lease_expires_at = NULL, - last_error_code = ?, - last_error = ?, - updated_at = ? -WHERE id = ? - AND status = 'leased' - AND lease_owner = ? - AND lease_expires_at > ?`, next, errCode, errMessage, at, id, owner, at) -} - -func (s *Store) MarkDeliveryFailed(ctx context.Context, id string, owner string, errCode string, errMessage string, at time.Time) error { - if at.IsZero() { - at = time.Now().UTC() - } - return s.updateDelivery(ctx, "mark delivery failed", `UPDATE notification_deliveries -SET status = 'failed', - attempts = CASE WHEN status = 'leased' THEN attempts + 1 ELSE attempts END, - lease_owner = '', - lease_expires_at = NULL, - last_error_code = ?, - last_error = ?, - updated_at = ? -WHERE id = ? - AND status = 'leased' - AND lease_owner = ? - AND lease_expires_at > ?`, errCode, errMessage, at, id, owner, at) -} - -func (s *Store) MarkDeliverySkipped(ctx context.Context, id string, reason string, at time.Time) error { - if at.IsZero() { - at = time.Now().UTC() - } - return s.updateDelivery(ctx, "mark delivery skipped", `UPDATE notification_deliveries -SET status = 'skipped', - lease_owner = '', - lease_expires_at = NULL, - last_error_code = 'skipped', - last_error = ?, - updated_at = ? -WHERE id = ? AND status NOT IN ('sent','failed','skipped','cancelled')`, reason, at, id) -} - -func (s *Store) GetDelivery(ctx context.Context, id string) (notification.DeliveryRow, bool, error) { - row, err := s.qr.GetNotificationDelivery(ctx, id) - if errors.Is(err, sql.ErrNoRows) { - return notification.DeliveryRow{}, false, nil - } - if err != nil { - return notification.DeliveryRow{}, false, fmt.Errorf("get notification delivery %s: %w", id, err) - } - return deliveryFromGen(row), true, nil -} - -func (s *Store) ListDeliveries(ctx context.Context, filter DeliveryFilter) ([]notification.DeliveryRow, error) { - limit := filter.Limit - if limit <= 0 { - limit = defaultDeliveryLimit - } - base := `SELECT ` + deliveryColumns + ` FROM notification_deliveries` - order := ` ORDER BY created_at ASC, id ASC LIMIT ?` - var ( - rows *sql.Rows - err error - ) - switch { - case filter.NotificationID != "": - if filter.Status != "" { - rows, err = s.readDB.QueryContext(ctx, base+` WHERE notification_id = ? AND status = ?`+order, filter.NotificationID, string(filter.Status), limit) - } else { - rows, err = s.readDB.QueryContext(ctx, base+` WHERE notification_id = ?`+order, filter.NotificationID, limit) - } - case filter.ProjectID != "": - if filter.Status != "" { - rows, err = s.readDB.QueryContext(ctx, base+` WHERE project_id = ? AND status = ?`+order, filter.ProjectID, string(filter.Status), limit) - } else { - rows, err = s.readDB.QueryContext(ctx, base+` WHERE project_id = ?`+order, filter.ProjectID, limit) - } - default: - if filter.Status != "" { - rows, err = s.readDB.QueryContext(ctx, base+` WHERE status = ?`+order, string(filter.Status), limit) - } else { - rows, err = s.readDB.QueryContext(ctx, base+order, limit) - } - } - if err != nil { - return nil, fmt.Errorf("list notification deliveries: %w", err) - } - defer rows.Close() - out := []notification.DeliveryRow{} - for rows.Next() { - row, err := scanDelivery(rows) - if err != nil { - return nil, err - } - out = append(out, row) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func (s *Store) updateDelivery(ctx context.Context, what string, query string, args ...any) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - res, err := s.writeDB.ExecContext(ctx, query, args...) - if err != nil { - return fmt.Errorf("%s: %w", what, err) - } - affected, err := res.RowsAffected() - if err != nil { - return fmt.Errorf("%s rows affected: %w", what, err) - } - if affected == 0 { - return fmt.Errorf("%s: %w", what, notification.ErrDeliveryUpdateConflict) - } - return nil -} - -func (s *Store) getDeliveryByUniqueLocked(ctx context.Context, id domain.NotificationID, routeName, destinationKey string) (notification.DeliveryRow, error) { - row, err := s.qw.GetNotificationDeliveryByUnique(ctx, gen.GetNotificationDeliveryByUniqueParams{ - NotificationID: string(id), - RouteName: routeName, - DestinationKey: destinationKey, - }) - if err != nil { - return notification.DeliveryRow{}, fmt.Errorf("get notification delivery by unique key: %w", err) - } - return deliveryFromGen(row), nil -} - -type rowScanner interface { - Scan(dest ...any) error -} - -func scanDelivery(scanner rowScanner) (notification.DeliveryRow, error) { - var ( - row notification.DeliveryRow - notificationID string - projectID string - sessionID string - status string - requestJSON string - leaseExpires sql.NullTime - deliveredAt sql.NullTime - ) - if err := scanner.Scan( - &row.ID, - ¬ificationID, - &row.NotificationSeq, - &projectID, - &sessionID, - &row.RouteName, - &row.Sink, - &row.DestinationKey, - &requestJSON, - &status, - &row.Attempts, - &row.MaxAttempts, - &row.NextAttemptAt, - &row.LeaseOwner, - &leaseExpires, - &row.LastErrorCode, - &row.LastError, - &row.ExternalID, - &row.CreatedAt, - &row.UpdatedAt, - &deliveredAt, - ); err != nil { - return notification.DeliveryRow{}, err - } - row.NotificationID = domain.NotificationID(notificationID) - row.ProjectID = domain.ProjectID(projectID) - row.SessionID = domain.SessionID(sessionID) - row.RequestJSON = []byte(requestJSON) - row.Status = notification.DeliveryStatus(status) - if leaseExpires.Valid { - row.LeaseExpiresAt = leaseExpires.Time - } - if deliveredAt.Valid { - row.DeliveredAt = deliveredAt.Time - } - return row, nil -} - -func deliveryFromGen(r gen.NotificationDelivery) notification.DeliveryRow { - row := notification.DeliveryRow{ - ID: r.ID, - NotificationID: domain.NotificationID(r.NotificationID), - NotificationSeq: r.NotificationSeq, - ProjectID: domain.ProjectID(r.ProjectID), - SessionID: domain.SessionID(r.SessionID), - RouteName: r.RouteName, - Sink: r.Sink, - DestinationKey: r.DestinationKey, - RequestJSON: []byte(r.RequestJson), - Status: notification.DeliveryStatus(r.Status), - Attempts: int(r.Attempts), - MaxAttempts: int(r.MaxAttempts), - NextAttemptAt: r.NextAttemptAt, - LeaseOwner: r.LeaseOwner, - LastErrorCode: r.LastErrorCode, - LastError: r.LastError, - ExternalID: r.ExternalID, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - } - if r.LeaseExpiresAt.Valid { - row.LeaseExpiresAt = r.LeaseExpiresAt.Time - } - if r.DeliveredAt.Valid { - row.DeliveredAt = r.DeliveredAt.Time - } - return row -} diff --git a/backend/internal/storage/sqlite/notification_delivery_store_test.go b/backend/internal/storage/sqlite/notification_delivery_store_test.go deleted file mode 100644 index 2c675466a4..0000000000 --- a/backend/internal/storage/sqlite/notification_delivery_store_test.go +++ /dev/null @@ -1,302 +0,0 @@ -package sqlite - -import ( - "context" - "errors" - "fmt" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/cdc" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/notification" -) - -func TestNotificationDeliveryEnqueueIdempotentAndCDC(t *testing.T) { - s, ntf := newDeliveryTestNotification(t, "delivery-dedupe") - ctx := context.Background() - startSeq, _ := s.MaxChangeLogSeq(ctx) - - row, created, err := s.EnqueueDelivery(ctx, sampleDelivery(ntf, "desktop")) - if err != nil { - t.Fatal(err) - } - if !created || row.ID == "" || row.Status != notification.DeliveryQueued { - t.Fatalf("created=%v row=%+v", created, row) - } - dup, created, err := s.EnqueueDelivery(ctx, sampleDelivery(ntf, "desktop")) - if err != nil { - t.Fatal(err) - } - if created || dup.ID != row.ID { - t.Fatalf("duplicate should return existing row created=false: created=%v dup=%+v row=%+v", created, dup, row) - } - evs, err := s.ReadChangeLogAfter(ctx, startSeq, 10) - if err != nil { - t.Fatal(err) - } - var createdEvents int - for _, ev := range evs { - if ev.EventType == string(cdc.EventNotificationDeliveryCreated) { - createdEvents++ - } - } - if createdEvents != 1 { - t.Fatalf("delivery created CDC count = %d, want 1 events=%+v", createdEvents, evs) - } -} - -func TestNotificationDeliveryEnqueueDefaultMaxAttempts(t *testing.T) { - s, ntf := newDeliveryTestNotification(t, "delivery-default-max") - ctx := context.Background() - row := sampleDelivery(ntf, "desktop") - row.MaxAttempts = 0 - got, _, err := s.EnqueueDelivery(ctx, row) - if err != nil { - t.Fatal(err) - } - if got.MaxAttempts != 5 { - t.Fatalf("default max attempts = %d, want 5", got.MaxAttempts) - } -} - -func TestNotificationDeliveryClaimDueStableOrder(t *testing.T) { - s, ntf := newDeliveryTestNotification(t, "delivery-claim") - ctx := context.Background() - base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - for i, d := range []time.Duration{2 * time.Second, time.Second, 3 * time.Second} { - row := sampleDelivery(ntf, fmt.Sprintf("desktop-%d", i)) - row.DestinationKey = fmt.Sprintf("dest-%d", i) - row.NextAttemptAt = base.Add(d) - row.CreatedAt = base.Add(time.Duration(i) * time.Millisecond) - row.UpdatedAt = row.CreatedAt - if _, _, err := s.EnqueueDelivery(ctx, row); err != nil { - t.Fatal(err) - } - } - - claimed, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "electron", base.Add(10*time.Second), 2, time.Minute) - if err != nil { - t.Fatal(err) - } - if len(claimed) != 2 { - t.Fatalf("claimed = %d, want 2", len(claimed)) - } - if claimed[0].DestinationKey != "dest-1" || claimed[1].DestinationKey != "dest-0" { - t.Fatalf("claim order = %s, %s; want dest-1, dest-0", claimed[0].DestinationKey, claimed[1].DestinationKey) - } - if claimed[0].Status != notification.DeliveryLeased || claimed[0].LeaseOwner != "electron" || claimed[0].LeaseExpiresAt.IsZero() { - t.Fatalf("claimed row not leased: %+v", claimed[0]) - } -} - -func TestNotificationDeliveryLeaseExpiryAndMaxAttempts(t *testing.T) { - s, ntf := newDeliveryTestNotification(t, "delivery-expiry") - ctx := context.Background() - now := time.Now().UTC().Truncate(time.Second) - queued, _, err := s.EnqueueDelivery(ctx, sampleDueDelivery(ntf, "desktop", now)) - if err != nil { - t.Fatal(err) - } - claimed, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 1, time.Second) - if err != nil || len(claimed) != 1 { - t.Fatalf("claim len=%d err=%v", len(claimed), err) - } - released, err := s.ReleaseExpiredDeliveryLeases(ctx, now.Add(2*time.Second)) - if err != nil || released != 1 { - t.Fatalf("release = %d err=%v", released, err) - } - got, ok, _ := s.GetDelivery(ctx, queued.ID) - if !ok || got.Status != notification.DeliveryQueued || got.Attempts != 1 || got.LeaseOwner != "" { - t.Fatalf("expired lease should return queued with attempts=1: ok=%v row=%+v", ok, got) - } - - maxOne := sampleDueDelivery(ntf, "desktop-max", now) - maxOne.DestinationKey = "max" - maxOne.MaxAttempts = 1 - maxOne, _, err = s.EnqueueDelivery(ctx, maxOne) - if err != nil { - t.Fatal(err) - } - if _, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 1, time.Second); err != nil { - t.Fatal(err) - } - released, err = s.ReleaseExpiredDeliveryLeases(ctx, now.Add(2*time.Second)) - if err != nil || released != 1 { - t.Fatalf("release max = %d err=%v", released, err) - } - got, ok, _ = s.GetDelivery(ctx, maxOne.ID) - if !ok || got.Status != notification.DeliveryFailed || got.Attempts != 1 { - t.Fatalf("max attempts expired lease should fail: ok=%v row=%+v", ok, got) - } -} - -func TestNotificationDeliveryMarkSentRetryFailedAndSkipped(t *testing.T) { - s, ntf := newDeliveryTestNotification(t, "delivery-mark") - ctx := context.Background() - now := time.Now().UTC().Truncate(time.Second) - - sent, _, _ := s.EnqueueDelivery(ctx, sampleDueDelivery(ntf, "desktop-sent", now)) - claimed, _ := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 1, time.Minute) - if len(claimed) != 1 { - t.Fatalf("claim sent row len=%d", len(claimed)) - } - if err := s.MarkDeliverySent(ctx, sent.ID, "owner", "native-1", now.Add(time.Second)); err != nil { - t.Fatal(err) - } - got, _, _ := s.GetDelivery(ctx, sent.ID) - if got.Status != notification.DeliverySent || got.ExternalID != "native-1" || got.Attempts != 1 || got.DeliveredAt.IsZero() { - t.Fatalf("sent row = %+v", got) - } - - retry := sampleDueDelivery(ntf, "desktop-retry", now) - retry.DestinationKey = "retry" - retry, _, _ = s.EnqueueDelivery(ctx, retry) - claimed, _ = s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 1, time.Minute) - if len(claimed) != 1 { - t.Fatalf("claim retry row len=%d", len(claimed)) - } - next := now.Add(30 * time.Second) - if err := s.MarkDeliveryRetry(ctx, retry.ID, "owner", "timeout", "timed out", next, now.Add(time.Second)); err != nil { - t.Fatal(err) - } - got, _, _ = s.GetDelivery(ctx, retry.ID) - if got.Status != notification.DeliveryRetryWait || got.Attempts != 1 || !got.NextAttemptAt.Equal(next) { - t.Fatalf("retry row = %+v", got) - } - - fail := sampleDueDelivery(ntf, "desktop-fail", now) - fail.DestinationKey = "fail" - fail.MaxAttempts = 1 - fail, _, _ = s.EnqueueDelivery(ctx, fail) - claimed, _ = s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 1, time.Minute) - if len(claimed) != 1 { - t.Fatalf("claim fail row len=%d", len(claimed)) - } - if err := s.MarkDeliveryRetry(ctx, fail.ID, "owner", "timeout", "timed out", next, now.Add(time.Second)); err != nil { - t.Fatal(err) - } - got, _, _ = s.GetDelivery(ctx, fail.ID) - if got.Status != notification.DeliveryFailed || got.Attempts != 1 { - t.Fatalf("retry at max should fail: %+v", got) - } - - skipped := sampleDueDelivery(ntf, "desktop-skip", now) - skipped.DestinationKey = "skip" - skipped.Status = notification.DeliverySkipped - skipped, _, _ = s.EnqueueDelivery(ctx, skipped) - claimed, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", now, 10, time.Minute) - if err != nil { - t.Fatal(err) - } - for _, row := range claimed { - if row.ID == skipped.ID { - t.Fatalf("skipped row should not be claimable: %+v", claimed) - } - } - if err := s.MarkDeliveryRetry(ctx, skipped.ID, "owner", "timeout", "timed out", next, now.Add(time.Second)); !errors.Is(err, notification.ErrDeliveryUpdateConflict) { - t.Fatalf("retry skipped row err = %v, want update conflict", err) - } - got, _, _ = s.GetDelivery(ctx, skipped.ID) - if got.Status != notification.DeliverySkipped || got.Attempts != 0 { - t.Fatalf("skipped row should be terminal: %+v", got) - } -} - -func TestNotificationDeliveryCompletionFencedByLeaseOwner(t *testing.T) { - s, ntf := newDeliveryTestNotification(t, "delivery-owner-fence") - ctx := context.Background() - now := time.Now().UTC().Truncate(time.Second) - row, _, err := s.EnqueueDelivery(ctx, sampleDueDelivery(ntf, "desktop", now)) - if err != nil { - t.Fatal(err) - } - if _, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner-1", now, 1, time.Second); err != nil { - t.Fatal(err) - } - if released, err := s.ReleaseExpiredDeliveryLeases(ctx, now.Add(2*time.Second)); err != nil || released != 1 { - t.Fatalf("release = %d err=%v", released, err) - } - if _, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner-2", now.Add(2*time.Second), 1, time.Second); err != nil { - t.Fatal(err) - } - - if err := s.MarkDeliverySent(ctx, row.ID, "owner-1", "stale", now.Add(2500*time.Millisecond)); !errors.Is(err, notification.ErrDeliveryUpdateConflict) { - t.Fatalf("stale owner MarkDeliverySent err = %v, want update conflict", err) - } - got, _, _ := s.GetDelivery(ctx, row.ID) - if got.Status != notification.DeliveryLeased || got.LeaseOwner != "owner-2" || got.ExternalID != "" { - t.Fatalf("stale owner should not change active lease: %+v", got) - } - if err := s.MarkDeliverySent(ctx, row.ID, "owner-2", "native-2", now.Add(2500*time.Millisecond)); err != nil { - t.Fatalf("current owner sent: %v", err) - } - got, _, _ = s.GetDelivery(ctx, row.ID) - if got.Status != notification.DeliverySent || got.ExternalID != "native-2" { - t.Fatalf("current owner should complete delivery: %+v", got) - } -} - -func TestNotificationDeliveryUpdateCDC(t *testing.T) { - s, ntf := newDeliveryTestNotification(t, "delivery-cdc-update") - ctx := context.Background() - row, _, err := s.EnqueueDelivery(ctx, sampleDelivery(ntf, "desktop")) - if err != nil { - t.Fatal(err) - } - startSeq, _ := s.MaxChangeLogSeq(ctx) - if _, err := s.ClaimDueDeliveries(ctx, notification.SinkAOApp, "owner", time.Now().UTC(), 1, time.Minute); err != nil { - t.Fatal(err) - } - if err := s.MarkDeliveryFailed(ctx, row.ID, "owner", "permanent", "bad route", time.Now().UTC()); err != nil { - t.Fatal(err) - } - evs, err := s.ReadChangeLogAfter(ctx, startSeq, 10) - if err != nil { - t.Fatal(err) - } - var updates int - for _, ev := range evs { - if ev.EventType == string(cdc.EventNotificationDeliveryUpdated) { - updates++ - } - } - if updates < 2 { - t.Fatalf("expected claim + failed update CDC events, got %d in %+v", updates, evs) - } -} - -func newDeliveryTestNotification(t *testing.T, dedupe string) (*Store, domain.Notification) { - t.Helper() - s, rec := newNotificationTestSession(t) - row, _, err := s.EnqueueNotification(context.Background(), sampleNotification(rec, dedupe)) - if err != nil { - t.Fatalf("enqueue notification: %v", err) - } - return s, row -} - -func sampleDelivery(ntf domain.Notification, route string) notification.DeliveryRow { - now := time.Now().UTC().Truncate(time.Second) - return notification.DeliveryRow{ - NotificationID: ntf.ID, - NotificationSeq: ntf.Seq, - ProjectID: ntf.ProjectID, - SessionID: ntf.SessionID, - RouteName: route, - Sink: notification.SinkAOApp, - Status: notification.DeliveryQueued, - MaxAttempts: 5, - NextAttemptAt: now, - CreatedAt: now, - UpdatedAt: now, - } -} - -func sampleDueDelivery(ntf domain.Notification, route string, due time.Time) notification.DeliveryRow { - row := sampleDelivery(ntf, route) - row.NextAttemptAt = due - row.CreatedAt = due - row.UpdatedAt = due - return row -} diff --git a/backend/internal/storage/sqlite/notification_store.go b/backend/internal/storage/sqlite/notification_store.go index 8e3e1b0e0c..90b84331c7 100644 --- a/backend/internal/storage/sqlite/notification_store.go +++ b/backend/internal/storage/sqlite/notification_store.go @@ -238,8 +238,5 @@ func notificationFromGen(r gen.Notification) (NotificationRow, error) { if r.ArchivedAt.Valid { row.ArchivedAt = r.ArchivedAt.Time } - if r.RoutedAt.Valid { - row.RoutedAt = r.RoutedAt.Time - } return row, nil } diff --git a/backend/internal/storage/sqlite/queries/notification_deliveries.sql b/backend/internal/storage/sqlite/queries/notification_deliveries.sql deleted file mode 100644 index 02f403afc1..0000000000 --- a/backend/internal/storage/sqlite/queries/notification_deliveries.sql +++ /dev/null @@ -1,46 +0,0 @@ --- name: ListUnroutedNotifications :many -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at -FROM notifications -WHERE routed_at IS NULL -ORDER BY seq ASC -LIMIT ?; - --- name: MarkNotificationRouted :exec -UPDATE notifications -SET routed_at = COALESCE(routed_at, ?), - updated_at = CASE WHEN routed_at IS NULL THEN ? ELSE updated_at END -WHERE id = ?; - --- name: InsertNotificationDelivery :one -INSERT INTO notification_deliveries ( - id, notification_id, notification_seq, project_id, session_id, - route_name, sink, destination_key, request_json, - status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, - last_error_code, last_error, external_id, - created_at, updated_at, delivered_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(notification_id, route_name, destination_key) DO NOTHING -RETURNING id, notification_id, notification_seq, project_id, session_id, - route_name, sink, destination_key, request_json, - status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, - last_error_code, last_error, external_id, - created_at, updated_at, delivered_at; - --- name: GetNotificationDelivery :one -SELECT id, notification_id, notification_seq, project_id, session_id, - route_name, sink, destination_key, request_json, - status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, - last_error_code, last_error, external_id, - created_at, updated_at, delivered_at -FROM notification_deliveries -WHERE id = ?; - --- name: GetNotificationDeliveryByUnique :one -SELECT id, notification_id, notification_seq, project_id, session_id, - route_name, sink, destination_key, request_json, - status, attempts, max_attempts, next_attempt_at, lease_owner, lease_expires_at, - last_error_code, last_error, external_id, - created_at, updated_at, delivered_at -FROM notification_deliveries -WHERE notification_id = ? AND route_name = ? AND destination_key = ?; diff --git a/backend/internal/storage/sqlite/queries/notifications.sql b/backend/internal/storage/sqlite/queries/notifications.sql index 96732bf5ef..a896b43c91 100644 --- a/backend/internal/storage/sqlite/queries/notifications.sql +++ b/backend/internal/storage/sqlite/queries/notifications.sql @@ -5,28 +5,28 @@ INSERT INTO notifications ( ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dedupe_key) DO NOTHING RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at; + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; -- name: GetNotification :one SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications WHERE id = ?; -- name: GetNotificationByDedupeKey :one SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications WHERE dedupe_key = ?; -- name: ListNotifications :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications ORDER BY seq DESC LIMIT ?; -- name: ListNotificationsByProject :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications WHERE project_id = ? ORDER BY seq DESC @@ -34,7 +34,7 @@ LIMIT ?; -- name: ListNotificationsBySession :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications WHERE session_id = ? ORDER BY seq DESC @@ -42,7 +42,7 @@ LIMIT ?; -- name: ListUnreadNotifications :many SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at FROM notifications WHERE read_at IS NULL AND archived_at IS NULL ORDER BY seq DESC @@ -53,18 +53,18 @@ UPDATE notifications SET read_at = ?, updated_at = ? WHERE id = ? AND read_at IS NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at; + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; -- name: MarkNotificationUnread :one UPDATE notifications SET read_at = NULL, updated_at = ? WHERE id = ? AND read_at IS NOT NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at; + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; -- name: ArchiveNotification :one UPDATE notifications SET archived_at = ?, updated_at = ? WHERE id = ? AND archived_at IS NULL RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at, routed_at; + message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; From 8df074b1c98b3a0ccd9c5127c746a893395d6489 Mon Sep 17 00:00:00 2001 From: prateek Date: Mon, 1 Jun 2026 04:04:57 +0530 Subject: [PATCH 096/250] chore(backend): add golangci-lint with a strong ruleset and clear the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces backend/.golangci.yml (27 linters across correctness, dead-code/ boilerplate, style, and security), wires it into CI as a blocking job, and fixes every finding so the tree starts at zero. Config: - 27 linters: errcheck, govet, staticcheck, errorlint, bodyclose, sqlclosecheck, rowserrcheck, nilerr, makezero, unused, unparam, unconvert, wastedassign, copyloopvar, prealloc, dupl, revive (incl. exported-symbol doc comments), gocritic, misspell, usestdlibvars, predeclared, nakedret, gosec, … - Tuned for signal over noise: govet/shadow and gocritic hugeParam/rangeValCopy/ unnamedResult disabled (idiomatic-Go false positives); sqlc-generated code and tests get scoped exclusions; gosec G304 excluded (paths are config/run-file/ worktree-derived, not user input); nilerr excluded in cli/status.go (probe failures are the reported status, not a command error). CI: - New blocking lint job (golangci-lint-action, latest binary for Go-version compatibility). - go-version now read from go.mod (was pinned 1.22 while go.mod declares 1.25). Cleanup to reach zero (no behavior change): - errcheck: wrap deferred/inline Close()/Remove()/Rollback() with `_ =`. - gosec: tighten dir/file perms (0755->0750, 0644->0600). - unparam: drop always-nil error return from startLifecycle; drop unused shellPath param (zellij PowerShell) and always-500 fallbackStatus param (writeProjectError). - gocritic: regexp \d, s != "", switch->if, combined appends. - revive: doc comments on all exported symbols; rename project.ProjectRow -> project.Row (stutter); rename `max` locals shadowing the builtin. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/go.yml | 28 ++++- backend/.golangci.yml | 115 ++++++++++++++++++ .../internal/adapters/runtime/tmux/tmux.go | 17 ++- .../adapters/runtime/zellij/commands.go | 4 +- .../adapters/runtime/zellij/zellij.go | 24 +++- .../internal/adapters/tracker/github/auth.go | 3 + .../adapters/tracker/github/tracker.go | 7 +- .../workspace/gitworktree/workspace.go | 19 +++ backend/internal/cdc/event.go | 1 + backend/internal/cli/doctor.go | 10 +- backend/internal/cli/start.go | 6 +- backend/internal/cli/status.go | 4 +- backend/internal/cli/stop.go | 4 +- backend/internal/cli/version.go | 2 + backend/internal/daemon/daemon.go | 7 +- backend/internal/daemon/lifecycle_wiring.go | 4 +- backend/internal/daemon/wiring_test.go | 5 +- backend/internal/domain/decide/types.go | 1 + backend/internal/domain/lifecycle.go | 22 ++++ backend/internal/domain/pr.go | 2 +- backend/internal/domain/session.go | 11 +- backend/internal/domain/status.go | 1 + backend/internal/domain/tracker.go | 3 + .../internal/httpd/apispec/apispec_test.go | 3 +- .../internal/httpd/controllers/projects.go | 22 ++-- backend/internal/httpd/router.go | 4 + backend/internal/httpd/server.go | 2 +- .../integration/lifecycle_sqlite_test.go | 4 +- backend/internal/lifecycle/manager.go | 3 + backend/internal/notification/enqueuer.go | 3 + backend/internal/notification/payload.go | 10 ++ backend/internal/notification/renderer.go | 3 + backend/internal/ports/facts.go | 2 + backend/internal/ports/inbound.go | 2 + backend/internal/ports/outbound.go | 17 +++ backend/internal/project/manager.go | 10 +- backend/internal/project/memory_store.go | 41 ++++--- backend/internal/runfile/runfile.go | 6 +- backend/internal/session/manager.go | 7 ++ backend/internal/storage/sqlite/db.go | 6 +- backend/internal/storage/sqlite/store.go | 2 +- backend/internal/storage/sqlite/store_test.go | 6 +- backend/internal/terminal/ring.go | 8 +- 43 files changed, 378 insertions(+), 83 deletions(-) create mode 100644 backend/.golangci.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e3ceaf1cc6..4df3f44cea 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,7 +22,9 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: "1.22" + # Read the version from go.mod so CI can't drift from the module + # (it previously pinned 1.22 while go.mod declared 1.25). + go-version-file: backend/go.mod cache: false - name: Check formatting @@ -42,3 +44,27 @@ jobs: - name: Test run: go test -race ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache: false + + - name: golangci-lint + # v8 of the action drives golangci-lint v2 (the schema this config uses); + # the v6 action speaks v1 CLI flags and errors against a v2 binary. + uses: golangci/golangci-lint-action@v8 + with: + # Pinned for reproducibility: bump intentionally rather than letting an + # upstream release change CI. Must be built with Go >= the module's + # (go.mod is 1.25); v2.12.2 is built with go1.25 — older v2 tags + # (e.g. v2.1.x) are built with go1.24 and refuse to analyze 1.25 code. + version: v2.12.2 + working-directory: backend + # Blocking on the full ruleset: the tree is clean at zero findings, so + # any new issue fails CI rather than being grandfathered. diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 0000000000..438dd020c8 --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,115 @@ +# golangci-lint v2 config for the AO backend. +# Run: golangci-lint run ./... (from backend/) +version: "2" + +run: + timeout: 5m + +issues: + # Report every finding, not the default first-50-per-linter / 3-same. + max-issues-per-linter: 0 + max-same-issues: 0 + +linters: + default: none + enable: + # --- correctness --- + - errcheck # unchecked errors + - govet # suspicious constructs + - ineffassign # ineffectual assignments + - staticcheck # the big static analyzer + - unused # dead code (funcs/vars/types/fields) + - errorlint # error wrapping / comparison bugs + - bodyclose # unclosed HTTP response bodies + - sqlclosecheck # unclosed sql.Rows/Stmt + - rowserrcheck # missing rows.Err() + - nilerr # `return nil` after a non-nil err check + - makezero # append to a non-zero-len make() slice + - gocheckcompilerdirectives # malformed //go: directives + - reassign # reassigning package-level vars from other pkgs + # --- dead code / boilerplate (the "nuke" linters) --- + - unparam # unused function params / always-same returns + - unconvert # unnecessary type conversions + - wastedassign # assignments never read + - copyloopvar # redundant loop-var copies (Go 1.22+) + - prealloc # slices that could be preallocated + - dupl # copy-pasted code blocks + # --- style / quality --- + - revive # configurable golint successor + - gocritic # opinionated diagnostics + style + - misspell # typos in comments/strings + - usestdlibvars # use stdlib consts (http.MethodGet, etc.) + - predeclared # shadowing predeclared identifiers + - nakedret # naked returns in long funcs + # --- security --- + - gosec + + settings: + errcheck: + check-type-assertions: true + govet: + enable-all: true + disable: + - fieldalignment # struct field ordering is not worth the churn + - shadow # shadowing `err` in nested scopes is idiomatic Go + revive: + rules: + - { name: exported } # doc comments on every exported symbol + - { name: blank-imports } + - { name: context-as-argument } + - { name: context-keys-type } + - { name: dot-imports } + - { name: error-return } + - { name: error-strings } + - { name: error-naming } + - { name: indent-error-flow } + - { name: errorf } + - { name: empty-block } + - { name: superfluous-else } + - { name: unreachable-code } + - { name: redefines-builtin-id } + - { name: range } + - { name: time-naming } + - { name: var-declaration } + gocritic: + enabled-tags: [diagnostic, performance, style] + disabled-checks: + - ifElseChain # overlaps revive/superfluous-else + - commentedOutCode + - hugeParam # pass-by-pointer micro-opt; hurts clarity, risks nil/aliasing + - rangeValCopy # same — copying a struct in range is usually fine + - unnamedResult # named returns are a style choice, not a defect + dupl: + threshold: 140 + gosec: + excludes: + - G104 # unchecked errors — errcheck owns this + - G304 # file inclusion via variable — paths are config/run-file/worktree-derived, not user input + + exclusions: + generated: lax # skip sqlc/codegen ("Code generated ... DO NOT EDIT") + rules: + # Tests: relax the noisiest checks (deliberate error-drops, repeated setup, + # preallocation, and upgrade-response bodies that don't need closing). + - path: _test\.go + linters: [errcheck, dupl, gosec, unparam, gocritic, prealloc, bodyclose] + # status.go deliberately reports probe failures in the result struct + # (st.State/st.Error) and returns nil — a down daemon is the status being + # reported, not a failure of the status command itself. + - path: internal/cli/status\.go + linters: [nilerr] + # The reflect/unsafe field-inspection test is intentional. + - path: wiring_test\.go + linters: [gosec] + # Spawning git/agent subprocesses with computed args is the point. + - linters: [gosec] + text: "G204" + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/aoagents/agent-orchestrator diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index ba7524ed82..ae0d0445f9 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -25,12 +25,15 @@ var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) var getenv = os.Getenv +// Options configures a tmux Runtime; every field has a default (see New). type Options struct { Binary string Timeout time.Duration Shell string } +// Runtime runs agent sessions inside tmux sessions, driving them via the tmux +// CLI. It implements ports.Runtime. type Runtime struct { binary string timeout time.Duration @@ -50,6 +53,8 @@ func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, return exec.CommandContext(ctx, name, args...).CombinedOutput() } +// New builds a tmux Runtime, filling unset Options with defaults: binary +// "tmux", shell from $SHELL (else /bin/sh), and the default timeout. func New(opts Options) *Runtime { binary := opts.Binary if binary == "" { @@ -69,6 +74,8 @@ func New(opts Options) *Runtime { return &Runtime{binary: binary, timeout: timeout, shell: shellPath, runner: execRunner{}} } +// Create starts a new tmux session in the workspace, running the agent's +// launch command, and returns a handle to it. func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { id, err := tmuxSessionName(cfg.SessionID) if err != nil { @@ -92,6 +99,8 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru return ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}, nil } +// Destroy kills the handle's tmux session. An already-gone session is treated +// as success. func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error { id, err := handleID(handle) if err != nil { @@ -107,6 +116,8 @@ func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error return nil } +// SendMessage types a message into the session's pane and presses Enter, +// routing large messages through a tmux paste buffer. func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { id, err := handleID(handle) if err != nil { @@ -124,6 +135,7 @@ func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, m return nil } +// GetOutput captures the last `lines` lines of the session pane. func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { id, err := handleID(handle) if err != nil { @@ -139,6 +151,7 @@ func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lin return string(out), nil } +// IsAlive reports whether the handle's tmux session still exists. func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { id, err := handleID(handle) if err != nil { @@ -155,6 +168,8 @@ func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err) } +// AttachCommand returns the argv a human runs to attach their terminal to the +// session. func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { id, err := handleID(handle) if err != nil { @@ -170,7 +185,7 @@ func (r *Runtime) sendViaBuffer(ctx context.Context, id, message string) error { return fmt.Errorf("tmux runtime: create message temp file: %w", err) } path := file.Name() - defer os.Remove(path) + defer func() { _ = os.Remove(path) }() if _, err := file.WriteString(message); err != nil { _ = file.Close() return fmt.Errorf("tmux runtime: write message temp file: %w", err) diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go index 4cdf865424..d4ca710451 100644 --- a/backend/internal/adapters/runtime/zellij/commands.go +++ b/backend/internal/adapters/runtime/zellij/commands.go @@ -99,7 +99,7 @@ func layoutString(workspacePath, shellPath string, shellArgs []string, shellComm func shellLaunchCommand(cfg ports.RuntimeConfig, shellPath string, spec shellLaunchSpec) string { if len(spec.args) > 0 && spec.args[0] == "-NoLogo" { - return wrapLaunchCommandPowerShell(cfg, shellPath) + return wrapLaunchCommandPowerShell(cfg) } if len(spec.args) > 0 && spec.args[0] == "/D" { return wrapLaunchCommandCmd(cfg) @@ -136,7 +136,7 @@ func wrapLaunchCommandUnix(cfg ports.RuntimeConfig, shellPath string) string { return b.String() } -func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig, shellPath string) string { +func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig) string { path := cfg.Env["PATH"] if path == "" { path = getenv("PATH") diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go index b98df84912..aade6490db 100644 --- a/backend/internal/adapters/runtime/zellij/zellij.go +++ b/backend/internal/adapters/runtime/zellij/zellij.go @@ -29,10 +29,12 @@ const ( ) var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) -var paneIDPattern = regexp.MustCompile(`^terminal_[0-9]+$`) +var paneIDPattern = regexp.MustCompile(`^terminal_\d+$`) var getenv = os.Getenv +// Options configures a zellij Runtime; every field has a sensible default +// (see New), so the zero value is usable. type Options struct { Binary string Timeout time.Duration @@ -42,6 +44,8 @@ type Options struct { ChunkSize int } +// Runtime runs agent sessions inside zellij sessions, driving them via the +// zellij CLI. It implements ports.Runtime. type Runtime struct { binary string timeout time.Duration @@ -68,6 +72,9 @@ func (execRunner) Run(ctx context.Context, env []string, name string, args ...st return cmd.CombinedOutput() } +// New builds a zellij Runtime, filling unset Options with defaults: binary +// "zellij", shell from $SHELL (else /bin/sh, or powershell.exe on Windows), and +// the default timeout and output chunk size. func New(opts Options) *Runtime { binary := opts.Binary if binary == "" { @@ -95,6 +102,8 @@ func New(opts Options) *Runtime { return &Runtime{binary: binary, timeout: timeout, shell: shellPath, socketDir: opts.SocketDir, configDir: opts.ConfigDir, chunkSize: chunkSize, runner: execRunner{}} } +// Create starts a new zellij session in the workspace, running the agent's +// launch command, and returns a handle to it. func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { id, err := zellijSessionName(cfg.SessionID) if err != nil { @@ -114,7 +123,7 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru if err != nil { return ports.RuntimeHandle{}, err } - defer os.Remove(layoutPath) + defer func() { _ = os.Remove(layoutPath) }() if _, err := r.run(ctx, createSessionArgs(id, layoutPath)...); err != nil { return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: create session %s: %w", id, err) @@ -127,6 +136,8 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru return ports.RuntimeHandle{ID: handleIDValue(id, paneID), RuntimeName: runtimeName}, nil } +// Destroy kills the handle's zellij session. An already-gone session is treated +// as success. func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error { id, _, err := handleID(handle) if err != nil { @@ -142,6 +153,8 @@ func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error return nil } +// SendMessage pastes a message into the session's pane (chunked) and presses +// Enter to submit it. func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { id, paneID, err := handleID(handle) if err != nil { @@ -158,6 +171,7 @@ func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, m return nil } +// GetOutput returns the last `lines` lines of the session pane's screen dump. func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { id, paneID, err := handleID(handle) if err != nil { @@ -173,6 +187,8 @@ func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lin return tailLines(string(out), lines), nil } +// IsAlive reports whether the handle's session still appears in `zellij +// list-sessions`. func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { id, _, err := handleID(handle) if err != nil { @@ -189,6 +205,8 @@ func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool return sessionListedAlive(string(out), id), nil } +// AttachCommand returns the argv a human runs to attach their terminal to the +// session. func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { id, _, err := handleID(handle) if err != nil { @@ -420,7 +438,7 @@ func chunks(s string, maxBytes int) []string { return []string{s} } parts := []string{} - for len(s) > 0 { + for s != "" { if len(s) <= maxBytes { parts = append(parts, s) break diff --git a/backend/internal/adapters/tracker/github/auth.go b/backend/internal/adapters/tracker/github/auth.go index 9aa810dff3..7c448910a9 100644 --- a/backend/internal/adapters/tracker/github/auth.go +++ b/backend/internal/adapters/tracker/github/auth.go @@ -22,6 +22,7 @@ var ErrNoToken = errors.New("github tracker: no token configured") // StaticTokenSource is a literal token, typically used in tests. type StaticTokenSource string +// Token returns the literal token, or ErrNoToken if it is blank. func (s StaticTokenSource) Token(context.Context) (string, error) { t := strings.TrimSpace(string(s)) if t == "" { @@ -39,6 +40,8 @@ type EnvTokenSource struct { EnvVars []string } +// Token returns the first non-empty configured env var (falling back to +// GITHUB_TOKEN), or ErrNoToken if none is set. func (s EnvTokenSource) Token(context.Context) (string, error) { for _, name := range s.EnvVars { if v := strings.TrimSpace(os.Getenv(name)); v != "" { diff --git a/backend/internal/adapters/tracker/github/tracker.go b/backend/internal/adapters/tracker/github/tracker.go index bf6ffcbfd2..a184fb1409 100644 --- a/backend/internal/adapters/tracker/github/tracker.go +++ b/backend/internal/adapters/tracker/github/tracker.go @@ -70,6 +70,7 @@ func (e *RateLimitError) Error() string { return ErrRateLimited.Error() } +// Is lets errors.Is match a *RateLimitError against the ErrRateLimited sentinel. func (e *RateLimitError) Is(target error) bool { return target == ErrRateLimited } // Options configures a Tracker. All fields except Token are optional — @@ -163,6 +164,7 @@ type ghUser struct { Login string `json:"login"` } +// Get fetches a single issue by id and maps it onto the normalized domain.Issue. func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) { owner, repo, number, err := t.parseID(id) if err != nil { @@ -220,8 +222,7 @@ func issueFromGH(owner, repo string, raw ghIssue) domain.Issue { // surface onto the normalized state. "in-review" wins over "in-progress" // when both labels are present (the workflow is progress -> review -> done). func mapStateFromGitHub(state, reason string, labels []string) domain.NormalizedIssueState { - switch strings.ToLower(state) { - case stateClosedGH: + if strings.EqualFold(state, stateClosedGH) { if strings.EqualFold(reason, reasonNotPlan) { return domain.IssueCancelled } @@ -374,7 +375,7 @@ func (t *Tracker) do(ctx context.Context, method, path string, body any) ([]byte if err != nil { return nil, fmt.Errorf("github tracker: %s %s: %w", method, path, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 200 && resp.StatusCode < 300 { return respBody, nil diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index da6d2d8321..9c4cc99383 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -18,16 +18,22 @@ const ( defaultBranch = "main" ) +// ErrUnsafePath is returned when a resolved worktree path escapes the managed +// root (path traversal guard). var ( ErrUnsafePath = errors.New("gitworktree: unsafe workspace path") ) +// RepoResolver maps a project to the absolute path of its source git repo. type RepoResolver interface { RepoPath(projectID domain.ProjectID) (string, error) } +// StaticRepoResolver is a RepoResolver backed by a fixed project→repo-path map. type StaticRepoResolver map[domain.ProjectID]string +// RepoPath returns the configured repo path for a project, or an error if none +// is configured. func (r StaticRepoResolver) RepoPath(projectID domain.ProjectID) (string, error) { path := r[projectID] if path == "" { @@ -36,6 +42,8 @@ func (r StaticRepoResolver) RepoPath(projectID domain.ProjectID) (string, error) return path, nil } +// Options configures a gitworktree Workspace. ManagedRoot and RepoResolver are +// required; Binary and DefaultBranch fall back to defaults. type Options struct { Binary string ManagedRoot string @@ -43,6 +51,8 @@ type Options struct { RepoResolver RepoResolver } +// Workspace creates per-session git worktrees under a managed root. It +// implements ports.Workspace. type Workspace struct { binary string managedRoot string @@ -55,6 +65,8 @@ type commandRunner func(ctx context.Context, binary string, args ...string) ([]b var _ ports.Workspace = (*Workspace)(nil) +// New builds a gitworktree Workspace, validating that ManagedRoot and +// RepoResolver are set and resolving the root to an absolute, symlink-free path. func New(opts Options) (*Workspace, error) { binary := opts.Binary if binary == "" { @@ -83,6 +95,8 @@ func New(opts Options) (*Workspace, error) { }, nil } +// Create adds a git worktree for the session under the managed root, checking +// out the requested branch, and returns where it landed. func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { if err := validateConfig(cfg); err != nil { return ports.WorkspaceInfo{}, err @@ -104,6 +118,8 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil } +// Destroy removes the session's worktree and prunes it from the repo, refusing +// (rather than force-deleting) if git still has the path registered afterwards. func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error { if info.ProjectID == "" { return errors.New("gitworktree: project id is required") @@ -139,6 +155,7 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error return nil } +// List returns the managed worktrees that belong to a project. func (w *Workspace) List(ctx context.Context, project domain.ProjectID) ([]ports.WorkspaceInfo, error) { if project == "" { return nil, errors.New("gitworktree: project id is required") @@ -158,6 +175,8 @@ func (w *Workspace) List(ctx context.Context, project domain.ProjectID) ([]ports return filterProjectWorktrees(records, projectRoot, project), nil } +// Restore re-attaches to an existing worktree for the session if one is still +// present, recreating the handle without disturbing its contents. func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { if err := validateConfig(cfg); err != nil { return ports.WorkspaceInfo{}, err diff --git a/backend/internal/cdc/event.go b/backend/internal/cdc/event.go index 5d37f47e26..16caaf741c 100644 --- a/backend/internal/cdc/event.go +++ b/backend/internal/cdc/event.go @@ -17,6 +17,7 @@ import ( // EventType mirrors the event_type values the DB triggers write. type EventType string +// Event types, one per row-change the DB triggers emit into change_log. const ( EventSessionCreated EventType = "session_created" EventSessionUpdated EventType = "session_updated" diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index 4c6953f2ea..59ad221c25 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -83,7 +83,7 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { Message: fmt.Sprintf("runFile=%s dataDir=%s port=%d", cfg.RunFilePath, cfg.DataDir, cfg.Port), }) - if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { + if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil { checks = append(checks, doctorCheck{Level: doctorFail, Name: "data-dir", Message: err.Error()}) } else { checks = append(checks, doctorCheck{Level: doctorPass, Name: "data-dir", Message: cfg.DataDir}) @@ -112,9 +112,11 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { checks = append(checks, doctorCheck{Level: level, Name: "daemon", Message: msg}) } - checks = append(checks, c.checkTool("git", true)) - checks = append(checks, c.checkTool("tmux", false)) - checks = append(checks, c.checkTool("zellij", false)) + checks = append(checks, + c.checkTool("git", true), + c.checkTool("tmux", false), + c.checkTool("zellij", false), + ) return checks } diff --git a/backend/internal/cli/start.go b/backend/internal/cli/start.go index 0787eba956..c6e7ee725c 100644 --- a/backend/internal/cli/start.go +++ b/backend/internal/cli/start.go @@ -82,14 +82,14 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da if logPath == "" { logPath = filepath.Join(filepath.Dir(cfg.RunFilePath), "daemon.log") } - if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(logPath), 0o750); err != nil { return daemonStatus{}, fmt.Errorf("create log dir: %w", err) } - logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return daemonStatus{}, fmt.Errorf("open daemon log: %w", err) } - defer logFile.Close() + defer func() { _ = logFile.Close() }() if _, err := c.deps.StartProcess(processStartConfig{ Path: exe, diff --git a/backend/internal/cli/status.go b/backend/internal/cli/status.go index 85cbf5ac63..8a020d5dbb 100644 --- a/backend/internal/cli/status.go +++ b/backend/internal/cli/status.go @@ -130,7 +130,7 @@ func (c *commandContext) readProbe(ctx context.Context, port int, path string) ( reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, fmt.Sprintf("http://%s:%d/%s", config.LoopbackHost, port, path), nil) + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, fmt.Sprintf("http://%s:%d/%s", config.LoopbackHost, port, path), http.NoBody) if err != nil { return probeResult{}, err } @@ -138,7 +138,7 @@ func (c *commandContext) readProbe(ctx context.Context, port int, path string) ( if err != nil { return probeResult{}, fmt.Errorf("%s: %w", path, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return probeResult{}, fmt.Errorf("%s: HTTP %d", path, resp.StatusCode) } diff --git a/backend/internal/cli/stop.go b/backend/internal/cli/stop.go index 9b00c1c406..b363b46312 100644 --- a/backend/internal/cli/stop.go +++ b/backend/internal/cli/stop.go @@ -79,7 +79,7 @@ func (c *commandContext) requestShutdown(ctx context.Context, port int) error { reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, fmt.Sprintf("http://%s:%d/shutdown", config.LoopbackHost, port), nil) + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, fmt.Sprintf("http://%s:%d/shutdown", config.LoopbackHost, port), http.NoBody) if err != nil { return err } @@ -87,7 +87,7 @@ func (c *commandContext) requestShutdown(ctx context.Context, port int) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("HTTP %d", resp.StatusCode) } diff --git a/backend/internal/cli/version.go b/backend/internal/cli/version.go index dd8a2598fe..7297cc13c5 100644 --- a/backend/internal/cli/version.go +++ b/backend/internal/cli/version.go @@ -14,6 +14,8 @@ var ( Date = "" ) +// VersionString renders the build metadata as " commit built ", +// omitting the commit/date parts when they are unset. func VersionString() string { parts := []string{Version} if Commit != "" { diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 556fe5f095..3cb4f45ccc 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -49,7 +49,7 @@ func Run() error { if err != nil { return fmt.Errorf("open store: %w", err) } - defer store.Close() + defer func() { _ = store.Close() }() // signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the // graceful shutdown inside Server.Run and stops the background goroutines. @@ -77,10 +77,7 @@ func Run() error { // Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE // timer). This makes the write path live end-to-end: LCM write -> store -> DB // trigger -> change_log -> poller -> broadcaster. - lcStack, err := startLifecycle(ctx, store, log) - if err != nil { - return err - } + lcStack := startLifecycle(ctx, store, log) // Bring up the Session Manager. Runtime (tmux) and Workspace (gitworktree) // are real on main; ports.Agent has no production adapter yet, so a loud diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index e96b55647f..65308f0e88 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -36,12 +36,12 @@ type lifecycleStack struct { // - noopMessenger — swap for the runtime/agent-plugin-backed AgentMessenger. // - reaper.MapRegistry{} — empty runtime registry, so the reaper ticks // escalations but probes nothing until the runtime plugins exist. -func startLifecycle(ctx context.Context, store *sqlite.Store, logger *slog.Logger) (*lifecycleStack, error) { +func startLifecycle(ctx context.Context, store *sqlite.Store, logger *slog.Logger) *lifecycleStack { renderer := notification.NewRenderer(store) notifier := notification.NewEnqueuer(store, renderer, logger) lcm := lifecycle.New(store, store, notifier, noopMessenger{}) rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Logger: logger}) - return &lifecycleStack{LCM: lcm, Store: store, reaperDone: rp.Start(ctx)}, nil + return &lifecycleStack{LCM: lcm, Store: store, reaperDone: rp.Start(ctx)} } // Stop waits for the reaper goroutine to exit (the caller must have cancelled the diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index f83be0dde2..3568eeb755 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -99,10 +99,7 @@ func TestWiring_SessionManagerSharesLifecycleStoreAndLCM(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) cfg := config.Config{DataDir: t.TempDir()} - lcStack, err := startLifecycle(ctx, store, log) - if err != nil { - t.Fatal(err) - } + lcStack := startLifecycle(ctx, store, log) // lcStack.Stop blocks on the reaper goroutine, which only exits once its // ctx is cancelled. Production main.go calls stop() before lcStack.Stop() // for the same reason — same ordering here. diff --git a/backend/internal/domain/decide/types.go b/backend/internal/domain/decide/types.go index 832fab6fe5..2e9a5c8437 100644 --- a/backend/internal/domain/decide/types.go +++ b/backend/internal/domain/decide/types.go @@ -41,6 +41,7 @@ type ProbeInput struct { // ProcessLiveness mirrors isProcessRunning's three-valued answer. type ProcessLiveness string +// Process liveness readings. const ( ProcessAlive ProcessLiveness = "alive" ProcessDead ProcessLiveness = "dead" diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go index a82ea85aed..155c099949 100644 --- a/backend/internal/domain/lifecycle.go +++ b/backend/internal/domain/lifecycle.go @@ -52,6 +52,7 @@ type CanonicalSessionLifecycle struct { // AgentHarness identifies which agent CLI/runtime a session drives. type AgentHarness string +// Supported agent harnesses. const ( HarnessClaudeCode AgentHarness = "claude-code" HarnessCodex AgentHarness = "codex" @@ -61,8 +62,10 @@ const ( // ---- session sub-state ---- +// SessionState is the canonical lifecycle phase of a session. type SessionState string +// The canonical session states (see the package doc for the transition model). const ( SessionNotStarted SessionState = "not_started" SessionWorking SessionState = "working" @@ -81,6 +84,7 @@ const ( // the pr table, not persisted on the session. type TerminationReason string +// Termination reasons; TermNone is the non-terminal zero value. const ( TermNone TerminationReason = "" TermManuallyKilled TerminationReason = "manually_killed" @@ -92,6 +96,8 @@ const ( TermPRMerged TerminationReason = "pr_merged" ) +// SessionSubstate wraps the session phase in a struct so the persisted/CDC JSON +// shape can gain fields without a migration. type SessionSubstate struct { State SessionState `json:"state"` } @@ -115,8 +121,10 @@ type PRFacts struct { ReviewComments bool // has unresolved review comments (any author) to address } +// CIState is the aggregate CI status of a PR. type CIState string +// CI states. const ( CIUnknown CIState = "unknown" CIPending CIState = "pending" @@ -124,8 +132,10 @@ const ( CIFailing CIState = "failing" ) +// ReviewDecision is the aggregate human-review verdict on a PR. type ReviewDecision string +// Review decisions. const ( ReviewNone ReviewDecision = "none" ReviewApproved ReviewDecision = "approved" @@ -133,8 +143,10 @@ const ( ReviewRequired ReviewDecision = "review_required" ) +// Mergeability is whether a PR can currently be merged. type Mergeability string +// Mergeability states. const ( MergeUnknown Mergeability = "unknown" MergeMergeable Mergeability = "mergeable" @@ -145,8 +157,10 @@ const ( // ---- activity sub-state (decider input) ---- +// ActivityState is how busy the agent is, derived from its output/JSONL. type ActivityState string +// Activity states. WaitingInput and Blocked are sticky (see IsSticky). const ( ActivityActive ActivityState = "active" ActivityReady ActivityState = "ready" @@ -162,8 +176,11 @@ func (a ActivityState) IsSticky() bool { return a == ActivityWaitingInput || a == ActivityBlocked } +// ActivitySource records where an activity reading came from, so a weaker +// source can't override a stronger one. type ActivitySource string +// Activity signal sources, strongest first. const ( SourceNative ActivitySource = "native" SourceTerminal ActivitySource = "terminal" @@ -172,6 +189,8 @@ const ( SourceNone ActivitySource = "none" ) +// ActivitySubstate is the persisted activity reading: the state, when it was +// last observed, and which source reported it. type ActivitySubstate struct { State ActivityState `json:"state"` LastActivityAt time.Time `json:"lastActivityAt"` @@ -180,6 +199,9 @@ type ActivitySubstate struct { // ---- detecting quarantine memory (decider input) ---- +// DetectingState is the anti-flap quarantine memory carried while a session is +// detecting: how many ambiguous observations, since when, and a hash of the +// (timestamp-stripped) evidence to tell "same signal again" from "signal moved". type DetectingState struct { Attempts int `json:"attempts"` StartedAt time.Time `json:"startedAt"` diff --git a/backend/internal/domain/pr.go b/backend/internal/domain/pr.go index 77f94f2758..a31b9958a3 100644 --- a/backend/internal/domain/pr.go +++ b/backend/internal/domain/pr.go @@ -6,7 +6,7 @@ import "time" // tables, shared by the PRWriter port and the sqlite store (the store maps them // to/from the sqlc gen.* models). They are flat by design — these tables carry // no nesting or derivation, so a single definition serves every layer. -// + // PRRow is the scalar facts of one tracked pull request (the pr table). A session // can own several PRs; a PR belongs to one session. PRFacts is the read-model // derived from these for display status; PRRow is what gets written. diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index 2b81088a40..4d436e2aea 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -2,16 +2,21 @@ package domain import "time" -// SessionID, ProjectID, IssueID are distinct string types so they can't be -// swapped at a call site by accident. +// These ID types are distinct string types so they can't be swapped at a call +// site by accident. type ( + // SessionID identifies a session. SessionID string + // ProjectID identifies a project. ProjectID string - IssueID string + // IssueID identifies a tracker issue. + IssueID string ) +// SessionKind distinguishes a worker session from an orchestrator session. type SessionKind string +// Session kinds. const ( KindWorker SessionKind = "worker" KindOrchestrator SessionKind = "orchestrator" diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index 3ae1e00c53..5fa0f72160 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -5,6 +5,7 @@ package domain // never persisted. type SessionStatus string +// The display statuses the dashboard renders. const ( StatusSpawning SessionStatus = "spawning" StatusWorking SessionStatus = "working" diff --git a/backend/internal/domain/tracker.go b/backend/internal/domain/tracker.go index 8fe0ed3b7b..c5f2226292 100644 --- a/backend/internal/domain/tracker.go +++ b/backend/internal/domain/tracker.go @@ -6,6 +6,7 @@ package domain // NormalizedIssueState. type TrackerProvider string +// Supported tracker providers. const ( TrackerProviderGitHub TrackerProvider = "github" TrackerProviderGitLab TrackerProvider = "gitlab" @@ -27,6 +28,7 @@ type TrackerID struct { // here is a port-level decision because every adapter must map it. type NormalizedIssueState string +// The normalized cross-provider issue states. const ( IssueOpen NormalizedIssueState = "open" IssueInProgress NormalizedIssueState = "in_progress" @@ -64,6 +66,7 @@ type TrackerRepo struct { // Labels field of ListFilter. type ListStateFilter string +// Coarse list-state filters for Tracker.List. const ( // ListAll is the zero value and returns issues in any state. ListAll ListStateFilter = "" diff --git a/backend/internal/httpd/apispec/apispec_test.go b/backend/internal/httpd/apispec/apispec_test.go index b5bde56273..a30721066b 100644 --- a/backend/internal/httpd/apispec/apispec_test.go +++ b/backend/internal/httpd/apispec/apispec_test.go @@ -1,6 +1,7 @@ package apispec_test import ( + "net/http" "net/http/httptest" "strings" "testing" @@ -55,7 +56,7 @@ func TestOperation_InheritsPathParameters(t *testing.T) { // whole rather than reconstructing it from per-operation slices. func TestServeYAML(t *testing.T) { rec := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/api/v1/openapi.yaml", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil) apispec.ServeYAML(rec, req) if rec.Code != 200 { diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index 8fa9db1f74..60e8159ee3 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -62,7 +62,7 @@ func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { } projects, err := c.Mgr.List(r.Context()) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, map[string]any{"projects": projects}) @@ -80,7 +80,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { } p, err := c.Mgr.Add(r.Context(), in) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusCreated, map[string]any{"project": p}) @@ -93,7 +93,7 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { } got, err := c.Mgr.Get(r.Context(), projectID(r)) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } if got.Status == "degraded" { @@ -123,7 +123,7 @@ func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request } p, err := c.Mgr.UpdateConfig(r.Context(), projectID(r), patch) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) @@ -136,7 +136,7 @@ func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { } result, err := c.Mgr.Remove(r.Context(), projectID(r)) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, result) @@ -149,7 +149,7 @@ func (c *ProjectsController) repair(w http.ResponseWriter, r *http.Request) { } p, err := c.Mgr.Repair(r.Context(), projectID(r)) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) @@ -162,7 +162,7 @@ func (c *ProjectsController) reload(w http.ResponseWriter, r *http.Request) { } result, err := c.Mgr.Reload(r.Context()) if err != nil { - writeProjectError(w, r, err, http.StatusInternalServerError) + writeProjectError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, result) @@ -196,10 +196,12 @@ func containsFrozenIdentityField(r *http.Request) ([]string, error) { return frozen, nil } -func writeProjectError(w http.ResponseWriter, r *http.Request, err error, fallbackStatus int) { +// writeProjectError maps a project.Error to its HTTP status, falling back to +// 500 for an unrecognized kind or a non-project.Error. +func writeProjectError(w http.ResponseWriter, r *http.Request, err error) { var pe *project.Error if errors.As(err, &pe) { - status := fallbackStatus + status := http.StatusInternalServerError switch pe.Kind { case "bad_request": status = http.StatusBadRequest @@ -215,5 +217,5 @@ func writeProjectError(w http.ResponseWriter, r *http.Request, err error, fallba envelope.WriteAPIError(w, r, status, pe.Kind, pe.Code, pe.Message, pe.Details) return } - envelope.WriteAPIError(w, r, fallbackStatus, "internal", "INTERNAL_ERROR", "Internal server error", nil) + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "INTERNAL_ERROR", "Internal server error", nil) } diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 5d132eb488..195907380d 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -37,6 +37,8 @@ func NewRouter(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) c return NewRouterWithAPI(cfg, log, termMgr, APIDeps{}) } +// ControlDeps carries the daemon-control hooks the router exposes, such as the +// callback that requests a graceful shutdown. type ControlDeps struct { RequestShutdown func() } @@ -48,6 +50,8 @@ func NewRouterWithAPI(cfg config.Config, log *slog.Logger, termMgr *terminal.Man return NewRouterWithControl(cfg, log, termMgr, deps, ControlDeps{}) } +// NewRouterWithControl is NewRouterWithAPI plus daemon-control hooks: it mounts +// the same API surface and additionally wires the ControlDeps callbacks. func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps, control ControlDeps) chi.Router { r := chi.NewRouter() diff --git a/backend/internal/httpd/server.go b/backend/internal/httpd/server.go index 0ed67eafb9..a9ddcbde87 100644 --- a/backend/internal/httpd/server.go +++ b/backend/internal/httpd/server.go @@ -71,7 +71,7 @@ func (s *Server) Run(ctx context.Context) error { StartedAt: time.Now().UTC(), } if err := runfile.Write(s.cfg.RunFilePath, info); err != nil { - s.listen.Close() + _ = s.listen.Close() return fmt.Errorf("write run-file: %w", err) } defer func() { diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index 67b781fb9b..e14a93fe37 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -700,7 +700,7 @@ func assertNotificationCreatedCDC(t *testing.T, store *sqlite.Store, after int64 type pollerSource struct{ *sqlite.Store } func (s pollerSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) { - rows, err := s.Store.ReadChangeLogAfter(ctx, after, limit) + rows, err := s.ReadChangeLogAfter(ctx, after, limit) if err != nil { return nil, err } @@ -718,7 +718,7 @@ func (s pollerSource) EventsAfter(ctx context.Context, after int64, limit int) ( return out, nil } func (s pollerSource) LatestSeq(ctx context.Context) (int64, error) { - return s.Store.MaxChangeLogSeq(ctx) + return s.MaxChangeLogSeq(ctx) } func anyEventType(evs []ports.Event, t string) bool { diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index dff0443d2c..19eada0150 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -35,6 +35,9 @@ type Manager struct { var _ ports.LifecycleManager = (*Manager)(nil) +// New builds a Lifecycle Manager over its collaborators: the session store it +// is the sole writer of, the PR-facts writer, the notifier, and the messenger +// used to nudge running agents. func New(store ports.SessionStore, pr ports.PRWriter, notifier ports.Notifier, messenger ports.AgentMessenger) *Manager { return &Manager{ store: store, diff --git a/backend/internal/notification/enqueuer.go b/backend/internal/notification/enqueuer.go index 79e902bf84..686490d219 100644 --- a/backend/internal/notification/enqueuer.go +++ b/backend/internal/notification/enqueuer.go @@ -24,6 +24,8 @@ type Enqueuer struct { var _ ports.Notifier = (*Enqueuer)(nil) +// NewEnqueuer returns a Notifier that renders events and persists the resulting +// notification rows via store, defaulting the logger to slog.Default. func NewEnqueuer(store Store, renderer *Renderer, logger *slog.Logger) *Enqueuer { if logger == nil { logger = slog.Default() @@ -31,6 +33,7 @@ func NewEnqueuer(store Store, renderer *Renderer, logger *slog.Logger) *Enqueuer return &Enqueuer{store: store, renderer: renderer, logger: logger} } +// Notify renders the event and enqueues the resulting notification row. func (e *Enqueuer) Notify(ctx context.Context, event ports.Event) error { row, err := e.renderer.Render(ctx, event) if err != nil { diff --git a/backend/internal/notification/payload.go b/backend/internal/notification/payload.go index 5492c19ce6..b4abaaca7f 100644 --- a/backend/internal/notification/payload.go +++ b/backend/internal/notification/payload.go @@ -17,6 +17,8 @@ type Payload struct { Merge *MergePayload `json:"merge,omitempty"` } +// SubjectPayload identifies what a notification is about — the session and, +// when relevant, its PR, issue, and branch. type SubjectPayload struct { Session *SessionSubjectPayload `json:"session,omitempty"` PR *PRSubjectPayload `json:"pr,omitempty"` @@ -24,40 +26,48 @@ type SubjectPayload struct { Branch string `json:"branch,omitempty"` } +// SessionSubjectPayload identifies the session a notification concerns. type SessionSubjectPayload struct { ID string `json:"id"` ProjectID string `json:"projectId"` } +// PRSubjectPayload identifies the PR a notification concerns. type PRSubjectPayload struct { Number int `json:"number,omitempty"` URL string `json:"url,omitempty"` Draft bool `json:"draft,omitempty"` } +// IssueSubjectPayload identifies the tracker issue a notification concerns. type IssueSubjectPayload struct { ID string `json:"id,omitempty"` } +// ReactionPayload carries the reaction that produced the notification. type ReactionPayload struct { Key string `json:"key"` Action string `json:"action"` } +// EscalationPayload carries the escalation that produced the notification. type EscalationPayload struct { Attempts int `json:"attempts"` Cause string `json:"cause"` DurationMs int64 `json:"durationMs"` } +// CIPayload is the CI context of a notification. type CIPayload struct { Status string `json:"status"` } +// ReviewPayload is the review context of a notification. type ReviewPayload struct { Decision string `json:"decision"` } +// MergePayload is the merge-readiness context of a notification. type MergePayload struct { Ready *bool `json:"ready,omitempty"` Conflicts *bool `json:"conflicts,omitempty"` diff --git a/backend/internal/notification/renderer.go b/backend/internal/notification/renderer.go index 21d41e3702..e10872cf57 100644 --- a/backend/internal/notification/renderer.go +++ b/backend/internal/notification/renderer.go @@ -24,10 +24,13 @@ type Renderer struct { clock func() time.Time } +// NewRenderer returns a Renderer that sources session/PR facts via reader. func NewRenderer(reader Reader) *Renderer { return &Renderer{reader: reader, clock: time.Now} } +// Render builds a durable Notification (subject + typed payload) from a +// lifecycle Event. func (r *Renderer) Render(ctx context.Context, event ports.Event) (domain.Notification, error) { if event.SessionID == "" { return domain.Notification{}, fmt.Errorf("render notification: missing session id") diff --git a/backend/internal/ports/facts.go b/backend/internal/ports/facts.go index 01a789617c..b119ecf634 100644 --- a/backend/internal/ports/facts.go +++ b/backend/internal/ports/facts.go @@ -14,6 +14,8 @@ import ( // route to the detecting quarantine, never to a death conclusion. type ProbeResult string +// Probe readings. Alive/Dead are conclusions; Failed/Unknown route to the +// detecting quarantine instead of a death decision. const ( ProbeAlive ProbeResult = "alive" ProbeDead ProbeResult = "dead" diff --git a/backend/internal/ports/inbound.go b/backend/internal/ports/inbound.go index 00223ae9a4..fa472d0038 100644 --- a/backend/internal/ports/inbound.go +++ b/backend/internal/ports/inbound.go @@ -40,6 +40,8 @@ type SessionManager interface { Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) } +// SpawnConfig is the request to start a new session: which project/issue, which +// agent harness, and the branch/prompt/rules the agent launches with. type SpawnConfig struct { ProjectID domain.ProjectID IssueID domain.IssueID diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index bc7321d334..58e1f509e7 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -44,8 +44,11 @@ type AgentMessenger interface { Send(ctx context.Context, id domain.SessionID, message string) error } +// Priority ranks a notification's urgency so a notifier can decide how loudly +// to surface it, from PriorityUrgent down to PriorityInfo. type Priority string +// Notification priorities, highest urgency first. const ( PriorityUrgent Priority = "urgent" PriorityAction Priority = "action" @@ -69,11 +72,15 @@ type Event struct { OccurredAt time.Time } +// ReactionEvent is the reaction context carried on an Event: which reaction +// fired and whether it merely notified or escalated. type ReactionEvent struct { Key string // agent-needs-input, approved-and-green, ci-failed, etc. Action string // notify | escalated } +// EscalationEvent is the escalation context carried on an Event once a reaction +// has exhausted its retry/attempt/duration budget. type EscalationEvent struct { Attempts int Cause string // max_retries | max_attempts | max_duration @@ -82,12 +89,15 @@ type EscalationEvent struct { // ---- runtime / agent / workspace plugin ports (used by the Session Manager) ---- +// Runtime is where a session's agent process runs — a tmux/zellij session or a +// bare process. The Session Manager creates one per session and tears it down. type Runtime interface { Create(ctx context.Context, cfg RuntimeConfig) (RuntimeHandle, error) Destroy(ctx context.Context, handle RuntimeHandle) error IsAlive(ctx context.Context, handle RuntimeHandle) (bool, error) } +// RuntimeConfig is the spec for launching a session's process in a Runtime. type RuntimeConfig struct { SessionID domain.SessionID WorkspacePath string @@ -95,35 +105,42 @@ type RuntimeConfig struct { Env map[string]string } +// RuntimeHandle identifies a live runtime instance (e.g. a tmux session). type RuntimeHandle struct { ID string RuntimeName string } +// Agent is the AI coding tool driving a session (claude-code, codex, …): it +// supplies the launch/restore commands and the process environment. type Agent interface { GetLaunchCommand(cfg AgentConfig) string GetEnvironment(cfg AgentConfig) map[string]string GetRestoreCommand(agentSessionID string) string } +// AgentConfig is the per-session input to an Agent's command and environment. type AgentConfig struct { SessionID domain.SessionID WorkspacePath string Prompt string } +// Workspace is the isolated checkout an agent works in (a git worktree or clone). type Workspace interface { Create(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) Destroy(ctx context.Context, info WorkspaceInfo) error Restore(ctx context.Context, cfg WorkspaceConfig) (WorkspaceInfo, error) } +// WorkspaceConfig is the spec for creating or restoring a session's workspace. type WorkspaceConfig struct { ProjectID domain.ProjectID SessionID domain.SessionID Branch string } +// WorkspaceInfo describes a created workspace — where it lives and its branch. type WorkspaceInfo struct { Path string Branch string diff --git a/backend/internal/project/manager.go b/backend/internal/project/manager.go index 93ca84d9c2..54c93b9a4d 100644 --- a/backend/internal/project/manager.go +++ b/backend/internal/project/manager.go @@ -19,6 +19,8 @@ type manager struct { var _ Manager = (*manager)(nil) +// NewManager returns a project Manager backed by the given Store, defaulting to +// an in-memory store when store is nil. func NewManager(store Store) Manager { if store == nil { store = NewMemoryStore() @@ -26,6 +28,8 @@ func NewManager(store Store) Manager { return &manager{store: store} } +// NewMemoryManager returns a project Manager backed by a fresh in-memory store, +// for tests and ephemeral use. func NewMemoryManager() Manager { return NewManager(NewMemoryStore()) } @@ -103,7 +107,7 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { }) } - row := ProjectRow{ + row := Row{ ID: string(id), Path: path, DisplayName: name, @@ -173,7 +177,7 @@ func (m *manager) suggestID(ctx context.Context, base domain.ProjectID) domain.P } } -func projectFromRow(row ProjectRow) Project { +func projectFromRow(row Row) Project { return Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), @@ -183,7 +187,7 @@ func projectFromRow(row ProjectRow) Project { } } -func displayName(row ProjectRow) string { +func displayName(row Row) string { if strings.TrimSpace(row.DisplayName) != "" { return row.DisplayName } diff --git a/backend/internal/project/memory_store.go b/backend/internal/project/memory_store.go index 945a78268b..e947136c26 100644 --- a/backend/internal/project/memory_store.go +++ b/backend/internal/project/memory_store.go @@ -6,10 +6,10 @@ import ( "time" ) -// ProjectRow mirrors the project table shape from the sqlite storage PR. The +// Row mirrors the project table shape from the sqlite storage PR. The // memory store is intentionally row-based so the API layer does not depend on a // richer mock model than the real DB will provide. -type ProjectRow struct { +type Row struct { ID string Path string RepoOriginURL string @@ -18,11 +18,13 @@ type ProjectRow struct { ArchivedAt time.Time } +// Store is the project persistence the manager depends on; both the sqlite +// store and MemoryStore satisfy it. type Store interface { - List(ctx context.Context) ([]ProjectRow, error) - Get(ctx context.Context, id string) (ProjectRow, bool, error) - FindByPath(ctx context.Context, path string) (ProjectRow, bool, error) - Upsert(ctx context.Context, row ProjectRow) error + List(ctx context.Context) ([]Row, error) + Get(ctx context.Context, id string) (Row, bool, error) + FindByPath(ctx context.Context, path string) (Row, bool, error) + Upsert(ctx context.Context, row Row) error Archive(ctx context.Context, id string, at time.Time) (bool, error) } @@ -30,24 +32,26 @@ type Store interface { // process-local and intentionally small, but concurrency-safe for HTTP tests. type MemoryStore struct { mu sync.Mutex - projects map[string]ProjectRow + projects map[string]Row paths map[string]string } var _ Store = (*MemoryStore)(nil) +// NewMemoryStore returns an empty, ready-to-use in-memory project store. func NewMemoryStore() *MemoryStore { return &MemoryStore{ - projects: map[string]ProjectRow{}, + projects: map[string]Row{}, paths: map[string]string{}, } } -func (s *MemoryStore) List(context.Context) ([]ProjectRow, error) { +// List returns all non-archived projects, in unspecified order. +func (s *MemoryStore) List(context.Context) ([]Row, error) { s.mu.Lock() defer s.mu.Unlock() - out := make([]ProjectRow, 0, len(s.projects)) + out := make([]Row, 0, len(s.projects)) for _, row := range s.projects { if row.ArchivedAt.IsZero() { out = append(out, row) @@ -56,33 +60,36 @@ func (s *MemoryStore) List(context.Context) ([]ProjectRow, error) { return out, nil } -func (s *MemoryStore) Get(_ context.Context, id string) (ProjectRow, bool, error) { +// Get returns the project with the given id, or ok=false if absent. +func (s *MemoryStore) Get(_ context.Context, id string) (Row, bool, error) { s.mu.Lock() defer s.mu.Unlock() row, ok := s.projects[id] if !ok { - return ProjectRow{}, false, nil + return Row{}, false, nil } return row, true, nil } -func (s *MemoryStore) FindByPath(_ context.Context, path string) (ProjectRow, bool, error) { +// FindByPath returns the project registered at a filesystem path, or ok=false. +func (s *MemoryStore) FindByPath(_ context.Context, path string) (Row, bool, error) { s.mu.Lock() defer s.mu.Unlock() id, ok := s.paths[path] if !ok { - return ProjectRow{}, false, nil + return Row{}, false, nil } row, ok := s.projects[id] if !ok { - return ProjectRow{}, false, nil + return Row{}, false, nil } return row, true, nil } -func (s *MemoryStore) Upsert(_ context.Context, row ProjectRow) error { +// Upsert inserts or replaces a project, keeping the path→id index in sync. +func (s *MemoryStore) Upsert(_ context.Context, row Row) error { s.mu.Lock() defer s.mu.Unlock() @@ -94,6 +101,8 @@ func (s *MemoryStore) Upsert(_ context.Context, row ProjectRow) error { return nil } +// Archive soft-deletes a project by stamping ArchivedAt; returns ok=false if +// the project doesn't exist. func (s *MemoryStore) Archive(_ context.Context, id string, at time.Time) (bool, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/backend/internal/runfile/runfile.go b/backend/internal/runfile/runfile.go index 7dafe1befe..3db84590c5 100644 --- a/backend/internal/runfile/runfile.go +++ b/backend/internal/runfile/runfile.go @@ -31,7 +31,7 @@ type Info struct { // partial file and a stale running.json from a crashed predecessor is // overwritten without an intermediate "no file" window. func Write(path string, info Info) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return fmt.Errorf("create run-file dir: %w", err) } data, err := json.MarshalIndent(info, "", " ") @@ -45,10 +45,10 @@ func Write(path string, info Info) error { return fmt.Errorf("create temp run-file: %w", err) } tmpName := tmp.Name() - defer os.Remove(tmpName) // no-op once the rename succeeds + defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds if _, err := tmp.Write(data); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("write temp run-file: %w", err) } if err := tmp.Close(); err != nil { diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index d7350f5f33..37b1de813e 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -14,6 +14,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) +// Sentinel errors returned by the Session Manager. var ( ErrNotFound = errors.New("session: not found") ErrNotRestorable = errors.New("session: not restorable (not terminal)") @@ -40,6 +41,7 @@ type Manager struct { var _ ports.SessionManager = (*Manager)(nil) +// Deps are the collaborators a Session Manager needs; New wires them together. type Deps struct { Runtime ports.Runtime Agent ports.Agent @@ -50,6 +52,8 @@ type Deps struct { Clock func() time.Time } +// New builds a Session Manager from its dependencies, defaulting the clock to +// time.Now when Deps.Clock is nil. func New(d Deps) *Manager { m := &Manager{ runtime: d.Runtime, @@ -184,6 +188,7 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess return m.Get(ctx, id) } +// List returns the project's sessions as enriched display models. func (m *Manager) List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) { recs, err := m.store.ListSessions(ctx, project) if err != nil { @@ -200,6 +205,7 @@ func (m *Manager) List(ctx context.Context, project domain.ProjectID) ([]domain. return out, nil } +// Get returns one session as a display model, or ErrNotFound if it is absent. func (m *Manager) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, ok, err := m.store.GetSession(ctx, id) if err != nil { @@ -211,6 +217,7 @@ func (m *Manager) Get(ctx context.Context, id domain.SessionID) (domain.Session, return m.toSession(ctx, rec) } +// Send delivers a message to a running session's agent via the messenger. func (m *Manager) Send(ctx context.Context, id domain.SessionID, message string) error { if err := m.messenger.Send(ctx, id, message); err != nil { return fmt.Errorf("send %s: %w", id, err) diff --git a/backend/internal/storage/sqlite/db.go b/backend/internal/storage/sqlite/db.go index 7f8535bfa7..280b48e01b 100644 --- a/backend/internal/storage/sqlite/db.go +++ b/backend/internal/storage/sqlite/db.go @@ -44,7 +44,7 @@ const maxReaders = 8 // - a READER pool (readDB, MaxOpenConns=maxReaders): all reads scale across // it; WAL readers see the latest committed snapshot. func Open(dataDir string) (*Store, error) { - if err := os.MkdirAll(dataDir, 0o755); err != nil { + if err := os.MkdirAll(dataDir, 0o750); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } dsn := "file:" + filepath.Join(dataDir, "ao.db") + pragmas @@ -56,13 +56,13 @@ func Open(dataDir string) (*Store, error) { writeDB.SetMaxOpenConns(1) writeDB.SetMaxIdleConns(1) if err := migrate(writeDB); err != nil { - writeDB.Close() + _ = writeDB.Close() return nil, err } readDB, err := sql.Open("sqlite", dsn) if err != nil { - writeDB.Close() + _ = writeDB.Close() return nil, fmt.Errorf("open sqlite reader: %w", err) } readDB.SetMaxOpenConns(maxReaders) diff --git a/backend/internal/storage/sqlite/store.go b/backend/internal/storage/sqlite/store.go index 800c18240e..34d028da38 100644 --- a/backend/internal/storage/sqlite/store.go +++ b/backend/internal/storage/sqlite/store.go @@ -126,7 +126,7 @@ func (s *Store) inTx(ctx context.Context, what string, fn func(*gen.Queries) err if err != nil { return fmt.Errorf("begin %s: %w", what, err) } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() if err := fn(s.qw.WithTx(tx)); err != nil { return fmt.Errorf("%s: %w", what, err) } diff --git a/backend/internal/storage/sqlite/store_test.go b/backend/internal/storage/sqlite/store_test.go index 832bcfa480..426a37d22c 100644 --- a/backend/internal/storage/sqlite/store_test.go +++ b/backend/internal/storage/sqlite/store_test.go @@ -248,9 +248,9 @@ func TestCDCTriggersPopulateChangeLog(t *testing.T) { if len(types) != 3 || types[0] != want[0] || types[1] != want[1] || types[2] != want[2] { t.Fatalf("change_log event types = %v, want %v (metadata-only update suppressed)", types, want) } - max, _ := s.MaxChangeLogSeq(ctx) - if max != int64(len(evs)) { - t.Fatalf("max seq = %d, want %d", max, len(evs)) + maxSeq, _ := s.MaxChangeLogSeq(ctx) + if maxSeq != int64(len(evs)) { + t.Fatalf("max seq = %d, want %d", maxSeq, len(evs)) } } diff --git a/backend/internal/terminal/ring.go b/backend/internal/terminal/ring.go index c0194a1b2e..ed55ca6591 100644 --- a/backend/internal/terminal/ring.go +++ b/backend/internal/terminal/ring.go @@ -16,11 +16,11 @@ type ringBuffer struct { max int } -func newRingBuffer(max int) *ringBuffer { - if max <= 0 { - max = defaultRingMax +func newRingBuffer(maxBytes int) *ringBuffer { + if maxBytes <= 0 { + maxBytes = defaultRingMax } - return &ringBuffer{max: max} + return &ringBuffer{max: maxBytes} } // append adds p and drops the oldest bytes beyond max. A single write larger From a34094e7d8b18bdafd3c89fe04e78c3ae2920cff Mon Sep 17 00:00:00 2001 From: prateek Date: Mon, 1 Jun 2026 08:42:49 +0530 Subject: [PATCH 097/250] refactor: simplify session lifecycle and zellij runtime (#62) * refactor: remove canonical lifecycle state * refactor: move sqlite stores into subpackage (#62) * refactor: strengthen sqlite generated model types (#62) * refactor: remove lifecycle notifications (#62) * docs: remove notification cleanup leftovers (#62) * refactor: narrow lifecycle manager scope (#62) * refactor: keep PR nudges in lifecycle (#62) * refactor: trim unused storage and lifecycle contracts (#62) * refactor: align storage and runtime observation surfaces (#62) * refactor: remove stale daemon and adapter bloat (#62) * test: fix terminal ring race assertion (#62) * refactor: trim lifecycle and http boilerplate (#62) * refactor: expose sqlite CDC source directly (#62) * refactor: share process liveness checks (#62) * test: trim lifecycle store fake surface (#62) * refactor: separate PR observations from storage rows (#62) * refactor: trim remaining cleanup surfaces (#62) * refactor: narrow observation and PR display APIs (#62) * refactor: move PR write DTOs out of domain (#62) * refactor: normalize PR domain storage types (#62) * refactor: remove unused session port interface (#62) * fix: reject unexpected CLI arguments (#62) * refactor: use session metadata for spawn completion (#62) * refactor: narrow session runtime dependency (#62) * fix: validate zellij version in doctor (#62) * refactor: split observation port DTOs (#62) * chore: add sqlc generation script (#62) * refactor: clarify terminal mux naming (#62) * fix: tolerate nil loggers (#62) --------- Co-authored-by: itrytoohard --- .gitignore | 3 + README.md | 12 +- .../adapters/runtime/tmux/commands.go | 97 --- .../internal/adapters/runtime/tmux/tmux.go | 296 ------- .../runtime/tmux/tmux_integration_test.go | 112 --- .../adapters/runtime/tmux/tmux_test.go | 256 ------ .../adapters/runtime/zellij/commands.go | 26 +- .../adapters/runtime/zellij/zellij.go | 35 +- .../runtime/zellij/zellij_integration_test.go | 8 +- .../adapters/runtime/zellij/zellij_test.go | 45 +- .../internal/adapters/tracker/github/doc.go | 2 +- .../adapters/tracker/github/tracker.go | 22 +- .../adapters/tracker/github/tracker_test.go | 8 +- .../workspace/gitworktree/commands.go | 15 +- .../workspace/gitworktree/workspace.go | 54 +- .../gitworktree/workspace_integration_test.go | 10 +- .../workspace/gitworktree/workspace_test.go | 31 +- backend/internal/cdc/broadcast.go | 10 +- backend/internal/cdc/cdc_test.go | 50 +- backend/internal/cdc/event.go | 15 +- backend/internal/cli/doctor.go | 29 +- backend/internal/cli/doctor_test.go | 71 ++ backend/internal/cli/process.go | 10 +- backend/internal/cli/process_unix.go | 13 +- backend/internal/cli/process_windows.go | 21 - backend/internal/cli/root.go | 53 +- backend/internal/cli/root_test.go | 33 +- backend/internal/cli/start.go | 13 +- backend/internal/cli/status.go | 51 +- backend/internal/cli/stop.go | 13 +- backend/internal/cli/stop_test.go | 4 +- backend/internal/cli/version.go | 1 + backend/internal/config/config.go | 23 +- backend/internal/config/config_test.go | 17 +- backend/internal/daemon/cdc_wiring.go | 39 +- backend/internal/daemon/daemon.go | 48 +- backend/internal/daemon/lifecycle_wiring.go | 140 +--- backend/internal/daemon/wiring_test.go | 100 +-- backend/internal/domain/activity.go | 63 ++ backend/internal/domain/decide/decide.go | 158 ---- backend/internal/domain/decide/decide_test.go | 164 ---- backend/internal/domain/decide/types.go | 58 -- backend/internal/domain/doc.go | 5 + backend/internal/domain/harness.go | 12 + backend/internal/domain/lifecycle.go | 209 ----- backend/internal/domain/notification.go | 44 -- backend/internal/domain/pr.go | 103 ++- backend/internal/domain/session.go | 50 +- backend/internal/domain/status.go | 76 +- backend/internal/domain/status_test.go | 67 +- backend/internal/domain/tracker.go | 39 +- backend/internal/httpd/api.go | 45 +- backend/internal/httpd/apispec/apispec.go | 24 +- backend/internal/httpd/apispec/openapi.yaml | 60 +- .../internal/httpd/controllers/projects.go | 25 +- .../httpd/controllers/projects_test.go | 3 +- backend/internal/httpd/errors.go | 22 - backend/internal/httpd/json.go | 14 - backend/internal/httpd/logger.go | 10 + backend/internal/httpd/logger_test.go | 19 + backend/internal/httpd/router.go | 35 +- backend/internal/httpd/server.go | 10 +- .../httpd/{mux.go => terminal_mux.go} | 34 +- .../{mux_test.go => terminal_mux_test.go} | 12 +- .../integration/lifecycle_sqlite_test.go | 746 +++--------------- backend/internal/lifecycle/decide_bridge.go | 112 --- backend/internal/lifecycle/manager.go | 323 ++------ backend/internal/lifecycle/manager_test.go | 348 +++----- backend/internal/lifecycle/reactions.go | 424 ++-------- backend/internal/lifecycle/runtime.go | 35 + backend/internal/notification/dedupe.go | 74 -- backend/internal/notification/dedupe_test.go | 63 -- backend/internal/notification/enqueuer.go | 53 -- .../internal/notification/enqueuer_test.go | 38 - backend/internal/notification/payload.go | 75 -- backend/internal/notification/renderer.go | 201 ----- .../internal/notification/renderer_test.go | 133 ---- backend/internal/observe/reaper/reaper.go | 137 ++-- .../internal/observe/reaper/reaper_test.go | 73 +- backend/internal/ports/doc.go | 5 + backend/internal/ports/facts.go | 69 -- backend/internal/ports/inbound.go | 53 -- backend/internal/ports/outbound.go | 83 +- backend/internal/ports/pr_observations.go | 40 + .../internal/ports/runtime_observations.go | 34 + backend/internal/ports/session.go | 15 + backend/internal/ports/tracker.go | 12 +- backend/internal/pr/manager.go | 67 ++ backend/internal/pr/manager_test.go | 87 ++ backend/internal/processalive/process_unix.go | 20 + .../internal/processalive/process_windows.go | 30 + backend/internal/project/dto.go | 8 +- backend/internal/project/memory_store.go | 4 +- backend/internal/project/project.go | 11 +- backend/internal/project/types.go | 48 +- backend/internal/runfile/process_unix.go | 24 - backend/internal/runfile/process_windows.go | 21 - backend/internal/runfile/runfile.go | 18 +- backend/internal/runfile/runfile_test.go | 26 + backend/internal/session/manager.go | 95 ++- backend/internal/session/manager_test.go | 140 +--- .../storage/sqlite/changelog_store.go | 89 --- backend/internal/storage/sqlite/db.go | 14 +- .../storage/sqlite/gen/changelog.sql.go | 47 +- backend/internal/storage/sqlite/gen/models.go | 101 +-- .../storage/sqlite/gen/notifications.sql.go | 464 ----------- backend/internal/storage/sqlite/gen/pr.sql.go | 100 ++- .../storage/sqlite/gen/pr_checks.sql.go | 66 +- .../storage/sqlite/gen/pr_comment.sql.go | 74 +- .../storage/sqlite/gen/projects.sql.go | 46 +- .../internal/storage/sqlite/gen/querier.go | 48 -- .../storage/sqlite/gen/sessions.sql.go | 161 ++-- backend/internal/storage/sqlite/mapping.go | 125 --- .../storage/sqlite/migrations/0001_init.sql | 59 +- .../sqlite/migrations/0002_notifications.sql | 81 -- .../storage/sqlite/notification_store.go | 242 ------ .../storage/sqlite/notification_store_test.go | 232 ------ backend/internal/storage/sqlite/pr_facts.go | 43 - backend/internal/storage/sqlite/pr_store.go | 246 ------ .../internal/storage/sqlite/project_store.go | 93 --- .../storage/sqlite/queries/changelog.sql | 5 +- .../storage/sqlite/queries/notifications.sql | 70 -- .../internal/storage/sqlite/queries/pr.sql | 33 +- .../storage/sqlite/queries/pr_checks.sql | 8 +- .../storage/sqlite/queries/pr_comment.sql | 10 +- .../storage/sqlite/queries/projects.sql | 6 +- .../storage/sqlite/queries/sessions.sql | 31 +- backend/internal/storage/sqlite/store.go | 134 ---- .../storage/sqlite/store/changelog_store.go | 46 ++ .../storage/sqlite/{ => store}/pr_cdc_test.go | 25 +- .../internal/storage/sqlite/store/pr_facts.go | 40 + .../internal/storage/sqlite/store/pr_store.go | 210 +++++ .../storage/sqlite/store/project_store.go | 101 +++ .../storage/sqlite/store/session_store.go | 163 ++++ .../internal/storage/sqlite/store/store.go | 60 ++ .../storage/sqlite/{ => store}/store_test.go | 176 ++--- backend/internal/terminal/doc.go | 8 +- backend/internal/terminal/fakes_test.go | 9 +- backend/internal/terminal/logger_test.go | 19 + backend/internal/terminal/manager.go | 25 +- backend/internal/terminal/protocol.go | 4 +- backend/internal/terminal/pty_unix.go | 4 +- backend/internal/terminal/pty_windows.go | 5 +- backend/internal/terminal/ring.go | 10 +- backend/internal/terminal/session.go | 17 +- .../terminal/session_integration_test.go | 49 +- backend/internal/terminal/session_test.go | 12 +- backend/sqlc.yaml | 79 +- docs/README.md | 42 +- docs/architecture.md | 234 ++---- docs/cli/README.md | 410 +--------- docs/status.md | 111 +-- package.json | 9 + test/cli/Dockerfile | 8 +- 154 files changed, 3054 insertions(+), 8342 deletions(-) delete mode 100644 backend/internal/adapters/runtime/tmux/commands.go delete mode 100644 backend/internal/adapters/runtime/tmux/tmux.go delete mode 100644 backend/internal/adapters/runtime/tmux/tmux_integration_test.go delete mode 100644 backend/internal/adapters/runtime/tmux/tmux_test.go create mode 100644 backend/internal/cli/doctor_test.go create mode 100644 backend/internal/domain/activity.go delete mode 100644 backend/internal/domain/decide/decide.go delete mode 100644 backend/internal/domain/decide/decide_test.go delete mode 100644 backend/internal/domain/decide/types.go create mode 100644 backend/internal/domain/doc.go create mode 100644 backend/internal/domain/harness.go delete mode 100644 backend/internal/domain/lifecycle.go delete mode 100644 backend/internal/domain/notification.go delete mode 100644 backend/internal/httpd/errors.go delete mode 100644 backend/internal/httpd/json.go create mode 100644 backend/internal/httpd/logger.go create mode 100644 backend/internal/httpd/logger_test.go rename backend/internal/httpd/{mux.go => terminal_mux.go} (51%) rename backend/internal/httpd/{mux_test.go => terminal_mux_test.go} (90%) delete mode 100644 backend/internal/lifecycle/decide_bridge.go create mode 100644 backend/internal/lifecycle/runtime.go delete mode 100644 backend/internal/notification/dedupe.go delete mode 100644 backend/internal/notification/dedupe_test.go delete mode 100644 backend/internal/notification/enqueuer.go delete mode 100644 backend/internal/notification/enqueuer_test.go delete mode 100644 backend/internal/notification/payload.go delete mode 100644 backend/internal/notification/renderer.go delete mode 100644 backend/internal/notification/renderer_test.go create mode 100644 backend/internal/ports/doc.go delete mode 100644 backend/internal/ports/facts.go delete mode 100644 backend/internal/ports/inbound.go create mode 100644 backend/internal/ports/pr_observations.go create mode 100644 backend/internal/ports/runtime_observations.go create mode 100644 backend/internal/ports/session.go create mode 100644 backend/internal/pr/manager.go create mode 100644 backend/internal/pr/manager_test.go create mode 100644 backend/internal/processalive/process_unix.go create mode 100644 backend/internal/processalive/process_windows.go delete mode 100644 backend/internal/runfile/process_unix.go delete mode 100644 backend/internal/runfile/process_windows.go delete mode 100644 backend/internal/storage/sqlite/changelog_store.go delete mode 100644 backend/internal/storage/sqlite/gen/notifications.sql.go delete mode 100644 backend/internal/storage/sqlite/gen/querier.go delete mode 100644 backend/internal/storage/sqlite/mapping.go delete mode 100644 backend/internal/storage/sqlite/migrations/0002_notifications.sql delete mode 100644 backend/internal/storage/sqlite/notification_store.go delete mode 100644 backend/internal/storage/sqlite/notification_store_test.go delete mode 100644 backend/internal/storage/sqlite/pr_facts.go delete mode 100644 backend/internal/storage/sqlite/pr_store.go delete mode 100644 backend/internal/storage/sqlite/project_store.go delete mode 100644 backend/internal/storage/sqlite/queries/notifications.sql delete mode 100644 backend/internal/storage/sqlite/store.go create mode 100644 backend/internal/storage/sqlite/store/changelog_store.go rename backend/internal/storage/sqlite/{ => store}/pr_cdc_test.go (72%) create mode 100644 backend/internal/storage/sqlite/store/pr_facts.go create mode 100644 backend/internal/storage/sqlite/store/pr_store.go create mode 100644 backend/internal/storage/sqlite/store/project_store.go create mode 100644 backend/internal/storage/sqlite/store/session_store.go create mode 100644 backend/internal/storage/sqlite/store/store.go rename backend/internal/storage/sqlite/{ => store}/store_test.go (54%) create mode 100644 backend/internal/terminal/logger_test.go create mode 100644 package.json diff --git a/.gitignore b/.gitignore index 425b31d78a..c883e6e68a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ agent-orchestrator.yaml session-events.jsonl session-events.jsonl.* +# Agent Orchestrator local session state +.ao/ + # Environment .env .env.* diff --git a/README.md b/README.md index 61a639d2a2..33e08dae8c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # agent-orchestrator Rewrite of the agent-orchestrator: a long-running Go backend daemon (`backend/`) -paired with an Electron + TypeScript frontend (`frontend/`). +paired with a placeholder Electron + TypeScript frontend shell (`frontend/`). See [`docs/`](docs/README.md) for architecture and status — start with the Lifecycle Manager + Session Manager lane in [`docs/architecture.md`](docs/architecture.md). @@ -31,8 +31,8 @@ AO_PORT=3019 go run ./cmd/ao start # override per invocation Health check: ```bash -curl localhost:3001/healthz # {"status":"ok"} -curl localhost:3001/readyz # {"status":"ready"} +curl localhost:3001/healthz # includes status/service/pid +curl localhost:3001/readyz # includes status/service/pid ``` ### Configuration (env only) @@ -47,10 +47,12 @@ is intentionally not env-configurable. | `AO_REQUEST_TIMEOUT` | `60s` | per-request timeout (Go duration) | | `AO_SHUTDOWN_TIMEOUT` | `10s` | graceful-shutdown hard cap | | `AO_RUN_FILE` | `/agent-orchestrator/running.json` | PID + port handshake path | +| `AO_DATA_DIR` | `/agent-orchestrator/data` | SQLite DB, WAL files, and managed state | ### Test ```bash -cd backend -gofmt -l . && go build ./... && go vet ./... && go test -race ./... +npm run lint +# optional deeper backend pass: +cd backend && go test -race ./... ``` diff --git a/backend/internal/adapters/runtime/tmux/commands.go b/backend/internal/adapters/runtime/tmux/commands.go deleted file mode 100644 index 6cf8739ebd..0000000000 --- a/backend/internal/adapters/runtime/tmux/commands.go +++ /dev/null @@ -1,97 +0,0 @@ -package tmux - -import ( - "fmt" - "sort" - "strings" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -const runtimeName = "tmux" - -func newSessionArgs(id, workspacePath, shellPath, script string) []string { - return []string{"new-session", "-d", "-s", id, "-c", workspacePath, shellPath, "-lc", script} -} - -func setStatusOffArgs(id string) []string { - return []string{"set-option", "-t", exactSessionTarget(id), "status", "off"} -} - -func hasSessionArgs(id string) []string { - return []string{"has-session", "-t", exactSessionTarget(id)} -} - -func killSessionArgs(id string) []string { - return []string{"kill-session", "-t", exactSessionTarget(id)} -} - -func capturePaneArgs(id string, lines int) []string { - return []string{"capture-pane", "-p", "-t", exactPaneTarget(id), "-S", fmt.Sprintf("-%d", lines)} -} - -func sendLiteralArgs(id, message string) []string { - return []string{"send-keys", "-t", exactPaneTarget(id), "-l", message} -} - -func sendEnterArgs(id string) []string { - return []string{"send-keys", "-t", exactPaneTarget(id), "C-m"} -} - -func loadBufferArgs(bufferName, path string) []string { - return []string{"load-buffer", "-b", bufferName, path} -} - -func pasteBufferArgs(id, bufferName string) []string { - return []string{"paste-buffer", "-d", "-t", exactPaneTarget(id), "-b", bufferName} -} - -func exactSessionTarget(id string) string { - return "=" + id + ":" -} - -func exactPaneTarget(id string) string { - return "=" + id + ":0.0" -} - -func wrapLaunchCommand(cfg ports.RuntimeConfig, shellPath string) string { - path := cfg.Env["PATH"] - if path == "" { - path = getenv("PATH") - } - - var b strings.Builder - for _, key := range sortedKeys(cfg.Env) { - if key == "PATH" { - continue - } - b.WriteString("export ") - b.WriteString(key) - b.WriteString("=") - b.WriteString(shellQuote(cfg.Env[key])) - b.WriteString("; ") - } - if path != "" { - b.WriteString("export PATH=") - b.WriteString(shellQuote(path)) - b.WriteString("; ") - } - b.WriteString(cfg.LaunchCommand) - b.WriteString("; exec ") - b.WriteString(shellQuote(shellPath)) - b.WriteString(" -i") - return b.String() -} - -func sortedKeys(m map[string]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -func shellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" -} diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go deleted file mode 100644 index ae0d0445f9..0000000000 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ /dev/null @@ -1,296 +0,0 @@ -// Package tmux implements ports.Runtime using tmux sessions. -package tmux - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -const defaultTimeout = 5 * time.Second -const longMessageThreshold = 512 - -var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - -var getenv = os.Getenv - -// Options configures a tmux Runtime; every field has a default (see New). -type Options struct { - Binary string - Timeout time.Duration - Shell string -} - -// Runtime runs agent sessions inside tmux sessions, driving them via the tmux -// CLI. It implements ports.Runtime. -type Runtime struct { - binary string - timeout time.Duration - shell string - runner runner -} - -var _ ports.Runtime = (*Runtime)(nil) - -type runner interface { - Run(ctx context.Context, name string, args ...string) ([]byte, error) -} - -type execRunner struct{} - -func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { - return exec.CommandContext(ctx, name, args...).CombinedOutput() -} - -// New builds a tmux Runtime, filling unset Options with defaults: binary -// "tmux", shell from $SHELL (else /bin/sh), and the default timeout. -func New(opts Options) *Runtime { - binary := opts.Binary - if binary == "" { - binary = "tmux" - } - timeout := opts.Timeout - if timeout == 0 { - timeout = defaultTimeout - } - shellPath := opts.Shell - if shellPath == "" { - shellPath = os.Getenv("SHELL") - } - if shellPath == "" { - shellPath = "/bin/sh" - } - return &Runtime{binary: binary, timeout: timeout, shell: shellPath, runner: execRunner{}} -} - -// Create starts a new tmux session in the workspace, running the agent's -// launch command, and returns a handle to it. -func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { - id, err := tmuxSessionName(cfg.SessionID) - if err != nil { - return ports.RuntimeHandle{}, err - } - if cfg.WorkspacePath == "" { - return ports.RuntimeHandle{}, errors.New("tmux runtime: workspace path is required") - } - if cfg.LaunchCommand == "" { - return ports.RuntimeHandle{}, errors.New("tmux runtime: launch command is required") - } - - script := wrapLaunchCommand(cfg, r.shell) - if _, err := r.run(ctx, newSessionArgs(id, cfg.WorkspacePath, r.shell, script)...); err != nil { - return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: create session %s: %w", id, err) - } - if _, err := r.run(ctx, setStatusOffArgs(id)...); err != nil { - _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}) - return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: disable status %s: %w", id, err) - } - return ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}, nil -} - -// Destroy kills the handle's tmux session. An already-gone session is treated -// as success. -func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error { - id, err := handleID(handle) - if err != nil { - return err - } - if _, err := r.run(ctx, killSessionArgs(id)...); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return nil - } - return fmt.Errorf("tmux runtime: destroy session %s: %w", id, err) - } - return nil -} - -// SendMessage types a message into the session's pane and presses Enter, -// routing large messages through a tmux paste buffer. -func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error { - id, err := handleID(handle) - if err != nil { - return err - } - if useBuffer(message) { - return r.sendViaBuffer(ctx, id, message) - } - if _, err := r.run(ctx, sendLiteralArgs(id, message)...); err != nil { - return fmt.Errorf("tmux runtime: send message %s: %w", id, err) - } - if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil { - return fmt.Errorf("tmux runtime: send enter %s: %w", id, err) - } - return nil -} - -// GetOutput captures the last `lines` lines of the session pane. -func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) { - id, err := handleID(handle) - if err != nil { - return "", err - } - if lines <= 0 { - return "", errors.New("tmux runtime: lines must be positive") - } - out, err := r.run(ctx, capturePaneArgs(id, lines)...) - if err != nil { - return "", fmt.Errorf("tmux runtime: capture output %s: %w", id, err) - } - return string(out), nil -} - -// IsAlive reports whether the handle's tmux session still exists. -func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) { - id, err := handleID(handle) - if err != nil { - return false, err - } - _, err = r.run(ctx, hasSessionArgs(id)...) - if err == nil { - return true, nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return false, nil - } - return false, fmt.Errorf("tmux runtime: probe session %s: %w", id, err) -} - -// AttachCommand returns the argv a human runs to attach their terminal to the -// session. -func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { - id, err := handleID(handle) - if err != nil { - return nil, err - } - return append([]string{r.binary}, "attach", "-t", exactSessionTarget(id)), nil -} - -func (r *Runtime) sendViaBuffer(ctx context.Context, id, message string) error { - dir := os.TempDir() - file, err := os.CreateTemp(dir, "ao-tmux-message-*") - if err != nil { - return fmt.Errorf("tmux runtime: create message temp file: %w", err) - } - path := file.Name() - defer func() { _ = os.Remove(path) }() - if _, err := file.WriteString(message); err != nil { - _ = file.Close() - return fmt.Errorf("tmux runtime: write message temp file: %w", err) - } - if err := file.Close(); err != nil { - return fmt.Errorf("tmux runtime: close message temp file: %w", err) - } - - bufferName := "ao-" + filepath.Base(path) - if _, err := r.run(ctx, loadBufferArgs(bufferName, path)...); err != nil { - return fmt.Errorf("tmux runtime: load buffer %s: %w", id, err) - } - if _, err := r.run(ctx, pasteBufferArgs(id, bufferName)...); err != nil { - return fmt.Errorf("tmux runtime: paste buffer %s: %w", id, err) - } - if _, err := r.run(ctx, sendEnterArgs(id)...); err != nil { - return fmt.Errorf("tmux runtime: send enter %s: %w", id, err) - } - return nil -} - -func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { - cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) - defer cancel() - out, err := r.runner.Run(cmdCtx, r.binary, args...) - if cmdCtx.Err() != nil { - return out, cmdCtx.Err() - } - if err != nil { - return out, commandError{err: err, output: strings.TrimSpace(string(out))} - } - return out, nil -} - -func tmuxSessionName(id domain.SessionID) (string, error) { - raw := string(id) - if raw == "" { - return "", errors.New("tmux runtime: session id is required") - } - if sessionIDPattern.MatchString(raw) { - return raw, nil - } - return sanitizedSessionName(raw), nil -} - -func sanitizedSessionName(raw string) string { - var b strings.Builder - lastDash := false - for _, r := range raw { - valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' - if valid { - b.WriteRune(r) - lastDash = false - continue - } - if !lastDash { - b.WriteByte('-') - lastDash = true - } - } - base := strings.Trim(b.String(), "-") - if base == "" { - base = "session" - } - if len(base) > 40 { - base = strings.TrimRight(base[:40], "-") - } - sum := sha256.Sum256([]byte(raw)) - return base + "-" + hex.EncodeToString(sum[:4]) -} - -func validateSessionID(id string) error { - if id == "" { - return errors.New("tmux runtime: session id is required") - } - if !sessionIDPattern.MatchString(id) { - return fmt.Errorf("tmux runtime: invalid session id %q", id) - } - return nil -} - -func handleID(handle ports.RuntimeHandle) (string, error) { - if handle.RuntimeName != "" && handle.RuntimeName != runtimeName { - return "", fmt.Errorf("tmux runtime: wrong runtime %q", handle.RuntimeName) - } - if err := validateSessionID(handle.ID); err != nil { - return "", err - } - return handle.ID, nil -} - -func useBuffer(message string) bool { - return strings.Contains(message, "\n") || len(message) > longMessageThreshold -} - -type commandError struct { - err error - output string -} - -func (e commandError) Error() string { - if e.output == "" { - return e.err.Error() - } - return e.err.Error() + ": " + e.output -} - -func (e commandError) Unwrap() error { return e.err } diff --git a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go b/backend/internal/adapters/runtime/tmux/tmux_integration_test.go deleted file mode 100644 index 7e79867382..0000000000 --- a/backend/internal/adapters/runtime/tmux/tmux_integration_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package tmux - -import ( - "context" - "os/exec" - "strings" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -func TestRuntimeIntegration(t *testing.T) { - if _, err := exec.LookPath("tmux"); err != nil { - t.Skip("tmux unavailable") - } - - r := New(Options{Timeout: 5 * time.Second}) - ctx := context.Background() - id := "ao_itest_tmux" - _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}) - - h, err := r.Create(ctx, ports.RuntimeConfig{ - SessionID: "ao_itest_tmux", - WorkspacePath: t.TempDir(), - LaunchCommand: "printf ready\\n", - Env: map[string]string{"AO_SESSION_ID": id}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - defer r.Destroy(ctx, h) - - alive, err := r.IsAlive(ctx, h) - if err != nil { - t.Fatalf("IsAlive: %v", err) - } - if !alive { - t.Fatal("alive = false, want true") - } - - if err := r.SendMessage(ctx, h, "printf hello-from-tmux"); err != nil { - t.Fatalf("SendMessage: %v", err) - } - deadline := time.Now().Add(2 * time.Second) - var out string - for time.Now().Before(deadline) { - out, err = r.GetOutput(ctx, h, 20) - if err != nil { - t.Fatalf("GetOutput: %v", err) - } - if strings.Contains(out, "hello-from-tmux") { - break - } - time.Sleep(100 * time.Millisecond) - } - if !strings.Contains(out, "hello-from-tmux") { - t.Fatalf("output = %q, want sent command output", out) - } - - if err := r.Destroy(ctx, h); err != nil { - t.Fatalf("Destroy: %v", err) - } - alive, err = r.IsAlive(ctx, h) - if err != nil { - t.Fatalf("IsAlive after destroy: %v", err) - } - if alive { - t.Fatal("alive after destroy = true, want false") - } -} - -func TestRuntimeIntegrationUsesExactTargets(t *testing.T) { - if _, err := exec.LookPath("tmux"); err != nil { - t.Skip("tmux unavailable") - } - - r := New(Options{Timeout: 5 * time.Second}) - ctx := context.Background() - longID := "ao_exact_target_long" - prefixID := "ao_exact_target" - _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID, RuntimeName: runtimeName}) - _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}) - - h, err := r.Create(ctx, ports.RuntimeConfig{ - SessionID: "ao_exact_target_long", - WorkspacePath: t.TempDir(), - LaunchCommand: "cat", - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - defer r.Destroy(ctx, h) - - alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}) - if err != nil { - t.Fatalf("IsAlive prefix: %v", err) - } - if alive { - t.Fatal("prefix handle reported alive; tmux target matching is not exact") - } - if err := r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}); err != nil { - t.Fatalf("Destroy prefix: %v", err) - } - alive, err = r.IsAlive(ctx, h) - if err != nil { - t.Fatalf("IsAlive long after prefix destroy: %v", err) - } - if !alive { - t.Fatal("destroying prefix handle killed longer session") - } -} diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go deleted file mode 100644 index cb56db35ff..0000000000 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package tmux - -import ( - "context" - "errors" - "os/exec" - "reflect" - "strings" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -func TestNewDefaultsToPortableShell(t *testing.T) { - t.Setenv("SHELL", "") - r := New(Options{}) - if got, want := r.shell, "/bin/sh"; got != want { - t.Fatalf("default shell = %q, want %q", got, want) - } -} - -func TestCommandBuilders(t *testing.T) { - if got, want := newSessionArgs("sess-1", "/tmp/ws", "/bin/zsh", "echo hi"), []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws", "/bin/zsh", "-lc", "echo hi"}; !reflect.DeepEqual(got, want) { - t.Fatalf("newSessionArgs = %#v, want %#v", got, want) - } - if got, want := setStatusOffArgs("sess-1"), []string{"set-option", "-t", "=sess-1:", "status", "off"}; !reflect.DeepEqual(got, want) { - t.Fatalf("setStatusOffArgs = %#v, want %#v", got, want) - } - if got, want := capturePaneArgs("sess-1", 42), []string{"capture-pane", "-p", "-t", "=sess-1:0.0", "-S", "-42"}; !reflect.DeepEqual(got, want) { - t.Fatalf("capturePaneArgs = %#v, want %#v", got, want) - } -} - -func TestExactTargets(t *testing.T) { - if got, want := exactSessionTarget("abc"), "=abc:"; got != want { - t.Fatalf("exactSessionTarget = %q, want %q", got, want) - } - if got, want := exactPaneTarget("abc"), "=abc:0.0"; got != want { - t.Fatalf("exactPaneTarget = %q, want %q", got, want) - } -} - -func TestTmuxSessionNameSanitizesIssueRefs(t *testing.T) { - got, err := tmuxSessionName("repo/issue#42.1") - if err != nil { - t.Fatalf("tmuxSessionName: %v", err) - } - if err := validateSessionID(got); err != nil { - t.Fatalf("sanitized id %q is invalid: %v", got, err) - } - if !strings.HasPrefix(got, "repo-issue-42-1-") { - t.Fatalf("sanitized id = %q, want readable prefix", got) - } - if got == "repo/issue#42.1" { - t.Fatal("sanitized id still contains raw unsafe characters") - } -} - -func TestValidateSessionID(t *testing.T) { - valid := []string{"sess-1", "S_2", "abc123"} - for _, id := range valid { - if err := validateSessionID(id); err != nil { - t.Fatalf("validateSessionID(%q): %v", id, err) - } - } - invalid := []string{"", "sess.1", "sess/1", "$(boom)", "with space"} - for _, id := range invalid { - if err := validateSessionID(id); err == nil { - t.Fatalf("validateSessionID(%q): got nil, want error", id) - } - } -} - -func TestWrapLaunchCommandExportsEnvAndKeepsPaneAlive(t *testing.T) { - oldGetenv := getenv - getenv = func(key string) string { - if key == "PATH" { - return "/usr/bin:/bin" - } - return "" - } - defer func() { getenv = oldGetenv }() - - got := wrapLaunchCommand(ports.RuntimeConfig{LaunchCommand: "ao run", Env: map[string]string{ - "AO_SESSION_ID": "sess-1", - "ODD": "can't", - "PATH": "/custom/bin:/usr/bin", - }}, "/bin/zsh") - - for _, want := range []string{ - "export AO_SESSION_ID='sess-1';", - "export ODD='can'\\''t';", - "export PATH='/custom/bin:/usr/bin';", - "ao run; exec '/bin/zsh' -i", - } { - if !strings.Contains(got, want) { - t.Fatalf("wrapped command missing %q in %q", want, got) - } - } -} - -func TestCreateRunsNewSessionAndDisablesStatus(t *testing.T) { - fr := &fakeRunner{} - r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/zsh"}) - r.runner = fr - - handle, err := r.Create(context.Background(), ports.RuntimeConfig{ - SessionID: "sess-1", - WorkspacePath: "/tmp/ws", - LaunchCommand: "echo ready", - Env: map[string]string{"AO_SESSION_ID": "sess-1"}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - if handle != (ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}) { - t.Fatalf("handle = %+v, want tmux handle", handle) - } - if len(fr.calls) != 2 { - t.Fatalf("calls = %d, want 2", len(fr.calls)) - } - if got, want := fr.calls[0].args[:6], []string{"new-session", "-d", "-s", "sess-1", "-c", "/tmp/ws"}; !reflect.DeepEqual(got, want) { - t.Fatalf("create args prefix = %#v, want %#v", got, want) - } - if got, want := fr.calls[1].args, setStatusOffArgs("sess-1"); !reflect.DeepEqual(got, want) { - t.Fatalf("status args = %#v, want %#v", got, want) - } -} - -func TestCreateNormalizesUnsafeSessionID(t *testing.T) { - fr := &fakeRunner{} - r := New(Options{Binary: "tmux-test", Timeout: time.Second, Shell: "/bin/sh"}) - r.runner = fr - - handle, err := r.Create(context.Background(), ports.RuntimeConfig{ - SessionID: "repo/issue#42", - WorkspacePath: "/tmp/ws", - LaunchCommand: "echo ready", - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - if err := validateSessionID(handle.ID); err != nil { - t.Fatalf("handle id %q invalid: %v", handle.ID, err) - } - if handle.ID == "repo/issue#42" { - t.Fatal("handle kept unsafe raw session id") - } - if got := fr.calls[0].args[3]; got != handle.ID { - t.Fatalf("tmux session arg = %q, want handle id %q", got, handle.ID) - } -} - -func TestSendMessageUsesLiteralForShortInput(t *testing.T) { - fr := &fakeRunner{} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, "hello"); err != nil { - t.Fatalf("SendMessage: %v", err) - } - if got, want := fr.calls[0].args, sendLiteralArgs("sess-1", "hello"); !reflect.DeepEqual(got, want) { - t.Fatalf("literal args = %#v, want %#v", got, want) - } - if got, want := fr.calls[1].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) { - t.Fatalf("enter args = %#v, want %#v", got, want) - } -} - -func TestSendMessageUsesBufferForMultilineInput(t *testing.T) { - fr := &fakeRunner{} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, "hello\nworld"); err != nil { - t.Fatalf("SendMessage: %v", err) - } - if len(fr.calls) != 3 { - t.Fatalf("calls = %d, want 3", len(fr.calls)) - } - if fr.calls[0].args[0] != "load-buffer" { - t.Fatalf("first command = %#v, want load-buffer", fr.calls[0].args) - } - if got := fr.calls[1].args; !reflect.DeepEqual(got[:4], []string{"paste-buffer", "-d", "-t", "=sess-1:0.0"}) { - t.Fatalf("paste args = %#v", got) - } - if got, want := fr.calls[2].args, sendEnterArgs("sess-1"); !reflect.DeepEqual(got, want) { - t.Fatalf("enter args = %#v, want %#v", got, want) - } -} - -func TestIsAliveTreatsExitStatusAsNotAlive(t *testing.T) { - fr := &fakeRunner{err: &exec.ExitError{}} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}) - if err != nil { - t.Fatalf("IsAlive: %v", err) - } - if alive { - t.Fatal("alive = true, want false") - } -} - -func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { - fr := &fakeRunner{err: &exec.ExitError{}} - r := New(Options{Timeout: time.Second}) - r.runner = fr - - if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}); err != nil { - t.Fatalf("Destroy: %v", err) - } - if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" { - t.Fatalf("calls = %#v, want only kill-session", fr.calls) - } -} - -func TestGetOutputValidatesLines(t *testing.T) { - r := New(Options{Timeout: time.Second}) - _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1", RuntimeName: runtimeName}, 0) - if err == nil { - t.Fatal("GetOutput lines=0: got nil, want error") - } -} - -type fakeRunner struct { - calls []runnerCall - out []byte - err error -} - -type runnerCall struct { - name string - args []string -} - -func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { - f.calls = append(f.calls, runnerCall{name: name, args: append([]string(nil), args...)}) - if f.err != nil { - return f.out, f.err - } - return f.out, nil -} - -func TestCommandErrorUnwraps(t *testing.T) { - base := errors.New("base") - err := commandError{err: base, output: "details"} - if !errors.Is(err, base) { - t.Fatal("commandError should unwrap base error") - } - if !strings.Contains(err.Error(), "details") { - t.Fatalf("error = %q, want output details", err.Error()) - } -} diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go index d4ca710451..1ecb9c31c2 100644 --- a/backend/internal/adapters/runtime/zellij/commands.go +++ b/backend/internal/adapters/runtime/zellij/commands.go @@ -10,7 +10,6 @@ import ( ) const ( - runtimeName = "zellij" agentPaneName = "agent" defaultChunkBytes = 16 * 1024 ) @@ -188,6 +187,31 @@ func wrapLaunchCommandCmd(cfg ports.RuntimeConfig) string { return b.String() } +func validateEnvKeys(env map[string]string) error { + for key := range env { + if !validEnvKey(key) { + return fmt.Errorf("zellij runtime: invalid env key %q", key) + } + } + return nil +} + +func validEnvKey(key string) bool { + if key == "" { + return false + } + for i, r := range key { + if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { + continue + } + if i > 0 && r >= '0' && r <= '9' { + continue + } + return false + } + return true +} + func sortedKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go index aade6490db..71acb635cb 100644 --- a/backend/internal/adapters/runtime/zellij/zellij.go +++ b/backend/internal/adapters/runtime/zellij/zellij.go @@ -115,6 +115,9 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru if cfg.LaunchCommand == "" { return ports.RuntimeHandle{}, errors.New("zellij runtime: launch command is required") } + if err := validateEnvKeys(cfg.Env); err != nil { + return ports.RuntimeHandle{}, err + } if err := r.ensureSupportedVersion(ctx); err != nil { return ports.RuntimeHandle{}, err } @@ -130,10 +133,10 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru } paneID, err := r.findAgentPane(ctx, id) if err != nil { - _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}) + _ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id}) return ports.RuntimeHandle{}, err } - return ports.RuntimeHandle{ID: handleIDValue(id, paneID), RuntimeName: runtimeName}, nil + return ports.RuntimeHandle{ID: handleIDValue(id, paneID)}, nil } // Destroy kills the handle's zellij session. An already-gone session is treated @@ -225,13 +228,9 @@ func (r *Runtime) ensureSupportedVersion(ctx context.Context) error { if err != nil { return fmt.Errorf("zellij runtime: check version: %w", err) } - version, err := parseVersion(string(out)) - if err != nil { + if _, err := CheckVersionOutput(string(out)); err != nil { return fmt.Errorf("zellij runtime: check version: %w", err) } - if compareVersion(version, semver{minMajor, minMinor, minPatch}) < 0 { - return fmt.Errorf("zellij runtime: unsupported zellij version %s; require >= %d.%d.%d", version, minMajor, minMinor, minPatch) - } return nil } @@ -384,9 +383,6 @@ func validatePaneID(id string) error { } func handleID(handle ports.RuntimeHandle) (string, string, error) { - if handle.RuntimeName != "" && handle.RuntimeName != runtimeName { - return "", "", fmt.Errorf("zellij runtime: wrong runtime %q", handle.RuntimeName) - } parts := strings.Split(handle.ID, "/") if len(parts) == 1 { if err := validateSessionID(parts[0]); err != nil { @@ -471,6 +467,25 @@ func tailLines(s string, n int) string { return strings.Join(lines[len(lines)-n:], "") } +// RequiredVersion returns the minimum Zellij version AO's runtime adapter +// supports. +func RequiredVersion() string { return minSupportedVersion().String() } + +// CheckVersionOutput parses `zellij --version` output, returning the parsed +// version when it satisfies AO's minimum runtime requirement. +func CheckVersionOutput(out string) (string, error) { + version, err := parseVersion(out) + if err != nil { + return "", err + } + if compareVersion(version, minSupportedVersion()) < 0 { + return version.String(), fmt.Errorf("unsupported zellij version %s; require >= %s", version, RequiredVersion()) + } + return version.String(), nil +} + +func minSupportedVersion() semver { return semver{minMajor, minMinor, minPatch} } + type semver struct { major int minor int diff --git a/backend/internal/adapters/runtime/zellij/zellij_integration_test.go b/backend/internal/adapters/runtime/zellij/zellij_integration_test.go index 6729cc3b52..fcc57eaac0 100644 --- a/backend/internal/adapters/runtime/zellij/zellij_integration_test.go +++ b/backend/internal/adapters/runtime/zellij/zellij_integration_test.go @@ -25,7 +25,7 @@ func TestRuntimeIntegration(t *testing.T) { } configDir := t.TempDir() r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: configDir}) - _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id, RuntimeName: runtimeName}) + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: id}) h, err := r.Create(ctx, ports.RuntimeConfig{ SessionID: "ao_itest_zj", @@ -90,8 +90,8 @@ func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) { r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: t.TempDir()}) longID := "ao_zj_exact_long" prefixID := "ao_zj_exact" - _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID, RuntimeName: runtimeName}) - _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}) + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID}) + _ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID}) h, err := r.Create(ctx, ports.RuntimeConfig{ SessionID: "ao_zj_exact_long", @@ -103,7 +103,7 @@ func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) { } defer r.Destroy(ctx, h) - alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName}) + alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID}) if err != nil { t.Fatalf("IsAlive prefix: %v", err) } diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go index a690af0385..3f0dc1437d 100644 --- a/backend/internal/adapters/runtime/zellij/zellij_test.go +++ b/backend/internal/adapters/runtime/zellij/zellij_test.go @@ -83,17 +83,13 @@ func TestValidateSessionAndPaneID(t *testing.T) { } func TestHandleID(t *testing.T) { - session, pane, err := handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7", RuntimeName: runtimeName}) + session, pane, err := handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7"}) if err != nil { t.Fatalf("handleID: %v", err) } if session != "sess-1" || pane != "terminal_7" { t.Fatalf("handleID = %q/%q", session, pane) } - _, _, err = handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7", RuntimeName: "tmux"}) - if err == nil { - t.Fatal("wrong runtime: got nil, want error") - } } func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) { @@ -176,6 +172,20 @@ func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) { } } +func TestCreateRejectsInvalidEnvKeys(t *testing.T) { + r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh"}) + r.runner = &fakeRunner{} + _, err := r.Create(context.Background(), ports.RuntimeConfig{ + SessionID: "sess-1", + WorkspacePath: "/tmp/ws", + LaunchCommand: "echo ready", + Env: map[string]string{"BAD KEY": "x"}, + }) + if err == nil || !strings.Contains(err.Error(), "invalid env key") { + t.Fatalf("Create err = %v, want invalid env key", err) + } +} + func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { fr := &fakeRunner{outputs: [][]byte{[]byte("zellij 0.44.3"), nil, []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`)}} r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"}) @@ -190,7 +200,7 @@ func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { if err != nil { t.Fatalf("Create: %v", err) } - if handle != (ports.RuntimeHandle{ID: "sess-1/terminal_3", RuntimeName: runtimeName}) { + if handle != (ports.RuntimeHandle{ID: "sess-1/terminal_3"}) { t.Fatalf("handle = %+v, want zellij handle", handle) } if len(fr.calls) != 3 { @@ -212,7 +222,7 @@ func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { func TestAttachCommandUsesSocketDir(t *testing.T) { r := New(Options{SocketDir: "/tmp/zj"}) - args, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}) + args, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) if err != nil { t.Fatalf("AttachCommand: %v", err) } @@ -270,6 +280,15 @@ func TestParseVersion(t *testing.T) { if compareVersion(semver{0, 44, 2}, semver{0, 44, 3}) >= 0 { t.Fatal("compareVersion should order 0.44.2 before 0.44.3") } + if got := RequiredVersion(); got != "0.44.3" { + t.Fatalf("RequiredVersion = %q, want 0.44.3", got) + } + if got, err := CheckVersionOutput("zellij 0.44.3"); err != nil || got != "0.44.3" { + t.Fatalf("CheckVersionOutput supported = %q, %v", got, err) + } + if _, err := CheckVersionOutput("zellij 0.44.2"); err == nil { + t.Fatal("CheckVersionOutput unsupported: got nil error") + } } func TestSendMessageChunksAndSendsEnter(t *testing.T) { @@ -277,7 +296,7 @@ func TestSendMessageChunksAndSendsEnter(t *testing.T) { r := New(Options{Timeout: time.Second, ChunkSize: 5}) r.runner = fr - if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, "hello世界"); err != nil { + if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, "hello世界"); err != nil { t.Fatalf("SendMessage: %v", err) } if len(fr.calls) != 4 { @@ -302,7 +321,7 @@ func TestGetOutputTrimsLines(t *testing.T) { r := New(Options{Timeout: time.Second}) r.runner = fr - out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, 2) + out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 2) if err != nil { t.Fatalf("GetOutput: %v", err) } @@ -316,7 +335,7 @@ func TestIsAliveParsesNoFormattingOutput(t *testing.T) { r := New(Options{Timeout: time.Second}) r.runner = fr - alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}) + alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}) if err != nil { t.Fatalf("IsAlive: %v", err) } @@ -336,7 +355,7 @@ func TestIsAliveTreatsExitStatusAsNotAlive(t *testing.T) { r := New(Options{Timeout: time.Second}) r.runner = fr - alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}) + alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}) if err != nil { t.Fatalf("IsAlive: %v", err) } @@ -350,7 +369,7 @@ func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { r := New(Options{Timeout: time.Second}) r.runner = fr - if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}); err != nil { + if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err != nil { t.Fatalf("Destroy: %v", err) } if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" { @@ -360,7 +379,7 @@ func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) { func TestGetOutputValidatesLines(t *testing.T) { r := New(Options{Timeout: time.Second}) - _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, 0) + _, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}, 0) if err == nil { t.Fatal("GetOutput lines=0: got nil, want error") } diff --git a/backend/internal/adapters/tracker/github/doc.go b/backend/internal/adapters/tracker/github/doc.go index f37c4c90f5..53acf22987 100644 --- a/backend/internal/adapters/tracker/github/doc.go +++ b/backend/internal/adapters/tracker/github/doc.go @@ -36,7 +36,7 @@ // - No List pagination beyond a single page (callers requesting more than // 100 results need to wait for the observer/polling work in issue #35). // - No webhook receiver, no polling goroutine, no fact projection into -// LCM (issue #35). +// the PR service (issue #35). // - No richer per-provider metadata on Issue (milestones, project boards, // reactions); the port only carries fields all v1 providers can fill. package github diff --git a/backend/internal/adapters/tracker/github/tracker.go b/backend/internal/adapters/tracker/github/tracker.go index a184fb1409..1d5d6c5dfd 100644 --- a/backend/internal/adapters/tracker/github/tracker.go +++ b/backend/internal/adapters/tracker/github/tracker.go @@ -230,10 +230,10 @@ func mapStateFromGitHub(state, reason string, labels []string) domain.Normalized } var hasProgress, hasReview bool for _, l := range labels { - switch l { - case labelInProgress: + switch { + case strings.EqualFold(l, labelInProgress): hasProgress = true - case labelInReview: + case strings.EqualFold(l, labelInReview): hasReview = true } } @@ -376,7 +376,10 @@ func (t *Tracker) do(ctx context.Context, method, path string, body any) ([]byte return nil, fmt.Errorf("github tracker: %s %s: %w", method, path, err) } defer func() { _ = resp.Body.Close() }() - respBody, _ := io.ReadAll(resp.Body) + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, fmt.Errorf("github tracker: read response body: %w", readErr) + } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return respBody, nil } @@ -473,14 +476,9 @@ func parseGitHubID(native string) (owner, repo string, number int, err error) { } repoPart := native[:hash] numPart := native[hash+1:] - slash := strings.IndexByte(repoPart, '/') - if slash < 0 { - return "", "", 0, fmt.Errorf("%w: missing owner/repo separator", ErrBadID) - } - owner = repoPart[:slash] - repo = repoPart[slash+1:] - if owner == "" || repo == "" { - return "", "", 0, fmt.Errorf("%w: empty owner or repo", ErrBadID) + owner, repo, err = parseGitHubRepo(repoPart) + if err != nil { + return "", "", 0, err } n, parseErr := strconv.Atoi(numPart) if parseErr != nil || n <= 0 { diff --git a/backend/internal/adapters/tracker/github/tracker_test.go b/backend/internal/adapters/tracker/github/tracker_test.go index a61a68999d..57585b743a 100644 --- a/backend/internal/adapters/tracker/github/tracker_test.go +++ b/backend/internal/adapters/tracker/github/tracker_test.go @@ -115,6 +115,8 @@ func TestParseID(t *testing.T) { {"missing slash", "octocat#42", "", "", 0, true}, {"empty owner", "/repo#1", "", "", 0, true}, {"empty repo", "owner/#1", "", "", 0, true}, + {"embedded slash", "o/r/x#1", "", "", 0, true}, + {"space", "o/r space#1", "", "", 0, true}, {"non-numeric", "o/r#abc", "", "", 0, true}, {"zero", "o/r#0", "", "", 0, true}, {"negative", "o/r#-1", "", "", 0, true}, @@ -184,7 +186,7 @@ func TestGet_StateMappingFromGitHubFields(t *testing.T) { wantState domain.NormalizedIssueState }{ {"plain open", "open", "", nil, domain.IssueOpen}, - {"open with in-progress label", "open", "", []string{"in-progress"}, domain.IssueInProgress}, + {"open with in-progress label", "open", "", []string{"In-Progress"}, domain.IssueInProgress}, {"open with in-review label", "open", "", []string{"in-review"}, domain.IssueInReview}, {"review wins over progress when both present", "open", "", []string{"in-progress", "in-review"}, domain.IssueInReview}, {"closed completed", "closed", "completed", nil, domain.IssueDone}, @@ -288,7 +290,7 @@ func TestGet_SecondaryRateLimit(t *testing.T) { func TestGet_RejectsWrongProvider(t *testing.T) { f := newFakeGH(t) tr := newTrackerForTest(t, f) - _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitLab, Native: "g/p#1"}) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProvider("gitlab"), Native: "g/p#1"}) if !errors.Is(err, ErrWrongProvider) { t.Fatalf("err = %v, want ErrWrongProvider", err) } @@ -518,7 +520,7 @@ func TestList_QueryEncoding(t *testing.T) { func TestList_RejectsWrongProvider(t *testing.T) { f := newFakeGH(t) tr := newTrackerForTest(t, f) - _, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderGitLab, Native: "g/p"}, domain.ListFilter{}) + _, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProvider("gitlab"), Native: "g/p"}, domain.ListFilter{}) if !errors.Is(err, ErrWrongProvider) { t.Fatalf("err = %v, want ErrWrongProvider", err) } diff --git a/backend/internal/adapters/workspace/gitworktree/commands.go b/backend/internal/adapters/workspace/gitworktree/commands.go index 5a417dd7f8..356a50e11d 100644 --- a/backend/internal/adapters/workspace/gitworktree/commands.go +++ b/backend/internal/adapters/workspace/gitworktree/commands.go @@ -1,5 +1,7 @@ package gitworktree +import "strings" + func checkRefFormatBranchArgs(repo, branch string) []string { return []string{"-C", repo, "check-ref-format", "--branch", branch} } @@ -34,12 +36,11 @@ func worktreeListPorcelainArgs(repo string) []string { } func baseRefCandidates(branch, defaultBranch string) []string { - return []string{"origin/" + branch, "origin/" + defaultBranch, branch} -} - -func chooseWorktreeAddArgs(repo, path, branch, baseRef string, localBranchExists bool) []string { - if localBranchExists { - return worktreeAddBranchArgs(repo, path, branch) + candidates := []string{"origin/" + branch} + if strings.Contains(defaultBranch, "/") { + candidates = append(candidates, defaultBranch) + } else { + candidates = append(candidates, "origin/"+defaultBranch) } - return worktreeAddNewBranchArgs(repo, branch, path, baseRef) + return append(candidates, branch) } diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index 9c4cc99383..a2a9cd97e9 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -143,7 +143,7 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error if err != nil { return err } - if worktreeRegistered(records, path) { + if _, ok := findWorktree(records, path); ok { if removeErr != nil { return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune (worktree remove: %w)", path, removeErr) } @@ -155,26 +155,6 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error return nil } -// List returns the managed worktrees that belong to a project. -func (w *Workspace) List(ctx context.Context, project domain.ProjectID) ([]ports.WorkspaceInfo, error) { - if project == "" { - return nil, errors.New("gitworktree: project id is required") - } - repo, err := w.repoPath(project) - if err != nil { - return nil, err - } - records, err := w.listRecords(ctx, repo) - if err != nil { - return nil, err - } - projectRoot, err := w.projectRoot(project) - if err != nil { - return nil, err - } - return filterProjectWorktrees(records, projectRoot, project), nil -} - // Restore re-attaches to an existing worktree for the session if one is still // present, recreating the handle without disturbing its contents. func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { @@ -220,7 +200,7 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string) return err } if localBranch { - if _, err := w.run(ctx, w.binary, chooseWorktreeAddArgs(repo, path, branch, "", true)...); err != nil { + if _, err := w.run(ctx, w.binary, worktreeAddBranchArgs(repo, path, branch)...); err != nil { return fmt.Errorf("gitworktree: worktree add existing branch %q: %w", branch, err) } return nil @@ -229,7 +209,7 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string) if err != nil { return err } - if _, err := w.run(ctx, w.binary, chooseWorktreeAddArgs(repo, path, branch, baseRef, false)...); err != nil { + if _, err := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo, branch, path, baseRef)...); err != nil { return fmt.Errorf("gitworktree: worktree add branch %q from %q: %w", branch, baseRef, err) } return nil @@ -358,11 +338,6 @@ func (w *Workspace) managedPath(project domain.ProjectID, session domain.Session return w.validateManagedPath(path) } -func (w *Workspace) projectRoot(project domain.ProjectID) (string, error) { - path := filepath.Join(w.managedRoot, string(project)) - return w.validateManagedPath(path) -} - func (w *Workspace) validateManagedPath(path string) (string, error) { if path == "" { return "", fmt.Errorf("%w: empty path", ErrUnsafePath) @@ -397,29 +372,6 @@ func pathWithin(root, path string) (bool, error) { return rel == "." || (rel != "" && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))), nil } -func filterProjectWorktrees(records []worktreeRecord, projectRoot string, project domain.ProjectID) []ports.WorkspaceInfo { - out := make([]ports.WorkspaceInfo, 0, len(records)) - for _, rec := range records { - path := filepath.Clean(rec.Path) - inside, err := pathWithin(projectRoot, path) - if err != nil || !inside || path == projectRoot { - continue - } - out = append(out, ports.WorkspaceInfo{ - Path: path, - Branch: rec.Branch, - SessionID: domain.SessionID(filepath.Base(path)), - ProjectID: project, - }) - } - return out -} - -func worktreeRegistered(records []worktreeRecord, path string) bool { - _, ok := findWorktree(records, path) - return ok -} - func findWorktree(records []worktreeRecord, path string) (worktreeRecord, bool) { clean := filepath.Clean(path) for _, rec := range records { diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go index 2b435c8558..6dc50f767a 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go @@ -12,7 +12,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -func TestWorkspaceIntegrationCreateListRestoreDestroy(t *testing.T) { +func TestWorkspaceIntegrationCreateRestoreDestroy(t *testing.T) { git := requireGit(t) tmp := t.TempDir() repo := setupOriginClone(t, git, tmp) @@ -35,14 +35,6 @@ func TestWorkspaceIntegrationCreateListRestoreDestroy(t *testing.T) { t.Fatalf("created worktree missing seed file: %v", err) } - listed, err := ws.List(ctx, "proj") - if err != nil { - t.Fatalf("list: %v", err) - } - if len(listed) != 1 || listed[0].Path != info.Path || listed[0].Branch != cfg.Branch || listed[0].SessionID != cfg.SessionID { - t.Fatalf("listed = %#v", listed) - } - restored, err := ws.Restore(ctx, cfg) if err != nil { t.Fatalf("restore registered: %v", err) diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_test.go index afa7872f41..fa14f52776 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_test.go @@ -9,7 +9,6 @@ import ( "strings" "testing" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -25,8 +24,8 @@ func TestCommandArgs(t *testing.T) { }{ {"check ref", checkRefFormatBranchArgs(repo, branch), []string{"-C", repo, "check-ref-format", "--branch", branch}}, {"rev parse", revParseVerifyArgs(repo, "origin/main"), []string{"-C", repo, "rev-parse", "--verify", "--quiet", "origin/main"}}, - {"add existing", chooseWorktreeAddArgs(repo, path, branch, "", true), []string{"-C", repo, "worktree", "add", path, branch}}, - {"add new", chooseWorktreeAddArgs(repo, path, branch, "origin/main", false), []string{"-C", repo, "worktree", "add", "-b", branch, path, "origin/main"}}, + {"add existing", worktreeAddBranchArgs(repo, path, branch), []string{"-C", repo, "worktree", "add", path, branch}}, + {"add new", worktreeAddNewBranchArgs(repo, branch, path, "origin/main"), []string{"-C", repo, "worktree", "add", "-b", branch, path, "origin/main"}}, // No --force: a dirty worktree must cause `git worktree remove` to fail so // the post-prune safety check surfaces the refusal instead of deleting // uncommitted agent work (review item RA). @@ -49,6 +48,12 @@ func TestBaseRefCandidates(t *testing.T) { if !reflect.DeepEqual(got, want) { t.Fatalf("candidates = %#v, want %#v", got, want) } + + got = baseRefCandidates("feature/test", "upstream/main") + want = []string{"origin/feature/test", "upstream/main", "feature/test"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("qualified candidates = %#v, want %#v", got, want) + } } func TestParseWorktreePorcelain(t *testing.T) { @@ -88,26 +93,6 @@ func TestParseWorktreePorcelain(t *testing.T) { } } -func TestFilterProjectWorktrees(t *testing.T) { - root := filepath.Clean("/managed/proj") - recs := []worktreeRecord{ - {Path: "/repo", Branch: "main"}, - {Path: "/managed/proj/s1", Branch: "feature/one"}, - {Path: "/managed/proj/s2", Branch: ""}, - {Path: "/managed/other/s3", Branch: "feature/three"}, - } - got := filterProjectWorktrees(recs, root, domain.ProjectID("proj")) - if len(got) != 2 { - t.Fatalf("len = %d, want 2: %#v", len(got), got) - } - if got[0].SessionID != "s1" || got[0].Branch != "feature/one" || got[0].ProjectID != "proj" { - t.Fatalf("first = %#v", got[0]) - } - if got[1].SessionID != "s2" || got[1].Branch != "" { - t.Fatalf("second = %#v", got[1]) - } -} - func TestManagedPathSafety(t *testing.T) { root := t.TempDir() ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": root}}) diff --git a/backend/internal/cdc/broadcast.go b/backend/internal/cdc/broadcast.go index b914f766e2..1393755900 100644 --- a/backend/internal/cdc/broadcast.go +++ b/backend/internal/cdc/broadcast.go @@ -5,11 +5,11 @@ import ( "sync" ) -// Broadcaster is the in-process fan-out the poller feeds. Subscribers (the -// WS/SSE transport, wired in the frontend task) register a callback; every -// polled Event is delivered to all current subscribers. It is the single seam -// between the CDC poller and live delivery, so the transport can be built and -// swapped without touching the poller. +// Broadcaster is the in-process fan-out the poller feeds. Subscribers such as +// terminal session-state fan-out register a callback; every polled Event is +// delivered to all current subscribers. It is the single seam between the CDC +// poller and live delivery, so transports can be built and swapped without +// touching the poller. type Broadcaster struct { mu sync.RWMutex nextID int diff --git a/backend/internal/cdc/cdc_test.go b/backend/internal/cdc/cdc_test.go index 52a0c57400..14ad640ceb 100644 --- a/backend/internal/cdc/cdc_test.go +++ b/backend/internal/cdc/cdc_test.go @@ -9,33 +9,10 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/cdc" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) -// storeSource adapts sqlite.Store to cdc.Source — the same glue the daemon wires. -type storeSource struct{ s *sqlite.Store } - -func (a storeSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) { - rows, err := a.s.ReadChangeLogAfter(ctx, after, limit) - if err != nil { - return nil, err - } - out := make([]cdc.Event, len(rows)) - for i, r := range rows { - out[i] = cdc.Event{ - Seq: r.Seq, - ProjectID: r.ProjectID, - SessionID: r.SessionID, - Type: cdc.EventType(r.EventType), - Payload: json.RawMessage(r.Payload), - CreatedAt: r.CreatedAt, - } - } - return out, nil -} - -func (a storeSource) LatestSeq(ctx context.Context) (int64, error) { return a.s.MaxChangeLogSeq(ctx) } - func newStore(t *testing.T) *sqlite.Store { t.Helper() s, err := sqlite.Open(t.TempDir()) @@ -50,15 +27,12 @@ func seedSession(t *testing.T, s *sqlite.Store) domain.SessionRecord { t.Helper() ctx := context.Background() now := time.Now().UTC().Truncate(time.Second) - if err := s.UpsertProject(ctx, sqlite.ProjectRow{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil { + if err := s.Upsert(ctx, project.Row{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil { t.Fatal(err) } r, err := s.CreateSession(ctx, domain.SessionRecord{ ProjectID: "mer", Kind: domain.KindWorker, - Lifecycle: domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: domain.SessionWorking}, - Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, - }, + Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, CreatedAt: now, UpdatedAt: now, }) if err != nil { @@ -74,18 +48,18 @@ func TestE2E_StoreWriteToBroadcast(t *testing.T) { s := newStore(t) r := seedSession(t, s) // -> session_created (seq 1) - r.Lifecycle.Session.State = domain.SessionIdle + r.Activity.State = domain.ActivityIdle if err := s.UpdateSession(ctx, r); err != nil { // -> session_updated (seq 2) t.Fatal(err) } - if err := s.UpsertPR(ctx, domain.PRRow{URL: "pr1", SessionID: string(r.ID), UpdatedAt: r.UpdatedAt}); err != nil { // -> pr_created (seq 3) + if err := s.WritePR(ctx, domain.PullRequest{URL: "pr1", SessionID: r.ID, UpdatedAt: r.UpdatedAt}, nil, nil); err != nil { // -> pr_created (seq 3) t.Fatal(err) } var got []cdc.Event bc := cdc.NewBroadcaster() bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) - p := cdc.NewPoller(storeSource{s}, bc, cdc.PollerConfig{}) // StartSeq 0: read from the top + p := cdc.NewPoller(s, bc, cdc.PollerConfig{}) // StartSeq 0: read from the top if err := p.Poll(ctx); err != nil { t.Fatal(err) } @@ -109,7 +83,7 @@ func TestE2E_StoreWriteToBroadcast(t *testing.T) { if err := json.Unmarshal(got[0].Payload, &payload); err != nil { t.Fatalf("payload not JSON: %v", err) } - if payload["id"] != string(r.ID) || payload["state"] != "working" { + if payload["id"] != string(r.ID) || payload["activity"] != "active" { t.Fatalf("payload = %v", payload) } @@ -135,17 +109,21 @@ func TestE2E_ConcurrentPollerLiveDelivery(t *testing.T) { bc := cdc.NewBroadcaster() bc.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) - p := cdc.NewPoller(storeSource{s}, bc, cdc.PollerConfig{}) // from the top + p := cdc.NewPoller(s, bc, cdc.PollerConfig{}) // from the top done := p.Start(ctx) const n = 6 for i := 0; i < n; i++ { - r.Lifecycle.IsAlive = i%2 == 0 // toggles is_alive -> sessions_cdc_update fires + if i%2 == 0 { + r.Activity.State = domain.ActivityActive + } else { + r.Activity.State = domain.ActivityIdle + } if err := s.UpdateSession(ctx, r); err != nil { t.Fatal(err) } } - want := 1 + n // session_created + n updates + want := n // session_created + n-1 activity updates; first write is unchanged deadline := time.Now().Add(5 * time.Second) for { diff --git a/backend/internal/cdc/event.go b/backend/internal/cdc/event.go index 16caaf741c..571ede2d30 100644 --- a/backend/internal/cdc/event.go +++ b/backend/internal/cdc/event.go @@ -1,7 +1,8 @@ // Package cdc is the change-data-capture delivery layer. Change events are // captured durably by SQLite triggers into the change_log table (see the storage // migrations); this package POLLS that log and fans new events out, in order, to -// in-process subscribers (the WS/SSE transport, wired in the frontend task). +// in-process subscribers such as terminal session-state fan-out. Future SSE/event +// endpoints can subscribe here too. // // There is no durable outbox/JSONL/janitor machinery: the change_log table IS // the durable, ordered source of truth, and clients catch up by reading it from @@ -19,13 +20,11 @@ type EventType string // Event types, one per row-change the DB triggers emit into change_log. const ( - EventSessionCreated EventType = "session_created" - EventSessionUpdated EventType = "session_updated" - EventPRCreated EventType = "pr_created" - EventPRUpdated EventType = "pr_updated" - EventPRCheckRecorded EventType = "pr_check_recorded" - EventNotificationCreated EventType = "notification_created" - EventNotificationUpdated EventType = "notification_updated" + EventSessionCreated EventType = "session_created" + EventSessionUpdated EventType = "session_updated" + EventPRCreated EventType = "pr_created" + EventPRUpdated EventType = "pr_updated" + EventPRCheckRecorded EventType = "pr_check_recorded" ) // Event is one CDC change read from change_log. Seq is the monotonic ordering + diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index 59ad221c25..02cd671c60 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/config" ) @@ -38,6 +39,7 @@ func newDoctorCommand(ctx *commandContext) *cobra.Command { cmd := &cobra.Command{ Use: "doctor", Short: "Run local AO health checks", + Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { checks := ctx.runDoctor(cmd.Context()) failures := 0 @@ -97,12 +99,12 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { } else { level := doctorPass switch st.State { - case "stale", "not_ready": + case stateStale, stateNotReady: level = doctorWarn - case "unhealthy": + case stateUnhealthy: level = doctorFail } - msg := st.State + msg := string(st.State) if st.PID != 0 { msg = fmt.Sprintf("%s pid=%d port=%d", msg, st.PID, st.Port) } @@ -114,8 +116,7 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { checks = append(checks, c.checkTool("git", true), - c.checkTool("tmux", false), - c.checkTool("zellij", false), + c.checkZellij(ctx), ) return checks } @@ -145,6 +146,24 @@ func checkStore(dataDir string) doctorCheck { } } +func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { + path, err := c.deps.LookPath("zellij") + if err != nil { + return doctorCheck{Level: doctorWarn, Name: "zellij", Message: "not found in PATH"} + } + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + out, err := c.deps.CommandOutput(reqCtx, path, "--version") + if err != nil { + return doctorCheck{Level: doctorFail, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} + } + version, err := zellij.CheckVersionOutput(string(out)) + if err != nil { + return doctorCheck{Level: doctorFail, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} + } + return doctorCheck{Level: doctorPass, Name: "zellij", Message: fmt.Sprintf("%s (%s)", path, version)} +} + func (c *commandContext) checkTool(name string, required bool) doctorCheck { path, err := c.deps.LookPath(name) if err == nil { diff --git a/backend/internal/cli/doctor_test.go b/backend/internal/cli/doctor_test.go new file mode 100644 index 0000000000..14dfcb2c5d --- /dev/null +++ b/backend/internal/cli/doctor_test.go @@ -0,0 +1,71 @@ +package cli + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestDoctorChecksZellijVersion(t *testing.T) { + setConfigEnv(t) + cmdPath := map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"} + c := &commandContext{deps: Deps{ + LookPath: func(name string) (string, error) { return cmdPath[name], nil }, + CommandOutput: func(_ context.Context, name string, args ...string) ([]byte, error) { + if name != "/bin/zellij" || len(args) != 1 || args[0] != "--version" { + t.Fatalf("unexpected command: %s %v", name, args) + } + return []byte("zellij 0.44.3\n"), nil + }, + }.withDefaults()} + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") + if check.Level != doctorPass || !strings.Contains(check.Message, "0.44.3") { + t.Fatalf("zellij check = %+v, want PASS with version", check) + } +} + +func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) { + setConfigEnv(t) + cmdPath := map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"} + c := &commandContext{deps: Deps{ + LookPath: func(name string) (string, error) { return cmdPath[name], nil }, + CommandOutput: func(context.Context, string, ...string) ([]byte, error) { + return []byte("zellij 0.44.2\n"), nil + }, + }.withDefaults()} + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") + if check.Level != doctorFail || !strings.Contains(check.Message, "require >= 0.44.3") { + t.Fatalf("zellij check = %+v, want FAIL with minimum version", check) + } +} + +func TestDoctorWarnsWhenZellijMissing(t *testing.T) { + setConfigEnv(t) + c := &commandContext{deps: Deps{ + LookPath: func(name string) (string, error) { + if name == "git" { + return "/bin/git", nil + } + return "", errors.New("missing") + }, + }.withDefaults()} + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") + if check.Level != doctorWarn { + t.Fatalf("zellij check = %+v, want WARN", check) + } +} + +func findDoctorCheck(t *testing.T, checks []doctorCheck, name string) doctorCheck { + t.Helper() + for _, check := range checks { + if check.Name == name { + return check + } + } + t.Fatalf("doctor check %q not found in %+v", name, checks) + return doctorCheck{} +} diff --git a/backend/internal/cli/process.go b/backend/internal/cli/process.go index 19c4d19fd8..c81a0361bf 100644 --- a/backend/internal/cli/process.go +++ b/backend/internal/cli/process.go @@ -13,11 +13,7 @@ type processStartConfig struct { Stderr *os.File } -type processHandle struct { - PID int -} - -func startProcess(cfg processStartConfig) (processHandle, error) { +func startProcess(cfg processStartConfig) error { cmd := exec.Command(cfg.Path, cfg.Args...) cmd.Env = cfg.Env cmd.Stdout = cfg.Stdout @@ -27,8 +23,8 @@ func startProcess(cfg processStartConfig) (processHandle, error) { // freshly spawned daemon (it would otherwise share the launcher's group). cmd.SysProcAttr = detachSysProcAttr() if err := cmd.Start(); err != nil { - return processHandle{}, err + return err } go func() { _ = cmd.Wait() }() - return processHandle{PID: cmd.Process.Pid}, nil + return nil } diff --git a/backend/internal/cli/process_unix.go b/backend/internal/cli/process_unix.go index 9963d9e9d4..edb610a46f 100644 --- a/backend/internal/cli/process_unix.go +++ b/backend/internal/cli/process_unix.go @@ -2,18 +2,7 @@ package cli -import ( - "errors" - "syscall" -) - -func processAlive(pid int) bool { - if pid <= 0 { - return false - } - err := syscall.Kill(pid, 0) - return err == nil || errors.Is(err, syscall.EPERM) -} +import "syscall" // detachSysProcAttr puts the daemon in a new session (Setsid) so it is no // longer in the launcher's foreground process group and won't receive the diff --git a/backend/internal/cli/process_windows.go b/backend/internal/cli/process_windows.go index 3ff8190a3a..03cc81a10b 100644 --- a/backend/internal/cli/process_windows.go +++ b/backend/internal/cli/process_windows.go @@ -3,32 +3,11 @@ package cli import ( - "errors" "syscall" "golang.org/x/sys/windows" ) -func processAlive(pid int) bool { - if pid <= 0 { - return false - } - handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) - if err != nil { - if errors.Is(err, windows.ERROR_ACCESS_DENIED) { - return true - } - return false - } - defer windows.CloseHandle(handle) - - status, err := windows.WaitForSingleObject(handle, 0) - if err != nil { - return false - } - return status == uint32(windows.WAIT_TIMEOUT) -} - // detachSysProcAttr starts the daemon in a new process group so it does not // receive the console's CTRL_C/CTRL_BREAK while `ao start` waits for readiness. func detachSysProcAttr() *syscall.SysProcAttr { diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 36e83e5a95..ce0157389c 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -3,6 +3,7 @@ package cli import ( + "context" "errors" "io" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/aoagents/agent-orchestrator/backend/internal/daemon" + "github.com/aoagents/agent-orchestrator/backend/internal/processalive" ) // Execute runs the ao CLI with process stdio. @@ -48,31 +50,37 @@ type Deps struct { Out io.Writer Err io.Writer - HTTPClient *http.Client - Executable func() (string, error) - StartProcess func(processStartConfig) (processHandle, error) - ProcessAlive func(pid int) bool - LookPath func(file string) (string, error) - Now func() time.Time - Sleep func(time.Duration) + HTTPClient *http.Client + Executable func() (string, error) + StartProcess func(processStartConfig) error + ProcessAlive func(pid int) bool + LookPath func(file string) (string, error) + CommandOutput func(ctx context.Context, name string, args ...string) ([]byte, error) + Now func() time.Time + Sleep func(time.Duration) } // DefaultDeps returns production dependencies. func DefaultDeps() Deps { return Deps{ - In: os.Stdin, - Out: os.Stdout, - Err: os.Stderr, - HTTPClient: &http.Client{Timeout: 2 * time.Second}, - Executable: os.Executable, - StartProcess: startProcess, - ProcessAlive: processAlive, - LookPath: exec.LookPath, - Now: time.Now, - Sleep: time.Sleep, + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + HTTPClient: &http.Client{Timeout: 2 * time.Second}, + Executable: os.Executable, + StartProcess: startProcess, + ProcessAlive: processalive.Alive, + LookPath: exec.LookPath, + CommandOutput: commandOutput, + Now: time.Now, + Sleep: time.Sleep, } } +func commandOutput(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).CombinedOutput() +} + func (d Deps) withDefaults() Deps { def := DefaultDeps() if d.In == nil { @@ -99,6 +107,9 @@ func (d Deps) withDefaults() Deps { if d.LookPath == nil { d.LookPath = def.LookPath } + if d.CommandOutput == nil { + d.CommandOutput = def.CommandOutput + } if d.Now == nil { d.Now = def.Now } @@ -146,11 +157,19 @@ type commandContext struct { deps Deps } +func noArgs(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(0)(cmd, args); err != nil { + return usageError{err} + } + return nil +} + func newDaemonCommand() *cobra.Command { return &cobra.Command{ Use: "daemon", Short: "Run the AO backend daemon", Hidden: true, + Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { return daemon.Run() }, diff --git a/backend/internal/cli/root_test.go b/backend/internal/cli/root_test.go index 5b9205315a..f9576cb945 100644 --- a/backend/internal/cli/root_test.go +++ b/backend/internal/cli/root_test.go @@ -34,6 +34,27 @@ func TestRootHelpDoesNotShowDaemon(t *testing.T) { } } +func TestCommandsRejectUnexpectedArgs(t *testing.T) { + for _, args := range [][]string{ + {"daemon", "extra"}, + {"start", "extra"}, + {"stop", "extra"}, + {"status", "extra"}, + {"doctor", "extra"}, + {"version", "extra"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + _, _, err := executeCLI(t, Deps{}, args...) + if err == nil { + t.Fatal("expected usage error") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("ExitCode(%v) = %d, want 2", err, got) + } + }) + } +} + func TestStatusStoppedJSON(t *testing.T) { setConfigEnv(t) @@ -71,9 +92,9 @@ func TestStartReturnsExistingReadyDaemon(t *testing.T) { var started bool out, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { return pid == os.Getpid() }, - StartProcess: func(processStartConfig) (processHandle, error) { + StartProcess: func(processStartConfig) error { started = true - return processHandle{}, nil + return nil }, Now: func() time.Time { return time.Unix(110, 0).UTC() }, }, "start", "--json") @@ -115,7 +136,7 @@ func TestStartClearsStaleRunFileBeforeSpawning(t *testing.T) { out, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { return pid == 4242 || pid == os.Getpid() }, - StartProcess: func(processStartConfig) (processHandle, error) { + StartProcess: func(processStartConfig) error { info, err := runfile.Read(cfg.runFile) if err != nil { t.Fatal(err) @@ -127,7 +148,7 @@ func TestStartClearsStaleRunFileBeforeSpawning(t *testing.T) { if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(110, 0).UTC()}); err != nil { t.Fatal(err) } - return processHandle{PID: os.Getpid()}, nil + return nil }, Now: func() time.Time { return time.Unix(120, 0).UTC() }, }, "start", "--json") @@ -301,9 +322,9 @@ func TestStartDoesNotSpawnWhenLiveProbeFails(t *testing.T) { var started bool _, _, err := executeCLI(t, Deps{ ProcessAlive: func(pid int) bool { return pid == 4242 }, - StartProcess: func(processStartConfig) (processHandle, error) { + StartProcess: func(processStartConfig) error { started = true - return processHandle{}, nil + return nil }, }, "start", "--timeout", "1ns", "--json") if err == nil { diff --git a/backend/internal/cli/start.go b/backend/internal/cli/start.go index c6e7ee725c..a67e4007cf 100644 --- a/backend/internal/cli/start.go +++ b/backend/internal/cli/start.go @@ -26,6 +26,7 @@ func newStartCommand(ctx *commandContext) *cobra.Command { cmd := &cobra.Command{ Use: "start", Short: "Start the AO daemon", + Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { st, err := ctx.startDaemon(cmd.Context(), opts) if err != nil { @@ -34,7 +35,7 @@ func newStartCommand(ctx *commandContext) *cobra.Command { if opts.json { return writeJSON(cmd.OutOrStdout(), st) } - if st.State == "ready" { + if st.State == stateReady { _, err = fmt.Fprintf(cmd.OutOrStdout(), "AO daemon ready (pid %d, port %d)\n", st.PID, st.Port) return err } @@ -57,17 +58,17 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da if err != nil { return daemonStatus{}, err } - if st.State == "ready" { + if st.State == stateReady { return st, nil } - if st.State != "stopped" && st.State != "stale" { + if st.State != stateStopped && st.State != stateStale { ready, waitErr := c.waitForReady(ctx, opts.timeout) if waitErr == nil { return ready, nil } return daemonStatus{}, fmt.Errorf("daemon process exists but did not become ready: %w", waitErr) } - if st.State == "stale" { + if st.State == stateStale { if err := runfile.Remove(cfg.RunFilePath); err != nil { return daemonStatus{}, err } @@ -91,7 +92,7 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da } defer func() { _ = logFile.Close() }() - if _, err := c.deps.StartProcess(processStartConfig{ + if err := c.deps.StartProcess(processStartConfig{ Path: exe, Args: []string{"daemon"}, Env: os.Environ(), @@ -128,7 +129,7 @@ func (c *commandContext) waitForReady(ctx context.Context, timeout time.Duration lastErr = err } else { last = st - if st.State == "ready" { + if st.State == stateReady { return st, nil } } diff --git a/backend/internal/cli/status.go b/backend/internal/cli/status.go index 8a020d5dbb..d0b3995bcd 100644 --- a/backend/internal/cli/status.go +++ b/backend/internal/cli/status.go @@ -20,17 +20,27 @@ type statusOptions struct { json bool } +type daemonState string + +const ( + stateReady daemonState = "ready" + stateStopped daemonState = "stopped" + stateStale daemonState = "stale" + stateUnhealthy daemonState = "unhealthy" + stateNotReady daemonState = "not_ready" +) + type daemonStatus struct { - State string `json:"state"` - PID int `json:"pid,omitempty"` - Port int `json:"port,omitempty"` - StartedAt *time.Time `json:"startedAt,omitempty"` - Uptime string `json:"uptime,omitempty"` - RunFile string `json:"runFile"` - DataDir string `json:"dataDir"` - Health string `json:"health,omitempty"` - Ready string `json:"ready,omitempty"` - Error string `json:"error,omitempty"` + State daemonState `json:"state"` + PID int `json:"pid,omitempty"` + Port int `json:"port,omitempty"` + StartedAt *time.Time `json:"startedAt,omitempty"` + Uptime string `json:"uptime,omitempty"` + RunFile string `json:"runFile"` + DataDir string `json:"dataDir"` + Health string `json:"health,omitempty"` + Ready string `json:"ready,omitempty"` + Error string `json:"error,omitempty"` owned bool } @@ -45,6 +55,7 @@ func newStatusCommand(ctx *commandContext) *cobra.Command { cmd := &cobra.Command{ Use: "status", Short: "Show AO daemon status", + Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { st, err := ctx.inspectDaemon(cmd.Context()) if err != nil { @@ -65,7 +76,7 @@ func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error if err != nil { return daemonStatus{}, err } - st := daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir} + st := daemonStatus{State: stateStopped, RunFile: cfg.RunFilePath, DataDir: cfg.DataDir} info, err := runfile.Read(cfg.RunFilePath) if err != nil { @@ -82,47 +93,47 @@ func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error st.Uptime = formatUptime(c.deps.Now().Sub(info.StartedAt)) if !c.deps.ProcessAlive(info.PID) { - st.State = "stale" + st.State = stateStale st.Error = "run-file points to a dead process" return st, nil } health, err := c.readProbe(ctx, info.Port, "healthz") if err != nil { - st.State = "unhealthy" + st.State = stateUnhealthy st.Error = err.Error() return st, nil } if err := verifyProbeOwner(health, info.PID, "healthz"); err != nil { - st.State = "stale" + st.State = stateStale st.Error = err.Error() return st, nil } st.owned = true st.Health = health.Status if health.Status != "ok" { - st.State = "unhealthy" + st.State = stateUnhealthy return st, nil } ready, err := c.readProbe(ctx, info.Port, "readyz") if err != nil { - st.State = "not_ready" + st.State = stateNotReady st.Error = err.Error() return st, nil } if err := verifyProbeOwner(ready, info.PID, "readyz"); err != nil { - st.State = "stale" + st.State = stateStale st.owned = false st.Error = err.Error() return st, nil } st.Ready = ready.Status - if ready.Status == "ready" { - st.State = "ready" + if ready.Status == string(stateReady) { + st.State = stateReady return st, nil } - st.State = "not_ready" + st.State = stateNotReady return st, nil } diff --git a/backend/internal/cli/stop.go b/backend/internal/cli/stop.go index b363b46312..d77f508ab5 100644 --- a/backend/internal/cli/stop.go +++ b/backend/internal/cli/stop.go @@ -24,6 +24,7 @@ func newStopCommand(ctx *commandContext) *cobra.Command { cmd := &cobra.Command{ Use: "stop", Short: "Stop the AO daemon", + Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { st, err := ctx.stopDaemon(cmd.Context(), opts) if err != nil { @@ -32,7 +33,7 @@ func newStopCommand(ctx *commandContext) *cobra.Command { if opts.json { return writeJSON(cmd.OutOrStdout(), st) } - if st.State == "stopped" { + if st.State == stateStopped { _, err = fmt.Fprintln(cmd.OutOrStdout(), "AO daemon stopped") return err } @@ -54,13 +55,13 @@ func (c *commandContext) stopDaemon(ctx context.Context, opts stopOptions) (daem return daemonStatus{}, err } switch st.State { - case "stopped": + case stateStopped: return st, nil - case "stale": + case stateStale: if err := runfile.Remove(cfg.RunFilePath); err != nil { return daemonStatus{}, err } - return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil + return daemonStatus{State: stateStopped, RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil } if !st.owned { if st.Error != "" { @@ -112,7 +113,7 @@ func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePat } alive := c.deps.ProcessAlive(pid) if info == nil { - return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil + return daemonStatus{State: stateStopped, RunFile: runFilePath, DataDir: dataDir}, nil } if !alive { // Only remove the run-file if it still belongs to the process we @@ -124,7 +125,7 @@ func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePat return daemonStatus{}, err } } - return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil + return daemonStatus{State: stateStopped, RunFile: runFilePath, DataDir: dataDir}, nil } if !c.deps.Now().Before(deadline) { return daemonStatus{}, fmt.Errorf("daemon pid %d did not stop within %s", pid, timeout) diff --git a/backend/internal/cli/stop_test.go b/backend/internal/cli/stop_test.go index 85b6a5092b..e364dce152 100644 --- a/backend/internal/cli/stop_test.go +++ b/backend/internal/cli/stop_test.go @@ -33,7 +33,7 @@ func TestWaitForStoppedKeepsRunFileFromConcurrentStart(t *testing.T) { if err != nil { t.Fatal(err) } - if st.State != "stopped" { + if st.State != stateStopped { t.Fatalf("state = %q, want stopped", st.State) } @@ -70,7 +70,7 @@ func TestWaitForStoppedRemovesOwnRunFile(t *testing.T) { if err != nil { t.Fatal(err) } - if st.State != "stopped" { + if st.State != stateStopped { t.Fatalf("state = %q, want stopped", st.State) } info, err := runfile.Read(runFile) diff --git a/backend/internal/cli/version.go b/backend/internal/cli/version.go index 7297cc13c5..9af3ee2991 100644 --- a/backend/internal/cli/version.go +++ b/backend/internal/cli/version.go @@ -31,6 +31,7 @@ func newVersionCommand() *cobra.Command { return &cobra.Command{ Use: "version", Short: "Print version information", + Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { _, err := fmt.Fprintln(cmd.OutOrStdout(), VersionString()) return err diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 719e75244f..529e370714 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -16,17 +16,14 @@ import ( const ( // LoopbackHost is the only host the daemon ever binds. There is deliberately // no AO_HOST env var: the daemon has no auth/CORS/TLS and a stray - // AO_HOST=0.0.0.0 would turn it into a public no-auth service. The legacy - // TS server bound all-interfaces by accident and docs/CROSS_PLATFORM.md - // already calls that out as a bug; the Go rewrite fixes it by removing the - // knob entirely. If a non-default loopback (e.g. ::1, 127.0.0.2) is ever - // needed, add it back with an IsLoopback() validator — not a raw env read. + // AO_HOST=0.0.0.0 would turn it into a public no-auth service. If a + // non-default loopback (e.g. ::1, 127.0.0.2) is ever needed, add it back with + // an IsLoopback() validator — not a raw env read. LoopbackHost = "127.0.0.1" - // DefaultPort is the single port the whole surface (REST, SSE, WS, static) - // is served from. Single-port keeps it same-origin: no CORS, one lifecycle. + // DefaultPort is the single port for REST, terminal mux, health, and control. DefaultPort = 3001 - // DefaultRequestTimeout bounds a single request. Long-lived surfaces (SSE, - // WS) are mounted outside this timeout; it guards the REST surface only. + // DefaultRequestTimeout bounds a single REST request. Long-lived terminal mux + // connections are mounted outside this timeout. DefaultRequestTimeout = 60 * time.Second // DefaultShutdownTimeout is the hard cap on graceful shutdown. After this // the process exits even if connections are still draining. @@ -47,8 +44,8 @@ type Config struct { // RunFilePath is where the PID + port handshake file (running.json) is // written so the Electron supervisor can discover and reap the daemon. RunFilePath string - // DataDir is the directory holding durable state (the SQLite database and - // the CDC JSONL log). It is created on first use by the storage layer. + // DataDir is the directory holding durable SQLite state: DB and WAL files. + // It is created on first use by the storage layer. DataDir string } @@ -136,7 +133,7 @@ func parsePositiveDuration(name, raw string) (time.Duration, error) { } // resolveRunFilePath picks where running.json lives. An explicit AO_RUN_FILE -// wins; otherwise it sits under the per-user state directory so multiple repos +// wins; otherwise it sits under the per-user config directory so multiple repos // share one supervisor handshake location. func resolveRunFilePath() (string, error) { if p, ok := os.LookupEnv("AO_RUN_FILE"); ok && p != "" { @@ -150,7 +147,7 @@ func resolveRunFilePath() (string, error) { } // resolveDataDir picks where durable state (the SQLite DB) lives. An explicit -// AO_DATA_DIR wins; otherwise it sits under the per-user state directory +// AO_DATA_DIR wins; otherwise it sits under the per-user config directory // alongside running.json. func resolveDataDir() (string, error) { if p, ok := os.LookupEnv("AO_DATA_DIR"); ok && p != "" { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index dfcb5b8af7..2d910f9c46 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,6 +1,8 @@ package config import ( + "path/filepath" + "strings" "testing" "time" ) @@ -8,7 +10,7 @@ import ( func TestLoadDefaults(t *testing.T) { // Clear every recognised var so we observe pure defaults regardless of the // surrounding environment. - for _, k := range []string{"AO_PORT", "AO_REQUEST_TIMEOUT", "AO_SHUTDOWN_TIMEOUT", "AO_RUN_FILE"} { + for _, k := range []string{"AO_PORT", "AO_REQUEST_TIMEOUT", "AO_SHUTDOWN_TIMEOUT", "AO_RUN_FILE", "AO_DATA_DIR"} { t.Setenv(k, "") } @@ -31,6 +33,15 @@ func TestLoadDefaults(t *testing.T) { if cfg.RunFilePath == "" { t.Error("RunFilePath is empty, want a resolved default path") } + if !strings.HasSuffix(cfg.RunFilePath, filepath.Join("agent-orchestrator", "running.json")) { + t.Errorf("RunFilePath = %q, want agent-orchestrator/running.json suffix", cfg.RunFilePath) + } + if cfg.DataDir == "" { + t.Error("DataDir is empty, want a resolved default path") + } + if !strings.HasSuffix(cfg.DataDir, filepath.Join("agent-orchestrator", "data")) { + t.Errorf("DataDir = %q, want agent-orchestrator/data suffix", cfg.DataDir) + } } func TestLoadOverrides(t *testing.T) { @@ -38,6 +49,7 @@ func TestLoadOverrides(t *testing.T) { t.Setenv("AO_REQUEST_TIMEOUT", "5s") t.Setenv("AO_SHUTDOWN_TIMEOUT", "3s") t.Setenv("AO_RUN_FILE", "/tmp/ao-test-running.json") + t.Setenv("AO_DATA_DIR", "/tmp/ao-test-data") cfg, err := Load() if err != nil { @@ -55,6 +67,9 @@ func TestLoadOverrides(t *testing.T) { if cfg.RunFilePath != "/tmp/ao-test-running.json" { t.Errorf("RunFilePath = %q, want /tmp/ao-test-running.json", cfg.RunFilePath) } + if cfg.DataDir != "/tmp/ao-test-data" { + t.Errorf("DataDir = %q, want /tmp/ao-test-data", cfg.DataDir) + } } func TestLoadInvalid(t *testing.T) { diff --git a/backend/internal/daemon/cdc_wiring.go b/backend/internal/daemon/cdc_wiring.go index a76c5c78b6..8a0ebbcf48 100644 --- a/backend/internal/daemon/cdc_wiring.go +++ b/backend/internal/daemon/cdc_wiring.go @@ -2,18 +2,17 @@ package daemon import ( "context" - "encoding/json" "log/slog" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) -// cdcPipeline owns the running CDC poller and the broadcaster the SSE transport -// subscribes to. The DB triggers write change_log; the poller tails it and fans -// each new event out through the broadcaster. Durable catch-up is the client's -// job (it reads change_log from its own Last-Event-ID), so the poller only -// pushes live events and re-seeks to head on restart. +// cdcPipeline owns the running CDC poller and live-event broadcaster. The DB +// triggers write change_log; the poller tails it and fans each new event out to +// live transports such as terminal session-state subscriptions. Durable catch-up +// is a client concern; the poller only pushes live events and re-seeks to head +// on restart. type cdcPipeline struct { Broadcaster *cdc.Broadcaster done <-chan struct{} @@ -23,7 +22,7 @@ type cdcPipeline struct { // when ctx is cancelled; Stop waits for it to drain. func startCDC(ctx context.Context, store *sqlite.Store, logger *slog.Logger) (*cdcPipeline, error) { bcast := cdc.NewBroadcaster() - poller := cdc.NewPoller(cdcSource{store}, bcast, cdc.PollerConfig{Logger: logger}) + poller := cdc.NewPoller(store, bcast, cdc.PollerConfig{Logger: logger}) if err := poller.SeekToHead(ctx); err != nil { return nil, err } @@ -36,29 +35,3 @@ func (p *cdcPipeline) Stop() error { <-p.done return nil } - -// cdcSource adapts *sqlite.Store's change_log reads to cdc.Source. -type cdcSource struct{ store *sqlite.Store } - -func (s cdcSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) { - rows, err := s.store.ReadChangeLogAfter(ctx, after, limit) - if err != nil { - return nil, err - } - out := make([]cdc.Event, len(rows)) - for i, r := range rows { - out[i] = cdc.Event{ - Seq: r.Seq, - ProjectID: r.ProjectID, - SessionID: r.SessionID, - Type: cdc.EventType(r.EventType), - Payload: json.RawMessage(r.Payload), - CreatedAt: r.CreatedAt, - } - } - return out, nil -} - -func (s cdcSource) LatestSeq(ctx context.Context) (int64, error) { - return s.store.MaxChangeLogSeq(ctx) -} diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 3cb4f45ccc..b8d89053e9 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -11,9 +11,10 @@ import ( "os/signal" "syscall" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" "github.com/aoagents/agent-orchestrator/backend/internal/terminal" @@ -38,13 +39,9 @@ func Run() error { return fmt.Errorf("daemon already running (pid %d, port %d); refusing to start", live.PID, live.Port) } - // Open the durable store and bring up the CDC substrate: the DB triggers - // capture changes into change_log, the poller tails it, and the broadcaster - // fans events out to the SSE transport. The LCM/Session Manager and the HTTP - // API routes that drive and read this store are owned by the daemon lane and - // are wired there once their collaborators (Notifier, AgentMessenger, and the - // runtime/agent/workspace plugins) have production implementations; here we - // stand up the persistence + change-delivery foundation they build on. + // Open the durable store and bring up the CDC substrate: DB triggers capture + // changes into change_log, the poller tails it, and the broadcaster fans + // events out to live transports. store, err := sqlite.Open(cfg.DataDir) if err != nil { return fmt.Errorf("open store: %w", err) @@ -61,46 +58,27 @@ func Run() error { return err } - // Terminal streaming: the tmux runtime supplies the PTY-attach command and + // Terminal streaming: the Zellij runtime supplies the PTY-attach command and // liveness; the CDC broadcaster feeds the session-state channel. The manager // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow // through the CDC change_log — only session-state events do. - runtimeAdapter := tmux.New(tmux.Options{}) + runtimeAdapter := zellij.New(zellij.Options{}) termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) defer termMgr.Close() - srv, err := httpd.New(cfg, log, termMgr) + srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: project.NewManager(store)}) if err != nil { - return err - } - - // Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE - // timer). This makes the write path live end-to-end: LCM write -> store -> DB - // trigger -> change_log -> poller -> broadcaster. - lcStack := startLifecycle(ctx, store, log) - - // Bring up the Session Manager. Runtime (tmux) and Workspace (gitworktree) - // are real on main; ports.Agent has no production adapter yet, so a loud - // stub returns a sentinel command that makes any Spawn fail at the runtime - // layer rather than start a broken session quietly. Notifier and - // AgentMessenger remain stubbed alongside the LCM until their multiplexers - // land. No HTTP routes wire to this yet — the daemon lane (#10) owns API - // surfacing — so we hold the SM in a local until it does. - sStack, err := startSession(ctx, cfg, lcStack, log) - if err != nil { - // startSession is the first start* call after this point that can - // realistically fail while the cdc poller and the reaper are already - // running. Mirror the bottom-of-run shutdown sequence so both have - // drained before the deferred store.Close() fires. Defers would hit - // the LIFO trap (see comment after srv.Run), hence explicit. stop() - lcStack.Stop() if cdcErr := cdcPipe.Stop(); cdcErr != nil { log.Error("cdc pipeline shutdown", "err", cdcErr) } return err } - _ = sStack + + // Bring up the Lifecycle Manager and the reaper. This makes the session + // lifecycle write path live end-to-end: reducer write -> store -> DB trigger + // -> change_log -> poller -> broadcaster. + lcStack := startLifecycle(ctx, store, runtimeAdapter, log) runErr := srv.Run(ctx) diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 65308f0e88..5c04002da3 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -3,147 +3,27 @@ package daemon import ( "context" "log/slog" - "path/filepath" - "sync" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" - "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" - "github.com/aoagents/agent-orchestrator/backend/internal/notification" "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/session" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) -// lifecycleStack owns the running LCM + reaper. The LCM is the sole writer of -// canonical transitions; the reaper is the OBSERVE-layer timer that probes live -// runtimes and reports facts back through it. Store is exposed so the Session -// Manager construction in startSession can plug the same SessionStore + PRWriter -// instance the LCM already holds (*sqlite.Store satisfies both ports directly). +// lifecycleStack owns the runtime reaper goroutine started with the lifecycle +// reducer. The reducer itself is only used for wiring observations into storage. type lifecycleStack struct { - LCM *lifecycle.Manager - Store *sqlite.Store reaperDone <-chan struct{} } -// startLifecycle constructs the LCM over the store adapter and starts the reaper. -// The goroutine stops when ctx is cancelled; Stop waits for it to drain. -// -// TEMPORARY STUBS (replace as the daemon lane lands the collaborators): -// - noopMessenger — swap for the runtime/agent-plugin-backed AgentMessenger. -// - reaper.MapRegistry{} — empty runtime registry, so the reaper ticks -// escalations but probes nothing until the runtime plugins exist. -func startLifecycle(ctx context.Context, store *sqlite.Store, logger *slog.Logger) *lifecycleStack { - renderer := notification.NewRenderer(store) - notifier := notification.NewEnqueuer(store, renderer, logger) - lcm := lifecycle.New(store, store, notifier, noopMessenger{}) - rp := reaper.New(lcm, reaper.MapRegistry{}, reaper.Config{Logger: logger}) - return &lifecycleStack{LCM: lcm, Store: store, reaperDone: rp.Start(ctx)} +// startLifecycle constructs the Lifecycle Manager over the store and starts the +// reaper. The goroutine stops when ctx is cancelled; Stop waits for it to drain. +func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runtime, logger *slog.Logger) *lifecycleStack { + lcm := lifecycle.New(store, nil) + rp := reaper.New(lcm, store, runtime, reaper.Config{Logger: logger}) + return &lifecycleStack{reaperDone: rp.Start(ctx)} } -// Stop waits for the reaper goroutine to exit (the caller must have cancelled the -// ctx passed to startLifecycle). +// Stop waits for the reaper goroutine to exit. The caller must cancel the ctx +// passed to startLifecycle before calling Stop. func (l *lifecycleStack) Stop() { <-l.reaperDone } - -// sessionStack holds the daemon's live Session Manager. It mirrors -// lifecycleStack's shape so a future teardown hook (worktree drain, runtime -// shutdown) has a place to attach. -type sessionStack struct { - SM *session.Manager -} - -// startSession constructs the Session Manager over the real tmux Runtime and -// gitworktree Workspace, the LCM and adapter created by startLifecycle, and the -// loud-stub Agent / Messenger / Notifier ports that have no production -// implementations yet. It does NOT mount any HTTP routes — those come with the -// daemon lane (#10). Returning the SM here lets main hold the wired-but-quiet -// instance so future route wiring is a one-line plumb-through. -func startSession(ctx context.Context, cfg config.Config, ls *lifecycleStack, log *slog.Logger) (*sessionStack, error) { - _ = ctx // reserved for future ctx-aware plugin construction; today's tmux/gitworktree constructors are synchronous. - runtime := tmux.New(tmux.Options{}) - - ws, err := gitworktree.New(gitworktree.Options{ - // ManagedRoot is the directory under which per-session worktrees are - // materialised. Co-located with the SQLite DB so a single AO_DATA_DIR - // override moves all durable per-user state together. - ManagedRoot: filepath.Join(cfg.DataDir, "worktrees"), - // An empty resolver fails every project lookup with a clear - // `no repo configured for project %q` error. That's the right loud - // failure until the projects table feeds repo paths into the resolver - // — hard-coding a single repo here would silently misroute spawns. - RepoResolver: gitworktree.StaticRepoResolver{}, - }) - if err != nil { - return nil, err - } - - agent := newNoopAgent(log) - - sm := session.New(session.Deps{ - Runtime: runtime, - Agent: agent, - Workspace: ws, - Store: ls.Store, - Messenger: noopMessenger{}, - Lifecycle: ls.LCM, - }) - - return &sessionStack{SM: sm}, nil -} - -// noopMessenger is a TEMPORARY stub (see startLifecycle): the canonical write -// path and durable notifications work without it; only live agent nudges are -// absent until the real runtime/agent plugin is wired. -type noopMessenger struct{} - -func (noopMessenger) Send(context.Context, domain.SessionID, string) error { return nil } - -// agentNotWiredSentinel is the launch / restore command (and env-var key) -// noopAgent returns. tmux will try to exec a binary named exactly this and fail -// fast, so a Spawn against the loud stub surfaces a clear runtime error rather -// than starting a quiet, broken session. -const agentNotWiredSentinel = "AO_AGENT_HARNESS_NOT_WIRED" - -// noopAgent is a loud stub for ports.Agent. There is no production Agent -// adapter on main yet; rather than panic at construction, this struct lets the -// daemon stand up the Session Manager, then logs a single warning the first -// time any SM call route through it and returns sentinel commands that make -// the runtime layer fail loudly. -type noopAgent struct { - log *slog.Logger - once *sync.Once -} - -var _ ports.Agent = (*noopAgent)(nil) - -func newNoopAgent(log *slog.Logger) *noopAgent { - return &noopAgent{log: log, once: &sync.Once{}} -} - -func (n *noopAgent) warn() { - n.once.Do(func() { - n.log.Warn( - "agent harness not wired: Spawn/Restore will fail at the runtime layer until a ports.Agent adapter is built", - "sentinel", agentNotWiredSentinel, - "next_step", "implement a per-harness ports.Agent adapter and plug it into startSession", - ) - }) -} - -func (n *noopAgent) GetLaunchCommand(ports.AgentConfig) string { - n.warn() - return agentNotWiredSentinel -} - -func (n *noopAgent) GetEnvironment(ports.AgentConfig) map[string]string { - n.warn() - return map[string]string{agentNotWiredSentinel: "1"} -} - -func (n *noopAgent) GetRestoreCommand(string) string { - n.warn() - return agentNotWiredSentinel -} diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 3568eeb755..d743fcee9b 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -2,27 +2,21 @@ package daemon import ( "context" - "io" - "log/slog" - "reflect" "sync" "testing" "time" - "unsafe" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" - "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" - "github.com/aoagents/agent-orchestrator/backend/internal/notification" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/session" + "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) // TestWiring_WriteFlowsToBroadcaster exercises the real boot path end to end: // a lifecycle write -> sqlite -> DB trigger -> change_log -> CDC poller -> -// broadcaster, through the production wiring.Adapter and cdcSource. +// broadcaster, through the same cdc.Source implementation the daemon uses. func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { ctx := context.Background() store, err := sqlite.Open(t.TempDir()) @@ -31,13 +25,10 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { } defer store.Close() - renderer := notification.NewRenderer(store) - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - notifier := notification.NewEnqueuer(store, renderer, logger) - lcm := lifecycle.New(store, store, notifier, noopMessenger{}) + lcm := lifecycle.New(store, nil) bcast := cdc.NewBroadcaster() - poller := cdc.NewPoller(cdcSource{store}, bcast, cdc.PollerConfig{}) + poller := cdc.NewPoller(store, bcast, cdc.PollerConfig{}) if err := poller.SeekToHead(ctx); err != nil { t.Fatal(err) } @@ -46,19 +37,19 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { var got []cdc.Event bcast.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) - if err := store.UpsertProject(ctx, sqlite.ProjectRow{ID: "mer", Path: "/repo/mer"}); err != nil { + if err := store.Upsert(ctx, project.Row{ID: "mer", Path: "/repo/mer"}); err != nil { t.Fatal(err) } rec, err := store.CreateSession(ctx, domain.SessionRecord{ ProjectID: "mer", Kind: domain.KindWorker, - Lifecycle: domain.CanonicalSessionLifecycle{Version: domain.LifecycleVersion, Session: domain.SessionSubstate{State: domain.SessionNotStarted}}, + Activity: domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: time.Now(), Source: domain.SourceNone}, }) if err != nil { t.Fatal(err) } // A real transition through the engine, which writes the row and fires the - // is_alive/activity_state CDC trigger. - if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{Valid: true, State: domain.ActivityActive, Timestamp: time.Now()}); err != nil { + // activity_state/is_terminated CDC trigger. + if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{Valid: true, State: domain.ActivityActive, Timestamp: time.Now(), Source: domain.SourceNative}); err != nil { t.Fatal(err) } @@ -78,78 +69,3 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { t.Fatalf("expected a change_log event for %s to reach the broadcaster, got %d events", rec.ID, len(got)) } } - -// TestWiring_SessionManagerSharesLifecycleStoreAndLCM verifies that startSession -// constructs an SM whose Store and Lifecycle dependencies are the exact same -// values the LCM holds: a single canonical-store + LCM pair, not two parallel -// stacks that would diverge under concurrent writes. The brief constraint -// forbids modifying session/manager.go to add accessors, so the assertion -// reaches into the unexported fields via reflect + unsafe — scoped to the test -// and isolated in inspectSessionDeps. -func TestWiring_SessionManagerSharesLifecycleStoreAndLCM(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - - store, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatal(err) - } - // Registered first so it runs LAST (after the reaper has drained). - t.Cleanup(func() { _ = store.Close() }) - - log := slog.New(slog.NewTextHandler(io.Discard, nil)) - cfg := config.Config{DataDir: t.TempDir()} - - lcStack := startLifecycle(ctx, store, log) - // lcStack.Stop blocks on the reaper goroutine, which only exits once its - // ctx is cancelled. Production main.go calls stop() before lcStack.Stop() - // for the same reason — same ordering here. - t.Cleanup(func() { - cancel() - lcStack.Stop() - }) - - sStack, err := startSession(ctx, cfg, lcStack, log) - if err != nil { - t.Fatal(err) - } - if sStack == nil || sStack.SM == nil { - t.Fatal("startSession returned nil Session Manager") - } - - gotStore, gotLCM := inspectSessionDeps(t, sStack.SM) - - // Store should be the exact *sqlite.Store the LCM was constructed with. - gotSqlite, ok := gotStore.(*sqlite.Store) - if !ok { - t.Fatalf("SM.store is %T, want *sqlite.Store", gotStore) - } - if gotSqlite != lcStack.Store { - t.Fatalf("SM.store is a different *sqlite.Store than lcStack.Store") - } - - // Lifecycle should be the exact *lifecycle.Manager pointer from startLifecycle. - gotLCMPtr, ok := gotLCM.(*lifecycle.Manager) - if !ok { - t.Fatalf("SM.lcm is %T, want *lifecycle.Manager", gotLCM) - } - if gotLCMPtr != lcStack.LCM { - t.Fatalf("SM.lcm pointer (%p) differs from lcStack.LCM (%p)", gotLCMPtr, lcStack.LCM) - } -} - -// inspectSessionDeps reads session.Manager's unexported store and lcm fields. -// The brief forbids modifying session/manager.go to expose them; we settle for -// reflect + unsafe scoped to this one test helper. If the field names change -// upstream, the type assertion (and this helper) is the only place to touch. -func inspectSessionDeps(t *testing.T, sm *session.Manager) (store any, lcm any) { - t.Helper() - v := reflect.ValueOf(sm).Elem() - storeField := v.FieldByName("store") - lcmField := v.FieldByName("lcm") - if !storeField.IsValid() || !lcmField.IsValid() { - t.Fatalf("session.Manager fields renamed: store.IsValid=%v lcm.IsValid=%v — update inspectSessionDeps", storeField.IsValid(), lcmField.IsValid()) - } - storeVal := reflect.NewAt(storeField.Type(), unsafe.Pointer(storeField.UnsafeAddr())).Elem() - lcmVal := reflect.NewAt(lcmField.Type(), unsafe.Pointer(lcmField.UnsafeAddr())).Elem() - return storeVal.Interface(), lcmVal.Interface() -} diff --git a/backend/internal/domain/activity.go b/backend/internal/domain/activity.go new file mode 100644 index 0000000000..c725a38cdf --- /dev/null +++ b/backend/internal/domain/activity.go @@ -0,0 +1,63 @@ +package domain + +import "time" + +// ActivityState is how busy the agent is, derived from its output/JSONL. +type ActivityState string + +// Activity states. WaitingInput and Blocked are sticky (see IsSticky). +const ( + ActivityActive ActivityState = "active" + ActivityIdle ActivityState = "idle" + ActivityWaitingInput ActivityState = "waiting_input" + ActivityBlocked ActivityState = "blocked" + ActivityExited ActivityState = "exited" +) + +// IsSticky reports whether an activity state must NOT be aged/demoted by the +// passage of time (a paused agent is still paused until a new signal says so). +func (a ActivityState) IsSticky() bool { + return a == ActivityWaitingInput || a == ActivityBlocked +} + +// ActivitySource records where an activity reading came from, so a weaker +// source can't override a stronger one. +type ActivitySource string + +// Activity signal sources, strongest first. +const ( + SourceNative ActivitySource = "native" + SourceTerminal ActivitySource = "terminal" + SourceHook ActivitySource = "hook" + SourceRuntime ActivitySource = "runtime" + SourceNone ActivitySource = "none" +) + +// CanOverride reports whether a reading from source a may replace a current +// reading from source current. Unknown sources are treated as weakest. +func (a ActivitySource) CanOverride(current ActivitySource) bool { + return activitySourceRank(a) <= activitySourceRank(current) +} + +func activitySourceRank(s ActivitySource) int { + switch s { + case SourceNative: + return 0 + case SourceTerminal: + return 1 + case SourceHook: + return 2 + case SourceRuntime: + return 3 + default: + return 4 + } +} + +// ActivitySubstate is the persisted activity reading: the state, when it was +// last observed, and which source reported it. +type ActivitySubstate struct { + State ActivityState `json:"state"` + LastActivityAt time.Time `json:"lastActivityAt"` + Source ActivitySource `json:"source"` +} diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go deleted file mode 100644 index be195aef4d..0000000000 --- a/backend/internal/domain/decide/decide.go +++ /dev/null @@ -1,158 +0,0 @@ -// Package decide is the pure DECIDE core: total, deterministic, zero I/O. It -// collapses observed liveness facts (plus the prior detecting memory) into one -// LifecycleDecision. Every function here is side-effect free so the whole -// liveness truth-table can be tested in isolation. -// -// PR-driven behaviour is NOT here: PR display status is derived by -// domain.DeriveStatus from the pr table, and PR-driven nudges are the reaction -// engine's job. decide is only about liveness + the anti-flap quarantine. -package decide - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "regexp" - "strings" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// Anti-flap tuning. detecting escalates to stuck only after this many -// consecutive unchanged-evidence ticks OR once this much wallclock has elapsed -// since first entering detecting. -const ( - DetectingMaxAttempts = 3 - DetectingMaxDuration = 5 * time.Minute -) - -// ResolveProbeDecision reconciles runtime/process liveness into a decision. -// -// The ordering encodes the load-bearing invariants: -// - an explicit kill short-circuits straight to terminal (the only inferred -// terminal this decider may reach without quarantine); -// - a *failed* probe (timeout/error) is never read as death — it routes to -// detecting, as does any disagreement between the two probes; -// - only runtime-down + process-dead + no-recent-activity reaches terminal. -func ResolveProbeDecision(in ProbeInput) LifecycleDecision { - if in.KillRequested { - reason := in.KillReason - if reason == "" { - reason = domain.TermManuallyKilled - } - return LifecycleDecision{ - Evidence: "manual kill requested", - SessionState: domain.SessionTerminated, - TerminationReason: reason, - IsAlive: false, - } - } - - if in.RuntimeFailed || in.ProcessFailed { - ev := fmt.Sprintf("probe_failed runtimeFailed=%t process=%s processFailed=%t", in.RuntimeFailed, in.Process, in.ProcessFailed) - return detecting(in, ev) - } - - if in.RuntimeAlive { - if in.Process == ProcessDead { - // Runtime up but the agent process is gone: probes disagree. - ev := fmt.Sprintf("disagree runtime=alive process=%s recentActivity=%t", in.Process, in.RecentActivity) - return detecting(in, ev) - } - return LifecycleDecision{ - Evidence: fmt.Sprintf("alive runtime=alive process=%s", in.Process), - SessionState: domain.SessionWorking, - IsAlive: true, - } - } - - // Runtime is gone. Death is only concluded when the process is *also* - // confirmed dead AND nothing has been heard from the agent recently; any - // other shape is ambiguous and quarantines. - if in.Process == ProcessAlive || in.RecentActivity { - ev := fmt.Sprintf("disagree runtime=down process=%s recentActivity=%t", in.Process, in.RecentActivity) - return detecting(in, ev) - } - if in.Process == ProcessDead { - return LifecycleDecision{ - Evidence: "dead runtime=down process=dead recentActivity=false", - SessionState: domain.SessionTerminated, - TerminationReason: domain.TermRuntimeLost, - IsAlive: false, - } - } - // Process indeterminate: cannot confirm death, so quarantine. - ev := fmt.Sprintf("runtime_lost runtime=down process=%s recentActivity=false", in.Process) - return detecting(in, ev) -} - -// CreateDetectingDecision advances or escalates the anti-flap quarantine. -// -// The attempt counter climbs only while the (timestamp-stripped) evidence hash -// is unchanged and resets the moment the evidence moves; StartedAt is preserved -// across the whole detecting episode so the duration cap is a real wall-clock -// safety net even when the evidence keeps flapping. Escalation to stuck fires at -// DetectingMaxAttempts consecutive unchanged ticks OR DetectingMaxDuration -// elapsed since first entering detecting. Detecting/stuck leave IsAlive true: -// the probe was ambiguous, so the session is not confirmed dead. -func CreateDetectingDecision(in DetectingInput) LifecycleDecision { - hash := HashEvidence(in.Evidence) - - attempts := 1 - startedAt := in.Now - if in.Prior != nil { - startedAt = in.Prior.StartedAt - if in.Prior.EvidenceHash == hash { - attempts = in.Prior.Attempts + 1 - } - } - - escalate := attempts >= DetectingMaxAttempts || !in.Now.Before(startedAt.Add(DetectingMaxDuration)) - if escalate { - return LifecycleDecision{ - Evidence: in.Evidence, - SessionState: domain.SessionStuck, - IsAlive: true, - } - } - - return LifecycleDecision{ - Evidence: in.Evidence, - Detecting: &domain.DetectingState{Attempts: attempts, StartedAt: startedAt, EvidenceHash: hash}, - SessionState: domain.SessionDetecting, - IsAlive: true, - } -} - -// HashEvidence normalises an evidence string (stripping timestamps and -// collapsing whitespace) and hashes it, so unchanged-but-restamped signals -// compare equal and the detecting counter is not reset by clock movement alone. -func HashEvidence(evidence string) string { - s := evidence - for _, re := range timestampPatterns { - s = re.ReplaceAllString(s, "") - } - s = strings.Join(strings.Fields(s), " ") - sum := sha256.Sum256([]byte(s)) - return hex.EncodeToString(sum[:]) -} - -// timestampPatterns is the list of regexes HashEvidence applies (in order) to -// delete the time-varying parts of an evidence string before hashing. -var timestampPatterns = []*regexp.Regexp{ - regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?`), - regexp.MustCompile(`\d{2}:\d{2}:\d{2}(?:\.\d+)?`), - regexp.MustCompile(`\b\d{10,13}\b`), -} - -// detecting packages a probe verdict into the shared anti-flap path, so every -// probe-driven ambiguity is counted and escalated by the identical quarantine -// logic instead of each probe branch re-implementing the counter. -func detecting(in ProbeInput, evidence string) LifecycleDecision { - return CreateDetectingDecision(DetectingInput{ - Evidence: evidence, - Prior: in.Prior, - Now: in.Now, - }) -} diff --git a/backend/internal/domain/decide/decide_test.go b/backend/internal/domain/decide/decide_test.go deleted file mode 100644 index bc25af55ed..0000000000 --- a/backend/internal/domain/decide/decide_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package decide - -import ( - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -var t0 = time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) - -func TestResolveProbeDecision(t *testing.T) { - tests := []struct { - name string - in ProbeInput - wantState domain.SessionState - wantReason domain.TerminationReason - wantAlive bool - wantDetect bool // expect a detecting verdict (first attempt -> SessionDetecting) - }{ - { - name: "kill requested -> terminated with reason", - in: ProbeInput{KillRequested: true, KillReason: domain.TermManuallyKilled, Now: t0}, - wantState: domain.SessionTerminated, wantReason: domain.TermManuallyKilled, wantAlive: false, - }, - { - name: "kill requested without reason defaults to manually_killed", - in: ProbeInput{KillRequested: true, Now: t0}, - wantState: domain.SessionTerminated, wantReason: domain.TermManuallyKilled, wantAlive: false, - }, - { - name: "runtime probe failed -> detecting (not death)", - in: ProbeInput{RuntimeFailed: true, Now: t0}, - wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, - }, - { - name: "process probe failed -> detecting", - in: ProbeInput{RuntimeAlive: true, ProcessFailed: true, Now: t0}, - wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, - }, - { - name: "runtime alive + process alive -> working", - in: ProbeInput{RuntimeAlive: true, Process: ProcessAlive, Now: t0}, - wantState: domain.SessionWorking, wantAlive: true, - }, - { - name: "runtime alive + process indeterminate -> working", - in: ProbeInput{RuntimeAlive: true, Process: ProcessIndeterminate, Now: t0}, - wantState: domain.SessionWorking, wantAlive: true, - }, - { - name: "runtime alive + process dead -> detecting (disagree)", - in: ProbeInput{RuntimeAlive: true, Process: ProcessDead, Now: t0}, - wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, - }, - { - name: "runtime down + process dead + no activity -> terminated runtime_lost", - in: ProbeInput{RuntimeAlive: false, Process: ProcessDead, RecentActivity: false, Now: t0}, - wantState: domain.SessionTerminated, wantReason: domain.TermRuntimeLost, wantAlive: false, - }, - { - name: "runtime down + process alive -> detecting (disagree)", - in: ProbeInput{RuntimeAlive: false, Process: ProcessAlive, Now: t0}, - wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, - }, - { - name: "runtime down + process dead + recent activity -> detecting", - in: ProbeInput{RuntimeAlive: false, Process: ProcessDead, RecentActivity: true, Now: t0}, - wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, - }, - { - name: "runtime down + process indeterminate -> detecting", - in: ProbeInput{RuntimeAlive: false, Process: ProcessIndeterminate, Now: t0}, - wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d := ResolveProbeDecision(tt.in) - if d.SessionState != tt.wantState { - t.Errorf("state = %q, want %q", d.SessionState, tt.wantState) - } - if d.TerminationReason != tt.wantReason { - t.Errorf("reason = %q, want %q", d.TerminationReason, tt.wantReason) - } - if d.IsAlive != tt.wantAlive { - t.Errorf("isAlive = %v, want %v", d.IsAlive, tt.wantAlive) - } - if tt.wantDetect && d.Detecting == nil { - t.Errorf("expected detecting memory, got nil") - } - }) - } -} - -func TestCreateDetectingDecision(t *testing.T) { - t.Run("first entry sets attempts 1", func(t *testing.T) { - d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Now: t0}) - if d.SessionState != domain.SessionDetecting || d.Detecting == nil || d.Detecting.Attempts != 1 { - t.Fatalf("got %+v", d) - } - }) - t.Run("same evidence climbs the counter", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} - d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(time.Second)}) - if d.Detecting == nil || d.Detecting.Attempts != 2 { - t.Fatalf("attempts = %+v, want 2", d.Detecting) - } - }) - t.Run("changed evidence resets the counter", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: 2, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} - d := CreateDetectingDecision(DetectingInput{Evidence: "process dead", Prior: prior, Now: t0.Add(time.Second)}) - if d.Detecting == nil || d.Detecting.Attempts != 1 { - t.Fatalf("attempts = %+v, want 1 (evidence changed)", d.Detecting) - } - }) - t.Run("escalates to stuck at the attempt cap", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} - d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(time.Second)}) - if d.SessionState != domain.SessionStuck { - t.Fatalf("state = %q, want stuck", d.SessionState) - } - }) - t.Run("escalates to stuck past the duration cap", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} - d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(DetectingMaxDuration + time.Second)}) - if d.SessionState != domain.SessionStuck { - t.Fatalf("state = %q, want stuck (duration cap)", d.SessionState) - } - }) -} - -func TestProbeDetectingEscalationFlow(t *testing.T) { - in := ProbeInput{RuntimeAlive: false, Process: ProcessIndeterminate, Now: t0} - var prior *domain.DetectingState - for i := 1; i < DetectingMaxAttempts; i++ { - in.Prior = prior - in.Now = t0.Add(time.Duration(i) * time.Second) - d := ResolveProbeDecision(in) - if d.SessionState != domain.SessionDetecting { - t.Fatalf("attempt %d: state = %q, want detecting", i, d.SessionState) - } - prior = d.Detecting - } - in.Prior = prior - in.Now = t0.Add(time.Hour) - if d := ResolveProbeDecision(in); d.SessionState != domain.SessionStuck { - t.Fatalf("final attempt: state = %q, want stuck", d.SessionState) - } -} - -func TestHashEvidence(t *testing.T) { - // timestamp-only differences hash equal; a real change differs. - a := HashEvidence("runtime down at 2026-05-31T12:00:00Z") - b := HashEvidence("runtime down at 2026-05-31T13:30:45Z") - if a != b { - t.Errorf("restamped evidence should hash equal") - } - c := HashEvidence("process dead at 2026-05-31T12:00:00Z") - if a == c { - t.Errorf("different evidence should hash differently") - } -} diff --git a/backend/internal/domain/decide/types.go b/backend/internal/domain/decide/types.go deleted file mode 100644 index 2e9a5c8437..0000000000 --- a/backend/internal/domain/decide/types.go +++ /dev/null @@ -1,58 +0,0 @@ -package decide - -import ( - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// LifecycleDecision is the output of a decider: the canonical session sub-state -// to persist (state, the liveness bool, and — only for a terminal state — the -// termination reason), the human-readable evidence, and the (possibly updated) -// detecting memory. The display status is NOT here — it is derived on read by -// domain.DeriveStatus from the persisted lifecycle plus the pr table. -// -// PR facts are likewise not here: a liveness verdict knows nothing about the PR, -// and PR-driven display/reactions are handled off the pr table, not the session -// state machine. -type LifecycleDecision struct { - Evidence string - Detecting *domain.DetectingState - SessionState domain.SessionState - TerminationReason domain.TerminationReason // set only when SessionState is terminated - IsAlive bool -} - -// ProbeInput reconciles runtime + process liveness. A *failed* probe (timeout or -// error) is distinct from a "dead" verdict and must route to detecting, never to -// a death conclusion. KillRequested short-circuits to terminal with KillReason. -type ProbeInput struct { - RuntimeAlive bool // the runtime probe reports the backing runtime is up - RuntimeFailed bool // the runtime probe itself failed (timeout/error) — not "dead" - Process ProcessLiveness - ProcessFailed bool - RecentActivity bool - KillRequested bool - KillReason domain.TerminationReason // the terminal reason when KillRequested - Prior *domain.DetectingState - Now time.Time -} - -// ProcessLiveness mirrors isProcessRunning's three-valued answer. -type ProcessLiveness string - -// Process liveness readings. -const ( - ProcessAlive ProcessLiveness = "alive" - ProcessDead ProcessLiveness = "dead" - ProcessIndeterminate ProcessLiveness = "indeterminate" -) - -// DetectingInput feeds the anti-flap quarantine counter. Evidence is hashed with -// timestamps stripped, so "same ambiguous signal" keeps the counter climbing -// while any real change resets it. -type DetectingInput struct { - Evidence string - Prior *domain.DetectingState - Now time.Time -} diff --git a/backend/internal/domain/doc.go b/backend/internal/domain/doc.go new file mode 100644 index 0000000000..60e53a81a2 --- /dev/null +++ b/backend/internal/domain/doc.go @@ -0,0 +1,5 @@ +// Package domain holds shared vocabulary for sessions, activity, and PR facts. +// Session state is deliberately small: durable session rows carry activity_state +// plus an is_terminated bit; user-facing status is derived from those fields and +// PR facts at read time. +package domain diff --git a/backend/internal/domain/harness.go b/backend/internal/domain/harness.go new file mode 100644 index 0000000000..90d8617192 --- /dev/null +++ b/backend/internal/domain/harness.go @@ -0,0 +1,12 @@ +package domain + +// AgentHarness identifies which agent CLI/runtime a session drives. +type AgentHarness string + +// Supported agent harnesses. +const ( + HarnessClaudeCode AgentHarness = "claude-code" + HarnessCodex AgentHarness = "codex" + HarnessAider AgentHarness = "aider" + HarnessOpenCode AgentHarness = "opencode" +) diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go deleted file mode 100644 index 155c099949..0000000000 --- a/backend/internal/domain/lifecycle.go +++ /dev/null @@ -1,209 +0,0 @@ -// Package domain holds the shared contract types for the LCM + Session Manager -// lane: the canonical session state model, the derived display status, and the -// session read-model. It has no behaviour beyond pure derivation (status.go) -// and imports nothing outside the standard library, so every other package can -// depend on it without creating cycles. -package domain - -import "time" - -// LifecycleVersion is the schema version stamped onto every persisted record. -// Greenfield: we start at 1 and carry no migration/synthesis code. -const LifecycleVersion = 1 - -// CanonicalSessionLifecycle is the ONLY lifecycle state persisted for a session. -// The display status is derived from it (plus the session's PR facts, which live -// in the separate pr table) on read — see DeriveStatus — and is never stored, so -// canonical truth and display cannot drift. -// -// PR facts are deliberately NOT here: a session can own several PRs over its -// life, and PR state is owned by the pr table. The runtime axis is collapsed to -// a single IsAlive boolean. Activity and Detecting are decider *inputs* that -// must survive between observations, so they live in the persisted record. -type CanonicalSessionLifecycle struct { - // Version is the Go-only schema-shape constant for this record. It is not - // persisted and is not part of the CDC payload. - Version int - - Session SessionSubstate `json:"session"` - Activity ActivitySubstate `json:"activity"` - - // TerminationReason is set only when Session.State is terminated; '' otherwise. - TerminationReason TerminationReason `json:"terminationReason,omitempty"` - - // IsAlive is the single liveness fact: is the runtime/process backing this - // session still up? It replaces the old runtime (state, reason) axis — the - // nuance the probe decider needs (failed-probe != dead, anti-flap) lives in - // the decide core's inputs, not in a persisted enum. - IsAlive bool `json:"isAlive"` - - // Harness is the agent harness the session runs (claude-code, codex, ...). - Harness AgentHarness `json:"harness,omitempty"` - - // Detecting is the anti-flap quarantine memory. It is non-nil only while - // the session is in the detecting state; it carries the attempt counter, - // the first-entry time, and a hash of the (timestamp-stripped) evidence so - // the decider can tell "same ambiguous signal N times" from "signal moved". - Detecting *DetectingState `json:"detecting,omitempty"` -} - -// ---- agent harness ---- - -// AgentHarness identifies which agent CLI/runtime a session drives. -type AgentHarness string - -// Supported agent harnesses. -const ( - HarnessClaudeCode AgentHarness = "claude-code" - HarnessCodex AgentHarness = "codex" - HarnessAider AgentHarness = "aider" - HarnessOpenCode AgentHarness = "opencode" -) - -// ---- session sub-state ---- - -// SessionState is the canonical lifecycle phase of a session. -type SessionState string - -// The canonical session states (see the package doc for the transition model). -const ( - SessionNotStarted SessionState = "not_started" - SessionWorking SessionState = "working" - SessionIdle SessionState = "idle" - SessionNeedsInput SessionState = "needs_input" - SessionStuck SessionState = "stuck" - SessionDetecting SessionState = "detecting" - SessionDone SessionState = "done" - SessionTerminated SessionState = "terminated" -) - -// TerminationReason is the typed "why" for a terminated session — the only -// state that carries a reason. Empty for every non-terminal state. It decides -// the terminal display status (killed / cleanup / errored). The PR-pipeline -// "why" (fixing CI, awaiting review, …) is NOT here; it is derived on read from -// the pr table, not persisted on the session. -type TerminationReason string - -// Termination reasons; TermNone is the non-terminal zero value. -const ( - TermNone TerminationReason = "" - TermManuallyKilled TerminationReason = "manually_killed" - TermRuntimeLost TerminationReason = "runtime_lost" - TermAgentProcessExited TerminationReason = "agent_process_exited" - TermProbeFailure TerminationReason = "probe_failure" - TermErrorInProcess TerminationReason = "error_in_process" - TermAutoCleanup TerminationReason = "auto_cleanup" - TermPRMerged TerminationReason = "pr_merged" -) - -// SessionSubstate wraps the session phase in a struct so the persisted/CDC JSON -// shape can gain fields without a migration. -type SessionSubstate struct { - State SessionState `json:"state"` -} - -// ---- PR facts (NOT persisted on the session; sourced from the pr table) ---- - -// PRFacts is the per-session PR snapshot the status/reaction derivation reads -// from the pr table. It is the decider input that replaces the old persisted PR -// axis. The zero value (Exists=false) means "no PR", which derivation treats as -// "session has no PR". -type PRFacts struct { - URL string - Number int - Exists bool - Draft bool - Merged bool - Closed bool - CI CIState - Review ReviewDecision - Mergeability Mergeability - ReviewComments bool // has unresolved review comments (any author) to address -} - -// CIState is the aggregate CI status of a PR. -type CIState string - -// CI states. -const ( - CIUnknown CIState = "unknown" - CIPending CIState = "pending" - CIPassing CIState = "passing" - CIFailing CIState = "failing" -) - -// ReviewDecision is the aggregate human-review verdict on a PR. -type ReviewDecision string - -// Review decisions. -const ( - ReviewNone ReviewDecision = "none" - ReviewApproved ReviewDecision = "approved" - ReviewChangesRequest ReviewDecision = "changes_requested" - ReviewRequired ReviewDecision = "review_required" -) - -// Mergeability is whether a PR can currently be merged. -type Mergeability string - -// Mergeability states. -const ( - MergeUnknown Mergeability = "unknown" - MergeMergeable Mergeability = "mergeable" - MergeConflicting Mergeability = "conflicting" - MergeBlocked Mergeability = "blocked" - MergeUnstable Mergeability = "unstable" -) - -// ---- activity sub-state (decider input) ---- - -// ActivityState is how busy the agent is, derived from its output/JSONL. -type ActivityState string - -// Activity states. WaitingInput and Blocked are sticky (see IsSticky). -const ( - ActivityActive ActivityState = "active" - ActivityReady ActivityState = "ready" - ActivityIdle ActivityState = "idle" - ActivityWaitingInput ActivityState = "waiting_input" // sticky: does not decay by wallclock - ActivityBlocked ActivityState = "blocked" // sticky: does not decay by wallclock - ActivityExited ActivityState = "exited" -) - -// IsSticky reports whether an activity state must NOT be aged/demoted by the -// passage of time (a paused agent is still paused until a new signal says so). -func (a ActivityState) IsSticky() bool { - return a == ActivityWaitingInput || a == ActivityBlocked -} - -// ActivitySource records where an activity reading came from, so a weaker -// source can't override a stronger one. -type ActivitySource string - -// Activity signal sources, strongest first. -const ( - SourceNative ActivitySource = "native" - SourceTerminal ActivitySource = "terminal" - SourceHook ActivitySource = "hook" - SourceRuntime ActivitySource = "runtime" - SourceNone ActivitySource = "none" -) - -// ActivitySubstate is the persisted activity reading: the state, when it was -// last observed, and which source reported it. -type ActivitySubstate struct { - State ActivityState `json:"state"` - LastActivityAt time.Time `json:"lastActivityAt"` - Source ActivitySource `json:"source"` -} - -// ---- detecting quarantine memory (decider input) ---- - -// DetectingState is the anti-flap quarantine memory carried while a session is -// detecting: how many ambiguous observations, since when, and a hash of the -// (timestamp-stripped) evidence to tell "same signal again" from "signal moved". -type DetectingState struct { - Attempts int `json:"attempts"` - StartedAt time.Time `json:"startedAt"` - EvidenceHash string `json:"evidenceHash"` -} diff --git a/backend/internal/domain/notification.go b/backend/internal/domain/notification.go deleted file mode 100644 index 8c64c9bcde..0000000000 --- a/backend/internal/domain/notification.go +++ /dev/null @@ -1,44 +0,0 @@ -package domain - -import ( - "encoding/json" - "time" -) - -// NotificationID is the stable public identifier for a persisted notification. -type NotificationID string - -// Notification is the provider-neutral durable notification read model. It is -// sink-agnostic: desktop, dashboard, Slack, webhooks, etc. all consume the same -// semantic payload and actions later. -type Notification struct { - Seq int64 - ID NotificationID - ProjectID ProjectID - SessionID SessionID - Source string - EventType string - SemanticType string - Priority string - Message string - Payload json.RawMessage - Actions []NotificationAction - DedupeKey string - CauseKey string - ReadAt time.Time - ArchivedAt time.Time - CreatedAt time.Time - UpdatedAt time.Time -} - -// NotificationAction is a provider-neutral action descriptor. Renderers may use -// Route for app-local navigation, URL for external navigation, or Method for a -// future command/action endpoint. -type NotificationAction struct { - ID string `json:"id"` - Kind string `json:"kind"` - Label string `json:"label"` - Route string `json:"route,omitempty"` - URL string `json:"url,omitempty"` - Method string `json:"method,omitempty"` -} diff --git a/backend/internal/domain/pr.go b/backend/internal/domain/pr.go index a31b9958a3..8d1c2451fb 100644 --- a/backend/internal/domain/pr.go +++ b/backend/internal/domain/pr.go @@ -2,17 +2,28 @@ package domain import "time" -// The PR rows are the canonical shapes for the pr / pr_checks / pr_comment -// tables, shared by the PRWriter port and the sqlite store (the store maps them -// to/from the sqlc gen.* models). They are flat by design — these tables carry -// no nesting or derivation, so a single definition serves every layer. - -// PRRow is the scalar facts of one tracked pull request (the pr table). A session -// can own several PRs; a PR belongs to one session. PRFacts is the read-model -// derived from these for display status; PRRow is what gets written. -type PRRow struct { +// ---- PR read model ---- + +// PRFacts is the per-session PR snapshot the status derivation reads from the +// pr table. +type PRFacts struct { + URL string + Number int + Draft bool + Merged bool + Closed bool + CI CIState + Review ReviewDecision + Mergeability Mergeability + ReviewComments bool // has unresolved review comments (any author) to address +} + +// PullRequest is the app-level representation of one tracked pull request as +// persisted by the PR store. It is intentionally separate from the sqlc +// generated sqlite row type so storage details do not leak outside sqlite. +type PullRequest struct { URL string - SessionID string + SessionID SessionID Number int Draft bool Merged bool @@ -23,20 +34,18 @@ type PRRow struct { UpdatedAt time.Time } -// PRCheckRow is one CI check run — one row per check name per commit. -type PRCheckRow struct { - PRURL string +// PullRequestCheck is one normalized CI check run for a pull request. +type PullRequestCheck struct { Name string CommitHash string - Status string + Status PRCheckStatus URL string LogTail string CreatedAt time.Time } -// PRComment is one review comment. Feedback is injected into the agent -// regardless of author, so there is no bot/human distinction. -type PRComment struct { +// PullRequestComment is one normalized review comment for a pull request. +type PullRequestComment struct { ID string Author string File string @@ -45,3 +54,63 @@ type PRComment struct { Resolved bool CreatedAt time.Time } + +// CIState is the aggregate CI status of a PR. +type CIState string + +// CI states. +const ( + CIUnknown CIState = "unknown" + CIPending CIState = "pending" + CIPassing CIState = "passing" + CIFailing CIState = "failing" +) + +// ReviewDecision is the aggregate human-review verdict on a PR. +type ReviewDecision string + +// Review decisions. +const ( + ReviewNone ReviewDecision = "none" + ReviewApproved ReviewDecision = "approved" + ReviewChangesRequest ReviewDecision = "changes_requested" + ReviewRequired ReviewDecision = "review_required" +) + +// Mergeability is whether a PR can currently be merged. +type Mergeability string + +// Mergeability states. +const ( + MergeUnknown Mergeability = "unknown" + MergeMergeable Mergeability = "mergeable" + MergeConflicting Mergeability = "conflicting" + MergeBlocked Mergeability = "blocked" + MergeUnstable Mergeability = "unstable" +) + +// PRState is the normalized lifecycle of one tracked pull request as stored in +// the pr table. +type PRState string + +// PR states. +const ( + PRStateDraft PRState = "draft" + PRStateOpen PRState = "open" + PRStateMerged PRState = "merged" + PRStateClosed PRState = "closed" +) + +// PRCheckStatus is one CI check run's normalized status. +type PRCheckStatus string + +// PR check statuses. +const ( + PRCheckUnknown PRCheckStatus = "unknown" + PRCheckQueued PRCheckStatus = "queued" + PRCheckInProgress PRCheckStatus = "in_progress" + PRCheckPassed PRCheckStatus = "passed" + PRCheckFailed PRCheckStatus = "failed" + PRCheckSkipped PRCheckStatus = "skipped" + PRCheckCancelled PRCheckStatus = "cancelled" +) diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index 4d436e2aea..76e799fb29 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -22,50 +22,34 @@ const ( KindOrchestrator SessionKind = "orchestrator" ) -// SessionMetadata is the typed, off-canonical metadata for a session: the -// operational handles and seed inputs the Session Manager and reaper need but -// that are NOT part of the canonical lifecycle. The set of fields is fixed here -// (no free-form keys), so what a session can carry is a compile-time fact, and -// it is folded into the sessions row off the CDC path. -// -// Empty fields mean "unset": the LCM merges metadata without overwriting a -// stored value with an empty one, so a partial write (spawn setting only the -// runtime handle) does not clobber a value set earlier (the branch at creation). +// SessionMetadata is the typed, off-status metadata for a session: operational +// handles and seed inputs used by Session Manager and reaper. type SessionMetadata struct { Branch string `json:"branch,omitempty"` WorkspacePath string `json:"workspacePath,omitempty"` RuntimeHandleID string `json:"runtimeHandleId,omitempty"` - RuntimeName string `json:"runtimeName,omitempty"` AgentSessionID string `json:"agentSessionId,omitempty"` Prompt string `json:"prompt,omitempty"` } -// IsZero reports whether no metadata field is set. -func (m SessionMetadata) IsZero() bool { return m == SessionMetadata{} } - -// SessionRecord is the PERSISTENCE shape: identity, canonical lifecycle, and -// metadata — everything the store holds, and nothing derived. The store reads -// and writes records; it never produces the derived display status. -// -// Metadata is json:"-" on purpose: it lives off the canonical path, so it must -// never ride along in the change_log / snapshot payloads. Enforcing that at the -// type level means no caller has to remember to scrub it before marshalling. +// SessionRecord is the persistence shape. It intentionally stores only durable +// facts: identity, agent harness, activity_state, is_terminated, and operational +// metadata. The user-facing Status is derived from these facts plus PR facts. type SessionRecord struct { - ID SessionID `json:"id"` - ProjectID ProjectID `json:"projectId"` - IssueID IssueID `json:"issueId,omitempty"` - Kind SessionKind `json:"kind"` - Lifecycle CanonicalSessionLifecycle `json:"lifecycle"` - Metadata SessionMetadata `json:"-"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID SessionID `json:"id"` + ProjectID ProjectID `json:"projectId"` + IssueID IssueID `json:"issueId,omitempty"` + Kind SessionKind `json:"kind"` + Harness AgentHarness `json:"harness,omitempty"` + Activity ActivitySubstate `json:"activity"` + IsTerminated bool `json:"isTerminated"` + Metadata SessionMetadata `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } -// Session is the read-model returned across the API boundary (to controllers, -// then the frontend): a SessionRecord plus the DERIVED display Status. The -// Session Manager is the single producer of Status — it builds a Session from a -// stored SessionRecord by calling DeriveLegacyStatus, so the store and API -// never recompute (or accidentally persist) it. +// Session is the read-model returned across the API boundary: a SessionRecord +// plus the derived display Status. type Session struct { SessionRecord Status SessionStatus `json:"status"` diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index 5fa0f72160..d02ddcb37e 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -1,15 +1,12 @@ package domain // SessionStatus is the single-word DISPLAY status the dashboard renders. It is -// derived from the canonical lifecycle (plus the session's PR facts) on read and -// never persisted. +// derived from persisted session facts plus PR facts and is never stored. type SessionStatus string // The display statuses the dashboard renders. const ( - StatusSpawning SessionStatus = "spawning" StatusWorking SessionStatus = "working" - StatusDetecting SessionStatus = "detecting" StatusPROpen SessionStatus = "pr_open" StatusDraft SessionStatus = "draft" StatusCIFailed SessionStatus = "ci_failed" @@ -18,78 +15,45 @@ const ( StatusApproved SessionStatus = "approved" StatusMergeable SessionStatus = "mergeable" StatusMerged SessionStatus = "merged" - StatusCleanup SessionStatus = "cleanup" StatusNeedsInput SessionStatus = "needs_input" StatusStuck SessionStatus = "stuck" - StatusErrored SessionStatus = "errored" - StatusKilled SessionStatus = "killed" StatusIdle SessionStatus = "idle" - StatusDone SessionStatus = "done" StatusTerminated SessionStatus = "terminated" ) -// DeriveStatus is the ONLY producer of the display status. It is a pure, total -// function of the canonical record plus the session's PR facts (read from the pr -// table by the caller, since PR state is no longer persisted on the session). -// -// Order matters: -// 1. Terminal / hard session states (done, terminated, needs_input, stuck, -// detecting, not_started) map directly — these OUTRANK PR facts. -// 2. Otherwise, if the session has a PR: a merged PR wins, else the PR pipeline -// ladder (CI failure dominates, then draft/review/merge states). -// 3. Otherwise fall through to the SOFT session state (idle/working). -// -// So "PR facts dominate session facts" applies only to the soft states: an idle -// or working session with an open, CI-failing PR displays as ci_failed — but a -// session that is stuck or needs_input shows that regardless, since it needs a -// human either way. -func DeriveStatus(l CanonicalSessionLifecycle, pr PRFacts) SessionStatus { - switch l.Session.State { - case SessionDone: - return StatusDone - case SessionTerminated: - return terminatedStatus(l.TerminationReason) - case SessionNeedsInput: +// DeriveStatus is the ONLY producer of display status. It is a pure function of +// persisted session facts and PR facts: is_terminated, activity_state, and the PR +// table are the durable facts that tell the UI what it needs to know. +func DeriveStatus(rec SessionRecord, pr *PRFacts) SessionStatus { + if rec.IsTerminated { + if pr != nil && pr.Merged { + return StatusMerged + } + return StatusTerminated + } + + switch rec.Activity.State { + case ActivityWaitingInput: return StatusNeedsInput - case SessionStuck: + case ActivityBlocked: return StatusStuck - case SessionDetecting: - return StatusDetecting - case SessionNotStarted: - return StatusSpawning } - if pr.Exists { + if pr != nil { if pr.Merged { return StatusMerged } if !pr.Closed { - return prPipelineStatus(pr) + return prPipelineStatus(*pr) } } - if l.Session.State == SessionIdle { - return StatusIdle - } - return StatusWorking -} - -func terminatedStatus(r TerminationReason) SessionStatus { - switch r { - case TermManuallyKilled, TermRuntimeLost, TermAgentProcessExited: - return StatusKilled - case TermAutoCleanup, TermPRMerged: - return StatusCleanup - case TermErrorInProcess, TermProbeFailure: - return StatusErrored - default: - return StatusTerminated + if rec.Activity.State == ActivityActive { + return StatusWorking } + return StatusIdle } -// prPipelineStatus maps an open/draft PR's facts to a display status, preserving -// the old ladder: CI failure dominates everything, then draft, then the review / -// merge states. func prPipelineStatus(pr PRFacts) SessionStatus { switch { case pr.CI == CIFailing: diff --git a/backend/internal/domain/status_test.go b/backend/internal/domain/status_test.go index 57512577c1..7bd02dbf3b 100644 --- a/backend/internal/domain/status_test.go +++ b/backend/internal/domain/status_test.go @@ -2,58 +2,37 @@ package domain import "testing" -func TestDeriveStatus(t *testing.T) { - // sess builds a non-terminal lifecycle (no reason). - sess := func(s SessionState) CanonicalSessionLifecycle { - return CanonicalSessionLifecycle{Session: SessionSubstate{State: s}} - } - // term builds a terminated lifecycle carrying a TerminationReason. - term := func(r TerminationReason) CanonicalSessionLifecycle { - return CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated}, TerminationReason: r} - } - openPR := func(mut func(*PRFacts)) PRFacts { - f := PRFacts{Exists: true, CI: CIUnknown, Review: ReviewNone, Mergeability: MergeUnknown} - if mut != nil { - mut(&f) - } - return f - } +func rec(activity ActivityState, terminated bool) SessionRecord { + return SessionRecord{Activity: ActivitySubstate{State: activity}, IsTerminated: terminated} +} +func pr(facts PRFacts) *PRFacts { return &facts } + +func TestDeriveStatusFromSessionFactsAndPR(t *testing.T) { tests := []struct { name string - in CanonicalSessionLifecycle - pr PRFacts + rec SessionRecord + pr *PRFacts want SessionStatus }{ - {"not_started maps to spawning", sess(SessionNotStarted), PRFacts{}, StatusSpawning}, - {"terminated+manually_killed -> killed", term(TermManuallyKilled), PRFacts{}, StatusKilled}, - {"terminated+runtime_lost -> killed", term(TermRuntimeLost), PRFacts{}, StatusKilled}, - {"terminated+auto_cleanup -> cleanup", term(TermAutoCleanup), PRFacts{}, StatusCleanup}, - {"terminated+pr_merged -> cleanup", term(TermPRMerged), PRFacts{}, StatusCleanup}, - {"terminated+error -> errored", term(TermErrorInProcess), PRFacts{}, StatusErrored}, - {"needs_input maps directly", sess(SessionNeedsInput), PRFacts{}, StatusNeedsInput}, - {"stuck dominates any PR", sess(SessionStuck), openPR(func(f *PRFacts) { f.CI = CIFailing }), StatusStuck}, - - {"no PR + idle -> idle", sess(SessionIdle), PRFacts{}, StatusIdle}, - {"no PR + working -> working", sess(SessionWorking), PRFacts{}, StatusWorking}, - - {"merged PR dominates idle session", sess(SessionIdle), PRFacts{Exists: true, Merged: true}, StatusMerged}, - {"open PR failing CI -> ci_failed", sess(SessionIdle), openPR(func(f *PRFacts) { f.CI = CIFailing }), StatusCIFailed}, - {"draft PR failing CI -> ci_failed (CI dominates)", sess(SessionWorking), openPR(func(f *PRFacts) { f.Draft = true; f.CI = CIFailing }), StatusCIFailed}, - {"draft PR ignores review state -> draft", sess(SessionWorking), openPR(func(f *PRFacts) { f.Draft = true; f.Review = ReviewApproved }), StatusDraft}, - {"open PR changes_requested", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewChangesRequest }), StatusChangesRequested}, - {"open PR review comments -> changes_requested", sess(SessionWorking), openPR(func(f *PRFacts) { f.ReviewComments = true }), StatusChangesRequested}, - {"open PR mergeable", sess(SessionWorking), openPR(func(f *PRFacts) { f.Mergeability = MergeMergeable }), StatusMergeable}, - {"open PR approved", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewApproved }), StatusApproved}, - {"open PR review required -> review_pending", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewRequired }), StatusReviewPending}, - {"open PR no signal -> pr_open", sess(SessionWorking), openPR(nil), StatusPROpen}, - {"closed PR falls through to soft state", sess(SessionIdle), PRFacts{Exists: true, Closed: true}, StatusIdle}, + {"terminated", rec(ActivityExited, true), nil, StatusTerminated}, + {"merged-pr", rec(ActivityIdle, true), pr(PRFacts{Merged: true}), StatusMerged}, + {"needs-input", rec(ActivityWaitingInput, false), pr(PRFacts{CI: CIFailing}), StatusNeedsInput}, + {"blocked", rec(ActivityBlocked, false), pr(PRFacts{CI: CIFailing}), StatusStuck}, + {"ci-failed", rec(ActivityIdle, false), pr(PRFacts{CI: CIFailing}), StatusCIFailed}, + {"draft", rec(ActivityIdle, false), pr(PRFacts{Draft: true}), StatusDraft}, + {"changes-requested", rec(ActivityIdle, false), pr(PRFacts{Review: ReviewChangesRequest}), StatusChangesRequested}, + {"mergeable", rec(ActivityIdle, false), pr(PRFacts{Mergeability: MergeMergeable}), StatusMergeable}, + {"approved", rec(ActivityIdle, false), pr(PRFacts{Review: ReviewApproved}), StatusApproved}, + {"review-pending", rec(ActivityIdle, false), pr(PRFacts{Review: ReviewRequired}), StatusReviewPending}, + {"pr-open", rec(ActivityIdle, false), pr(PRFacts{}), StatusPROpen}, + {"working", rec(ActivityActive, false), nil, StatusWorking}, + {"idle", rec(ActivityIdle, false), nil, StatusIdle}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := DeriveStatus(tt.in, tt.pr); got != tt.want { - t.Errorf("DeriveStatus() = %q, want %q", got, tt.want) + if got := DeriveStatus(tt.rec, tt.pr); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) } }) } diff --git a/backend/internal/domain/tracker.go b/backend/internal/domain/tracker.go index c5f2226292..fde1631b36 100644 --- a/backend/internal/domain/tracker.go +++ b/backend/internal/domain/tracker.go @@ -1,23 +1,13 @@ package domain // TrackerProvider identifies an issue-tracker provider implementation. -// Provider differences (label-driven vs state-machine vs close-reason) are -// absorbed inside each adapter; the rest of the system only sees -// NormalizedIssueState. type TrackerProvider string -// Supported tracker providers. -const ( - TrackerProviderGitHub TrackerProvider = "github" - TrackerProviderGitLab TrackerProvider = "gitlab" - TrackerProviderLinear TrackerProvider = "linear" -) +// TrackerProviderGitHub is the only supported issue-tracker provider. +const TrackerProviderGitHub TrackerProvider = "github" -// TrackerID identifies a single issue across providers. Native is the -// provider's own canonical form ("owner/repo#123" for GitHub, -// "group/project#456" for GitLab, "ABC-789" for Linear) and is parsed by the -// adapter. Provider is the discriminator the Session Manager uses to pick an -// adapter. +// TrackerID identifies one issue. Native is the provider's own canonical form +// ("owner/repo#123" for GitHub) and is parsed by the adapter. type TrackerID struct { Provider TrackerProvider `json:"provider"` Native string `json:"native"` @@ -37,9 +27,8 @@ const ( IssueCancelled NormalizedIssueState = "cancelled" ) -// Issue is the minimum projection every tracker can produce. Fields are -// added only when all v1 providers (GitHub, GitLab, Linear) can populate -// them faithfully; richer metadata stays inside provider-specific code paths. +// Issue is the minimum projection every tracker can produce. Provider-specific +// metadata stays inside provider-specific code paths. type Issue struct { ID TrackerID `json:"id"` Title string `json:"title"` @@ -50,11 +39,9 @@ type Issue struct { Assignees []string `json:"assignees,omitempty"` } -// TrackerRepo identifies a repository (or its provider-equivalent) for -// cross-issue queries like Tracker.List. Native is the provider's canonical -// owner/project form: "owner/repo" for GitHub, "group/project" for GitLab. -// Linear has no native repo concept; adapters may use a team or workspace -// identifier in Native when this port reaches Linear. +// TrackerRepo identifies a repository for cross-issue queries like Tracker.List. +// Native is the provider's canonical owner/project form, e.g. "owner/repo" for +// GitHub. type TrackerRepo struct { Provider TrackerProvider `json:"provider"` Native string `json:"native"` @@ -77,12 +64,8 @@ const ( // ListFilter is the query the Session Manager passes to Tracker.List. // Empty / zero values mean "no filter on this dimension". // -// Limit is the requested page size. The adapter applies its own default -// when zero and SILENTLY CAPS at the provider's per-page maximum — a -// caller asking for more than the cap gets exactly cap items back with no -// error and no indication of truncation. v1 has no auto-pagination; -// callers needing more results need to wait for the observer/polling work -// in issue #35. +// Limit is the requested page size. The adapter applies its own default when +// zero and caps at the provider's per-page maximum. type ListFilter struct { State ListStateFilter `json:"state,omitempty"` Labels []string `json:"labels,omitempty"` diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 124a8d788f..9480cdad4c 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -9,27 +9,20 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" "github.com/aoagents/agent-orchestrator/backend/internal/project" ) -// APIDeps bundles every Manager the API layer's controllers depend on. There -// is exactly one Manager per resource, defined in that resource's own package -// (project.Manager, later session.Manager, ...), and the controllers see ONLY -// that interface — they don't reach past it to the LCM, adapters, or stores. -// Whether a Manager impl talks to the registry, the LCM, or an outbound port -// is its own concern. -// -// The route-shell PR (#20) leaves every field nil — handlers answer via -// apispec.NotImplemented and don't dereference them yet. The handler-impl PR -// wires real Managers and flips stubs to real logic one route at a time. +// APIDeps bundles every Manager the API layer's controllers depend on. +// Controllers see only resource-level interfaces; they do not reach through to +// lifecycle reducers, adapters, or storage. A nil dependency keeps its routes +// registered but returns the OpenAPI-backed 501 response. type APIDeps struct { Projects project.Manager } // API owns one controller per resource and is the single Register call the -// router invokes to mount the /api/v1 surface. Splitting per-resource means -// later PRs can land a controller's real handlers without touching the -// surrounding wiring. +// router invokes to mount the /api/v1 surface. type API struct { cfg config.Config projects *controllers.ProjectsController @@ -47,13 +40,8 @@ func NewAPI(cfg config.Config, deps APIDeps) *API { } } -// Register mounts the API surface on root. /api/v1 hosts the REST group with -// the per-request Timeout that the skeleton router (router.go) deliberately -// kept off the global stack — REST routes are bounded, but long-lived surfaces -// (/events SSE, /mux WS) live outside this group when they land. -// -// /mux is mounted outside /api/v1 for parity with the legacy TS surface; it is -// a phase-4 placeholder and stays unregistered here until that lane starts. +// Register mounts the bounded /api/v1 REST surface. Long-lived surfaces such +// as muxed terminal streams stay outside this timeout group. func (a *API) Register(root chi.Router) { timeout := a.cfg.RequestTimeout if timeout <= 0 { @@ -61,20 +49,15 @@ func (a *API) Register(root chi.Router) { } root.Route("/api/v1", func(r chi.Router) { - // The OpenAPI document is the source of truth for every contract on - // this surface; serve it so tooling (SDK generators, the OpenAPI - // validator in #19, the dashboard's developer tools) can fetch the - // whole spec from the same origin as the routes it describes. - apispec.RegisterServe(r, "/openapi.yaml") + // Serve the OpenAPI document from the same origin as the routes it describes. + r.Get("/openapi.yaml", apispec.ServeYAML) r.Group(func(r chi.Router) { r.Use(middleware.Timeout(timeout)) a.projects.Register(r) - // Sibling controllers (sessions, issues, prs, ...) plug in here in - // follow-up PRs #21 / #22 without touching the timeout group. + // Sibling REST controllers plug in here. }) - // Surfaces that intentionally bypass the REST timeout (SSE, future WS) - // register at this level — none exist in the route-shell PR. + // Surfaces that intentionally bypass the REST timeout register at this level. }) } @@ -82,7 +65,7 @@ func (a *API) Register(root chi.Router) { // 404 is a text/plain body; the API surface must answer JSON so consumers can // parse it uniformly. func notFoundJSON(w http.ResponseWriter, r *http.Request) { - writeAPIError(w, r, http.StatusNotFound, "not_found", "ROUTE_NOT_FOUND", + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "ROUTE_NOT_FOUND", r.Method+" "+r.URL.Path+" has no handler", nil) } @@ -90,6 +73,6 @@ func notFoundJSON(w http.ResponseWriter, r *http.Request) { // known path without a matching verb (e.g. PUT /projects/{id} after we drop // the legacy PUT alias). func methodNotAllowedJSON(w http.ResponseWriter, r *http.Request) { - writeAPIError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED", + envelope.WriteAPIError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED", r.Method+" not allowed on "+r.URL.Path, nil) } diff --git a/backend/internal/httpd/apispec/apispec.go b/backend/internal/httpd/apispec/apispec.go index 627ad5dbed..2603820fa8 100644 --- a/backend/internal/httpd/apispec/apispec.go +++ b/backend/internal/httpd/apispec/apispec.go @@ -16,7 +16,6 @@ import ( "strings" "sync" - "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" yaml "gopkg.in/yaml.v3" ) @@ -65,10 +64,6 @@ func New(yamlBytes []byte) (*Spec, error) { return &Spec{doc: doc}, nil } -// YAML returns the raw embedded document bytes. Used by the /openapi.yaml -// handler. -func (s *Spec) YAML() []byte { return openapiYAML } - // Operation returns the spec slice for a single (method, path) pair, ready // to be JSON-serialised. The slice is the OpenAPI Operation object (the // inner block under e.g. paths./projects.get), with parent path-level @@ -119,9 +114,7 @@ type notImplementedResponse struct { } // NotImplemented writes the locked 501 envelope, embedding the OpenAPI -// Operation slice that documents what this route WILL do. Replaces the -// throwaway PlannedRoute literals that the first cut of the route shell -// duplicated in controller code. +// Operation slice for the capability that is currently unavailable. func NotImplemented(w http.ResponseWriter, r *http.Request, method, path string) { op := Default().Operation(method, path) if op == nil { @@ -130,7 +123,7 @@ func NotImplemented(w http.ResponseWriter, r *http.Request, method, path string) body := notImplementedResponse{ Error: "not_implemented", Code: "NOT_IMPLEMENTED", - Message: method + " " + path + " is registered but not yet implemented", + Message: method + " " + path + " is unavailable in this daemon", RequestID: middleware.GetReqID(r.Context()), Spec: op, } @@ -140,18 +133,9 @@ func NotImplemented(w http.ResponseWriter, r *http.Request, method, path string) _ = json.NewEncoder(w).Encode(body) } -// ServeYAML serves the embedded openapi.yaml document. Mounted at -// /api/v1/openapi.yaml so spec-consuming tooling (#19's validator, -// SDK generators, the dashboard's developer tools) can fetch the -// whole document in one request. +// ServeYAML serves the embedded OpenAPI document for SDK generators, tests, and +// developer tooling. func ServeYAML(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/yaml; charset=utf-8") _, _ = w.Write(openapiYAML) } - -// RegisterServe mounts ServeYAML on the supplied router. Kept as a -// helper so the router code only references one symbol from apispec -// for the static serve path. -func RegisterServe(r chi.Router, path string) { - r.Get(path, ServeYAML) -} diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 2b60a3a537..970ec7f481 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1,13 +1,12 @@ openapi: 3.1.0 info: title: Agent Orchestrator HTTP daemon - version: 0.1.0-route-shell + version: 0.1.0 description: | - Loopback-only HTTP surface served by the Go daemon. This spec is the - source of truth for every route's contract — the 501 stubs in the - route-shell phase return the matching Operation slice as a `spec` - field, so consumers discover the contract by calling the endpoint - they care about. Real handlers in later PRs satisfy this same spec. + Loopback-only HTTP surface served by the Go daemon. This document describes + the registered /api/v1 project routes and the shared error envelope used by + OpenAPI-backed 501 responses. Daemon control endpoints such as /healthz, + /readyz, /shutdown, and /mux are intentionally outside this REST spec. servers: - url: http://127.0.0.1:3001 @@ -135,12 +134,6 @@ paths: schema: { $ref: "#/components/schemas/APIError" } example: { error: internal, code: PROJECT_LOAD_FAILED, message: "Failed to load project" } "501": { $ref: "#/components/responses/NotImplemented" } - x-rest-audit-notes: | - R5: degraded projects return 200 with a `status` discriminator - instead of 200 with an `error` field (as the legacy TS surface did). - Archived projects are hidden from list responses but still resolve by - id so historical sessions can keep their project_id reference. - patch: operationId: updateProjectConfig tags: [projects] @@ -180,13 +173,6 @@ paths: application/json: schema: { $ref: "#/components/schemas/APIError" } example: { error: not_implemented, code: PROJECT_CONFIG_NOT_IMPLEMENTED, message: "Project config patching is not available until config persistence is wired" } - x-rest-audit-notes: | - R3: legacy `PUT /projects/{id}` (a TS alias of PATCH) is NOT - registered. PUT returns 405 Method Not Allowed. - R6: when config persistence lands this route returns { project }, not - { ok: true }. Until then, config patches return 501 instead of - pretending to persist fields the current project store cannot store. - delete: operationId: removeProject tags: [projects] @@ -221,10 +207,6 @@ paths: summary: Repair a degraded project where automatic recovery is available x-replaces: - "POST /api/v1/projects/{id}" - x-rest-audit-notes: | - R4: this canonical path replaces the overloaded - `POST /api/v1/projects/{id}` from the legacy TS surface. - The legacy path is NOT registered; consumers must use /repair. responses: "200": description: Project repaired @@ -323,12 +305,8 @@ components: description: "\"owner/name\" or empty string when unset" defaultBranch: { type: string, default: main } agent: { type: string } - runtime: { type: string } tracker: { $ref: "#/components/schemas/TrackerConfig" } scm: { $ref: "#/components/schemas/SCMConfig" } - reactions: - type: object - additionalProperties: { $ref: "#/components/schemas/ReactionConfig" } DegradedProject: type: object @@ -373,12 +351,8 @@ components: persistence exists. properties: agent: { type: string } - runtime: { type: string } tracker: { $ref: "#/components/schemas/TrackerConfig" } scm: { $ref: "#/components/schemas/SCMConfig" } - reactions: - type: object - additionalProperties: { $ref: "#/components/schemas/ReactionConfig" } RemoveProjectResult: type: object @@ -395,9 +369,7 @@ components: projectCount: { type: integer } degradedCount: { type: integer } - # ---- Behaviour config blobs (ported from the TS Zod schemas) ---- - # These are the known config shapes only. The current Go handler does not - # preserve unknown passthrough keys until config persistence is implemented. + # ---- Behaviour config blobs ---- TrackerConfig: type: object @@ -424,23 +396,3 @@ components: eventHeader: { type: string } deliveryHeader: { type: string } maxBodyBytes: { type: integer } - - ReactionConfig: - type: object - properties: - auto: { type: boolean } - action: - type: string - enum: [send-to-agent, notify, auto-merge] - message: { type: string } - priority: - type: string - enum: [urgent, action, warning, info] - retries: { type: integer } - escalateAfter: - oneOf: - - { type: number } - - { type: string } - description: Either ms (number) or duration string ("30m"). - threshold: { type: string } - includeSummary: { type: boolean } diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index 60e8159ee3..91a1e47dcf 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -1,16 +1,7 @@ // Package controllers holds the HTTP-facing controllers for the /api/v1 // surface. Each controller groups one resource's routes, exposes a Register -// method that wires them on a chi.Router, and depends on exactly one -// *Manager interface from ports/inbound.go — never on a store, the LCM, an -// adapter, or any other port. Whether the Manager impl reaches past that -// boundary is its own concern. -// -// In the route-shell PR (#20) every handler is a one-line apispec.NotImplemented -// call: the contract lives in the OpenAPI document (apispec/openapi.yaml), and -// the 501 body returns that document's slice for the route so consumers can -// discover the contract from the endpoint itself. When real handlers land, -// the stub one-liner is replaced with the impl; no per-route planned -// metadata in code ever has to be deleted. +// method, and depends on exactly one resource-level Manager interface — never +// directly on stores, lifecycle reducers, or adapters. package controllers import ( @@ -28,10 +19,8 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/project" ) -// ProjectsController owns the 7 canonical /projects routes. The controller -// depends ONLY on project.Manager — it doesn't know whether the impl reaches -// into the registry, the LCM, an adapter, or all three. Mgr is nil while -// handlers are stubs; the handler-impl PR supplies a real project.Manager. +// ProjectsController owns the /projects routes. The controller depends only on +// project.Manager; nil keeps routes registered but returns OpenAPI-backed 501s. type ProjectsController struct { Mgr project.Manager } @@ -39,12 +28,6 @@ type ProjectsController struct { // Register mounts the project routes on the supplied router. Route order // matters: /projects/reload must register before /projects/{id} for the POST // verb, otherwise chi would treat "reload" as an {id} match for repair. -// -// Legacy paths that the REST audit dropped are deliberately NOT registered -// here. They surface as 405 (sibling method exists, e.g. PUT /projects/{id}) -// or 404 (no sibling). The mapping lives in apispec/openapi.yaml as -// `x-replaces` on the canonical operation so consumers discover the -// migration without leaving the spec. func (c *ProjectsController) Register(r chi.Router) { r.Get("/projects", c.list) r.Post("/projects", c.add) diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index d1ca244232..8d303da51c 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -136,7 +136,7 @@ func TestProjectsAPI_UpdateDeleteRepair(t *testing.T) { t.Fatalf("seed create = %d, want 201; body=%s", status, body) } - body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"agent":"claude","runtime":"tmux"}`) + body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"agent":"claude"}`) assertErrorCode(t, body, status, http.StatusNotImplemented, "PROJECT_CONFIG_NOT_IMPLEMENTED") body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"path":"elsewhere"}`) @@ -229,7 +229,6 @@ type projectBody struct { Repo string `json:"repo"` DefaultBranch string `json:"defaultBranch"` Agent string `json:"agent"` - Runtime string `json:"runtime"` } type errorBody struct { diff --git a/backend/internal/httpd/errors.go b/backend/internal/httpd/errors.go deleted file mode 100644 index 8b41c99f66..0000000000 --- a/backend/internal/httpd/errors.go +++ /dev/null @@ -1,22 +0,0 @@ -package httpd - -import ( - "net/http" - - "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" -) - -// APIError is the locked wire shape for every non-2xx response. It supersedes -// the legacy TS `{error: "msg"}` bag with a machine-readable Code and a -// RequestID for log correlation (sourced from chi's RequestID middleware). -// -// Details is open so collision-style errors can carry typed sub-fields -// (e.g. existingProjectId, suggestedProjectId on POST /projects 409s). -type APIError = envelope.APIError - -// writeAPIError emits the locked envelope for any non-2xx response. The -// request id falls back to empty when the chi middleware hasn't tagged the -// request (e.g. in tests that bypass NewRouter). -func writeAPIError(w http.ResponseWriter, r *http.Request, status int, kind, code, message string, details map[string]any) { - envelope.WriteAPIError(w, r, status, kind, code, message, details) -} diff --git a/backend/internal/httpd/json.go b/backend/internal/httpd/json.go deleted file mode 100644 index 64ccb340d6..0000000000 --- a/backend/internal/httpd/json.go +++ /dev/null @@ -1,14 +0,0 @@ -package httpd - -import ( - "net/http" - - "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" -) - -// writeJSON serialises v as JSON with the given status. It is the single JSON -// writer for the skeleton; the typed error envelope (open item Q1.3) will build -// on this in a later phase. -func writeJSON(w http.ResponseWriter, status int, v any) { - envelope.WriteJSON(w, status, v) -} diff --git a/backend/internal/httpd/logger.go b/backend/internal/httpd/logger.go new file mode 100644 index 0000000000..0df29da0c7 --- /dev/null +++ b/backend/internal/httpd/logger.go @@ -0,0 +1,10 @@ +package httpd + +import "log/slog" + +func loggerOrDefault(log *slog.Logger) *slog.Logger { + if log != nil { + return log + } + return slog.Default() +} diff --git a/backend/internal/httpd/logger_test.go b/backend/internal/httpd/logger_test.go new file mode 100644 index 0000000000..ddd6d308f8 --- /dev/null +++ b/backend/internal/httpd/logger_test.go @@ -0,0 +1,19 @@ +package httpd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" +) + +func TestNewRouterAllowsNilLogger(t *testing.T) { + router := NewRouter(config.Config{}, nil, nil) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("/healthz status = %d, want 200", rec.Code) + } +} diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 195907380d..5d73156d79 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -1,7 +1,5 @@ -// Package httpd builds and runs the daemon's HTTP surface. Phase 1a is the -// skeleton: the middleware stack, liveness/readiness probes, and a graceful -// run loop. Route registration (/api/v1, /events, /mux, /) lands in later -// phases on top of the router this package builds. +// Package httpd builds and runs the daemon's HTTP surface: middleware, health +// probes, daemon control, REST APIs, and terminal WebSocket routing. package httpd import ( @@ -15,6 +13,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/daemonmeta" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) @@ -28,11 +27,8 @@ import ( // requestLogger → slog-backed access log, stderr, carries the request id // RealIP → normalise client IP (loopback proxy from the dev server) // -// The per-request Timeout from the decision table is deliberately NOT applied -// globally: it must wrap only the /api/v1 REST surface, never the long-lived -// SSE (/events) or WebSocket (/mux) surfaces, nor the always-must-answer health -// probes. It is therefore applied per-surface when those subrouters are mounted -// in Phase 1b; cfg.RequestTimeout carries the value through to that point. +// The per-request timeout is deliberately not global: it wraps only bounded +// REST routes, never long-lived terminal streams or health probes. func NewRouter(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) chi.Router { return NewRouterWithAPI(cfg, log, termMgr, APIDeps{}) } @@ -43,9 +39,8 @@ type ControlDeps struct { RequestShutdown func() } -// NewRouterWithAPI is the dependency-injected variant. main.go calls it with -// real Managers when they exist; tests/dev wiring inject mocks explicitly. -// Missing Managers intentionally keep the route-shell 501 behavior. +// NewRouterWithAPI is the dependency-injected variant. Missing Managers keep +// routes registered but return OpenAPI-backed 501 responses. func NewRouterWithAPI(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps) chi.Router { return NewRouterWithControl(cfg, log, termMgr, deps, ControlDeps{}) } @@ -53,6 +48,7 @@ func NewRouterWithAPI(cfg config.Config, log *slog.Logger, termMgr *terminal.Man // NewRouterWithControl is NewRouterWithAPI plus daemon-control hooks: it mounts // the same API surface and additionally wires the ControlDeps callbacks. func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps, control ControlDeps) chi.Router { + log = loggerOrDefault(log) r := chi.NewRouter() r.Use(middleware.Recoverer) @@ -67,7 +63,7 @@ func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal r.MethodNotAllowed(methodNotAllowedJSON) mountHealth(r) - mountMux(r, termMgr, log) + mountTerminalMux(r, termMgr, log) mountControl(r, control) NewAPI(cfg, deps).Register(r) @@ -91,13 +87,13 @@ func mountControl(r chi.Router, deps ControlDeps) { } r.Post("/shutdown", func(w http.ResponseWriter, req *http.Request) { if !localControlRequest(req) { - writeJSON(w, http.StatusForbidden, map[string]any{ + envelope.WriteJSON(w, http.StatusForbidden, map[string]any{ "status": "forbidden", "service": daemonmeta.ServiceName, }) return } - writeJSON(w, http.StatusAccepted, map[string]any{ + envelope.WriteJSON(w, http.StatusAccepted, map[string]any{ "status": "shutting_down", "service": daemonmeta.ServiceName, "pid": os.Getpid(), @@ -132,18 +128,17 @@ func localControlRequest(r *http.Request) bool { // handleHealthz is the liveness probe: it answers 200 as long as the process is // up and serving. It does no dependency checks by design. func handleHealthz(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ + envelope.WriteJSON(w, http.StatusOK, map[string]any{ "status": "ok", "service": daemonmeta.ServiceName, "pid": os.Getpid(), }) } -// handleReadyz is the readiness probe. In the 1a skeleton the daemon is ready -// as soon as it is listening; later phases will gate this on dependency -// initialisation (e.g. store/event-bus warm-up). +// handleReadyz is the readiness probe. Dependency initialization happens before +// the server is constructed, so a listening daemon is ready to answer requests. func handleReadyz(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ + envelope.WriteJSON(w, http.StatusOK, map[string]any{ "status": "ready", "service": daemonmeta.ServiceName, "pid": os.Getpid(), diff --git a/backend/internal/httpd/server.go b/backend/internal/httpd/server.go index a9ddcbde87..a1b8e6157e 100644 --- a/backend/internal/httpd/server.go +++ b/backend/internal/httpd/server.go @@ -34,6 +34,12 @@ type Server struct { // the returned Server's lifecycle via Run. termMgr may be nil, in which case // the /mux terminal surface is not mounted. func New(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) (*Server, error) { + return NewWithDeps(cfg, log, termMgr, APIDeps{}) +} + +// NewWithDeps constructs a Server with API dependencies supplied by the daemon. +func NewWithDeps(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps) (*Server, error) { + log = loggerOrDefault(log) ln, err := net.Listen("tcp", cfg.Addr()) if err != nil { return nil, fmt.Errorf("bind %s (is a daemon already running?): %w", cfg.Addr(), err) @@ -46,7 +52,7 @@ func New(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) (*Serve shutdownRequested: make(chan struct{}), } srv.http = &http.Server{ - Handler: NewRouterWithControl(cfg, log, termMgr, APIDeps{}, ControlDeps{ + Handler: NewRouterWithControl(cfg, log, termMgr, deps, ControlDeps{ RequestShutdown: srv.requestShutdown, }), // ReadHeaderTimeout guards against slow-loris even on loopback; @@ -75,7 +81,7 @@ func (s *Server) Run(ctx context.Context) error { return fmt.Errorf("write run-file: %w", err) } defer func() { - if err := runfile.Remove(s.cfg.RunFilePath); err != nil { + if err := runfile.RemoveIfOwned(s.cfg.RunFilePath, info.PID); err != nil { s.log.Warn("failed to remove run-file", "path", s.cfg.RunFilePath, "err", err) } }() diff --git a/backend/internal/httpd/mux.go b/backend/internal/httpd/terminal_mux.go similarity index 51% rename from backend/internal/httpd/mux.go rename to backend/internal/httpd/terminal_mux.go index 0c17a548b1..ef038fd27b 100644 --- a/backend/internal/httpd/mux.go +++ b/backend/internal/httpd/terminal_mux.go @@ -12,48 +12,50 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) -// muxReadLimit caps a single inbound frame. Client→server frames are small +// terminalMuxReadLimit caps a single inbound frame. Client→server frames are small // (keystrokes, resize, control), so a generous 1 MiB is ample headroom while // still bounding memory per message. -const muxReadLimit = 1 << 20 +const terminalMuxReadLimit = 1 << 20 -// mountMux registers the long-lived terminal-multiplexing WebSocket at /mux. It +// mountTerminalMux registers the long-lived terminal-multiplexing WebSocket at /mux. It // is intentionally outside the per-request Timeout middleware (the connection is // long-lived). When mgr is nil the route is not mounted — the daemon simply has // no terminal surface yet. -func mountMux(r chi.Router, mgr *terminal.Manager, log *slog.Logger) { +func mountTerminalMux(r chi.Router, mgr *terminal.Manager, log *slog.Logger) { if mgr == nil { return } - r.Get("/mux", muxHandler(mgr, log)) + r.Get("/mux", terminalMuxHandler(mgr, log)) } -// muxHandler upgrades the request to a WebSocket and hands the connection to the +// terminalMuxHandler upgrades the request to a WebSocket and hands the connection to the // terminal manager. httpd owns only the upgrade and the transport adaptation; // all stream logic lives in internal/terminal. -func muxHandler(mgr *terminal.Manager, log *slog.Logger) http.HandlerFunc { +func terminalMuxHandler(mgr *terminal.Manager, log *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // InsecureSkipVerify disables coder/websocket's same-origin check: the // daemon binds loopback only and the desktop renderer's origin differs // from the loopback host, mirroring the legacy Node mux server. c, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) if err != nil { - log.Warn("mux: websocket upgrade failed", "err", err) + log.Warn("terminal mux: websocket upgrade failed", "err", err) return } - c.SetReadLimit(muxReadLimit) - mgr.Serve(r.Context(), &coderConn{c: c}) + c.SetReadLimit(terminalMuxReadLimit) + mgr.Serve(r.Context(), &terminalMuxConn{c: c}) } } -// coderConn adapts a coder/websocket connection to terminal.wsConn. JSON framing +// terminalMuxConn adapts a coder/websocket connection to terminal.wsConn. JSON framing // uses wsjson (text messages); Ping is a control frame; Close sends a normal // closure. -type coderConn struct{ c *websocket.Conn } +type terminalMuxConn struct{ c *websocket.Conn } -func (a *coderConn) ReadJSON(ctx context.Context, v any) error { return wsjson.Read(ctx, a.c, v) } -func (a *coderConn) WriteJSON(ctx context.Context, v any) error { return wsjson.Write(ctx, a.c, v) } -func (a *coderConn) Ping(ctx context.Context) error { return a.c.Ping(ctx) } -func (a *coderConn) Close(reason string) error { +func (a *terminalMuxConn) ReadJSON(ctx context.Context, v any) error { return wsjson.Read(ctx, a.c, v) } +func (a *terminalMuxConn) WriteJSON(ctx context.Context, v any) error { + return wsjson.Write(ctx, a.c, v) +} +func (a *terminalMuxConn) Ping(ctx context.Context) error { return a.c.Ping(ctx) } +func (a *terminalMuxConn) Close(reason string) error { return a.c.Close(websocket.StatusNormalClosure, reason) } diff --git a/backend/internal/httpd/mux_test.go b/backend/internal/httpd/terminal_mux_test.go similarity index 90% rename from backend/internal/httpd/mux_test.go rename to backend/internal/httpd/terminal_mux_test.go index b334cf8b7f..fc7bca5fa7 100644 --- a/backend/internal/httpd/mux_test.go +++ b/backend/internal/httpd/terminal_mux_test.go @@ -17,9 +17,9 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) -// stubSource attaches a throwaway shell command instead of a real tmux pane, so +// stubSource attaches a throwaway shell command instead of a real Zellij pane, so // the /mux path exercises the genuine upgrade + wsjson + Serve + creack/pty flow -// without needing tmux. IsAlive=false means the pane is treated as gone once the +// without needing Zellij. IsAlive=false means the pane is treated as gone once the // command exits (no re-attach). type stubSource struct{ argv []string } @@ -28,7 +28,7 @@ func (stubSource) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { return false, nil } -type muxFrame struct { +type terminalMuxFrame struct { Ch string `json:"ch"` ID string `json:"id"` Type string `json:"type"` @@ -52,12 +52,12 @@ func dialMux(t *testing.T, mgr *terminal.Manager) (*websocket.Conn, func()) { } } -func readFrame(t *testing.T, c *websocket.Conn, ch, typ string, d time.Duration) muxFrame { +func readFrame(t *testing.T, c *websocket.Conn, ch, typ string, d time.Duration) terminalMuxFrame { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), d) defer cancel() for { - var f muxFrame + var f terminalMuxFrame if err := wsjson.Read(ctx, c, &f); err != nil { t.Fatalf("waiting for %s/%s: %v", ch, typ, err) } @@ -81,7 +81,7 @@ func TestMuxUpgradeStreamsTerminal(t *testing.T) { defer done() ctx := context.Background() - if err := wsjson.Write(ctx, c, muxFrame{Ch: "terminal", ID: "t1", Type: "open"}); err != nil { + if err := wsjson.Write(ctx, c, terminalMuxFrame{Ch: "terminal", ID: "t1", Type: "open"}); err != nil { t.Fatalf("write open: %v", err) } diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index e14a93fe37..670fa150ad 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -1,731 +1,163 @@ -// Package integration exercises the lifecycle + session lane against the real -// SQLite store and the real CDC trigger pipeline. Unit tests stay on the -// in-memory fakes in lifecycle/ and session/; these live-fire tests prove the -// wiring across packages actually flows: SM -> store row -> LCM mutate -> store -// update -> DB trigger -> change_log read. package integration import ( "context" - "io" - "log/slog" - "path/filepath" - "strings" - "sync" "testing" "time" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" - "github.com/aoagents/agent-orchestrator/backend/internal/notification" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + prsvc "github.com/aoagents/agent-orchestrator/backend/internal/pr" + "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/session" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) -// ---- plugin fakes (minimal: only enough to drive SM through real LCM) ---- +type stubRuntime struct{ created, destroyed int } -type stubRuntime struct { - id, name string -} - -func (s *stubRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { - return ports.RuntimeHandle{ID: s.id, RuntimeName: s.name}, nil -} -func (s *stubRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil } -func (s *stubRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { - return true, nil +func (s *stubRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) { + s.created++ + return ports.RuntimeHandle{ID: "h1"}, nil } +func (s *stubRuntime) Destroy(context.Context, ports.RuntimeHandle) error { s.destroyed++; return nil } +func (s *stubRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { return true, nil } type stubAgent struct{} -func (stubAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" } -func (stubAgent) GetEnvironment(ports.AgentConfig) map[string]string { return map[string]string{} } -func (stubAgent) GetRestoreCommand(id string) string { return "resume " + id } - -type stubWorkspace struct { - root string +func (stubAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" } +func (stubAgent) GetEnvironment(ports.AgentConfig) map[string]string { + return map[string]string{"X": "1"} } +func (stubAgent) GetRestoreCommand(id string) string { return "resume " + id } -func (w *stubWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { - return ports.WorkspaceInfo{ - Path: filepath.Join(w.root, string(cfg.SessionID)), - Branch: cfg.Branch, - SessionID: cfg.SessionID, - ProjectID: cfg.ProjectID, - }, nil -} -func (w *stubWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { return nil } -func (w *stubWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { - return w.Create(ctx, cfg) -} +type stubWorkspace struct{ destroyed int } -type captureMessenger struct { - mu sync.Mutex - msgs []string +func (s *stubWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + return ports.WorkspaceInfo{Path: "/ws/" + string(cfg.SessionID), Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil } - -func (m *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error { - m.mu.Lock() - defer m.mu.Unlock() - m.msgs = append(m.msgs, msg) +func (s *stubWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { + s.destroyed++ return nil } -func (m *captureMessenger) drain() []string { - m.mu.Lock() - defer m.mu.Unlock() - out := append([]string(nil), m.msgs...) - m.msgs = nil - return out +func (s *stubWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + return s.Create(ctx, cfg) } -type captureNotifier struct { - mu sync.Mutex - events []ports.Event -} +type captureMessenger struct{ msgs []string } -func (n *captureNotifier) Notify(_ context.Context, e ports.Event) error { - n.mu.Lock() - defer n.mu.Unlock() - n.events = append(n.events, e) +func (c *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error { + c.msgs = append(c.msgs, msg) return nil } -func (n *captureNotifier) drain() []ports.Event { - n.mu.Lock() - defer n.mu.Unlock() - out := append([]ports.Event(nil), n.events...) - n.events = nil - return out -} - -// ---- harness: real store + real LCM + real SM + change_log poller ---- -type liveStack struct { - dataDir string - store *sqlite.Store - lcm *lifecycle.Manager - sm *session.Manager - notifier *captureNotifier - messenger *captureMessenger - - closed bool // guard so the explicit close() and t.Cleanup don't double-close +type stack struct { + store *sqlite.Store + sm *session.Manager + lcm *lifecycle.Manager + prm *prsvc.Manager + rt *stubRuntime + ws *stubWorkspace + msg *captureMessenger } -// openLiveStack opens the store + hydrates the LCM/SM and registers an -// idempotent t.Cleanup so a mid-test t.Fatalf can't leak the SQLite handle. -// Tests that need to simulate a daemon restart still call close() explicitly -// between phases; the cleanup hook becomes a no-op once that runs. -func openLiveStack(t *testing.T, dataDir string) *liveStack { +func newStack(t *testing.T) *stack { t.Helper() - store, err := sqlite.Open(dataDir) + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) if err != nil { - t.Fatalf("open sqlite: %v", err) - } - notifier := &captureNotifier{} - messenger := &captureMessenger{} - lcm := lifecycle.New(store, store, notifier, messenger) - - wsRoot := t.TempDir() - sm := session.New(session.Deps{ - Runtime: &stubRuntime{id: "h1", name: "tmux"}, - Agent: stubAgent{}, - Workspace: &stubWorkspace{root: wsRoot}, - Store: store, - Messenger: messenger, - Lifecycle: lcm, - }) - st := &liveStack{ - dataDir: dataDir, - store: store, - lcm: lcm, - sm: sm, - notifier: notifier, - messenger: messenger, - } - t.Cleanup(func() { - if st.closed { - return - } - // Best-effort: failures here would be noise after t.Fatalf already - // recorded the real cause. - _ = st.store.Close() - st.closed = true - }) - return st -} - -func (s *liveStack) close(t *testing.T) { - t.Helper() - if s.closed { - return - } - s.closed = true - if err := s.store.Close(); err != nil { - t.Fatalf("close store: %v", err) + t.Fatal(err) } -} - -func seedProject(t *testing.T, store *sqlite.Store, id string) { - t.Helper() - if err := store.UpsertProject(context.Background(), sqlite.ProjectRow{ - ID: id, Path: "/repo/" + id, RegisteredAt: time.Now(), - }); err != nil { - t.Fatalf("upsert project: %v", err) - } -} - -func durableLifecycle(store *sqlite.Store, messenger ports.AgentMessenger) *lifecycle.Manager { - renderer := notification.NewRenderer(store) - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - notifier := notification.NewEnqueuer(store, renderer, logger) - return lifecycle.New(store, store, notifier, messenger) -} - -func durableRecord(project, issue, branch string) domain.SessionRecord { - now := time.Now().UTC().Truncate(time.Second) - return domain.SessionRecord{ - ProjectID: domain.ProjectID(project), - IssueID: domain.IssueID(issue), - Kind: domain.KindWorker, - Lifecycle: domain.CanonicalSessionLifecycle{ - Version: domain.LifecycleVersion, - Session: domain.SessionSubstate{State: domain.SessionWorking}, - IsAlive: true, - Activity: domain.ActivitySubstate{ - State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceHook, - }, - }, - Metadata: domain.SessionMetadata{Branch: branch, WorkspacePath: "/workspace/" + branch}, - CreatedAt: now, - UpdatedAt: now, + t.Cleanup(func() { _ = store.Close() }) + if err := store.Upsert(ctx, project.Row{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil { + t.Fatal(err) } + msg := &captureMessenger{} + lcm := lifecycle.New(store, msg) + prm := prsvc.New(prsvc.Deps{Writer: store, Lifecycle: lcm}) + rt := &stubRuntime{} + ws := &stubWorkspace{} + sm := session.New(session.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm}) + return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg} } -// ---- tests ---- - -// TestHappyPath drives Spawn -> SCM PR observation (open + CI passing) -> Kill, -// asserting via direct store reads that the canonical row, the PR row, and the -// change_log stream all reflect what each step contributed. -func TestHappyPath_Spawn_PR_Kill(t *testing.T) { - t.Parallel() +func TestSpawnPRKillRoundTrip(t *testing.T) { ctx := context.Background() - st := openLiveStack(t, t.TempDir()) - defer st.close(t) - seedProject(t, st.store, "mer") - - // 1. Spawn — SM inserts the session row, LCM marks it live. We only assert - // the structural invariant of the id (project-scoped, non-empty), not the - // literal counter — that's a store-internal detail. - sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ - ProjectID: "mer", Kind: domain.KindWorker, Prompt: "ship it", - }) + st := newStack(t) + sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "do it"}) if err != nil { - t.Fatalf("spawn: %v", err) + t.Fatal(err) } - if sess.ID == "" || !strings.HasPrefix(string(sess.ID), "mer-") { - t.Fatalf("expected project-scoped id like mer-N, got %q", sess.ID) + if sess.ID != "mer-1" || sess.Status != domain.StatusIdle { + t.Fatalf("spawn got %+v", sess) } - - rec, ok, err := st.store.GetSession(ctx, sess.ID) - if err != nil || !ok { - t.Fatalf("get session: ok=%v err=%v", ok, err) - } - if !rec.Lifecycle.IsAlive { - t.Fatal("post-spawn: is_alive should be true") - } - if rec.Lifecycle.Session.State != domain.SessionNotStarted { - t.Fatalf("post-spawn state want not_started, got %q", rec.Lifecycle.Session.State) - } - if rec.Metadata.RuntimeHandleID != "h1" || rec.Metadata.RuntimeName != "tmux" { - t.Fatalf("post-spawn handles missing: %+v", rec.Metadata) - } - if rec.Metadata.WorkspacePath == "" || rec.Metadata.Prompt != "ship it" { - t.Fatalf("post-spawn metadata missing: %+v", rec.Metadata) + rec, ok, _ := st.store.GetSession(ctx, sess.ID) + if !ok || rec.Metadata.RuntimeHandleID != "h1" || rec.IsTerminated { + t.Fatalf("post-spawn row wrong: %+v", rec) } - - // 2. SCM observes a fresh PR — open, CI passing. LCM writes the pr row - // atomically (one tx, triggers fire pr_created). - prURL := "https://github.com/repo/mer/pull/1" - if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ - Fetched: true, URL: prURL, Number: 1, - CI: domain.CIPassing, Review: domain.ReviewNone, Mergeability: domain.MergeMergeable, - Checks: []domain.PRCheckRow{{ - Name: "ci/build", CommitHash: "abc123", Status: "passed", CreatedAt: time.Now(), - }}, - }); err != nil { - t.Fatalf("apply pr: %v", err) + if err := st.prm.ApplyObservation(ctx, sess.ID, ports.PRObservation{Fetched: true, URL: "pr1", Number: 1, CI: domain.CIFailing, Checks: []ports.PRCheckObservation{{Name: "build", CommitHash: "c1", Status: domain.PRCheckFailed, LogTail: "boom"}}}); err != nil { + t.Fatal(err) } - prRow, ok, err := st.store.GetPR(ctx, prURL) - if err != nil || !ok { - t.Fatalf("get pr: ok=%v err=%v", ok, err) + got, err := st.sm.Get(ctx, sess.ID) + if err != nil { + t.Fatal(err) } - if prRow.SessionID != string(sess.ID) || prRow.CI != domain.CIPassing || prRow.Draft || prRow.Merged || prRow.Closed { - t.Fatalf("pr row wrong: %+v", prRow) + if got.Status != domain.StatusCIFailed { + t.Fatalf("want ci_failed, got %q", got.Status) } - - // 3. Kill — SM routes to LCM and tears down runtime+workspace. - freed, err := st.sm.Kill(ctx, sess.ID, domain.TermManuallyKilled) + freed, err := st.sm.Kill(ctx, sess.ID) if err != nil || !freed { t.Fatalf("kill freed=%v err=%v", freed, err) } rec, _, _ = st.store.GetSession(ctx, sess.ID) - if rec.Lifecycle.Session.State != domain.SessionTerminated || - rec.Lifecycle.TerminationReason != domain.TermManuallyKilled || - rec.Lifecycle.IsAlive { - t.Fatalf("post-kill canonical wrong: %+v", rec.Lifecycle) - } - - // 4. Assert the change_log captured the full timeline. The DB triggers - // write the only durable CDC; we don't want to assume an ordering of - // interleaved events, just that each expected event_type shows up. - rows, err := st.store.ReadChangeLogAfter(ctx, 0, 100) - if err != nil { - t.Fatalf("read change_log: %v", err) - } - seen := map[string]bool{} - for _, r := range rows { - seen[r.EventType] = true - } - for _, want := range []string{"session_created", "session_updated", "pr_created", "pr_check_recorded"} { - if !seen[want] { - t.Fatalf("missing change_log event %q (got: %v)", want, seen) - } + if !rec.IsTerminated { + t.Fatalf("post-kill row should be terminated: %+v", rec) } } -// TestRestoreRoundTrip simulates a daemon restart: spawn a session, persist the -// kill, fully close the in-process LCM/SM, open a fresh stack against the SAME -// DB file, and Restore. The restored session must keep its metadata (the agent -// session id is the must-survive bit). -func TestRestoreRoundTrip_PreservesMetadata(t *testing.T) { - t.Parallel() +func TestRestoreRoundTripPreservesMetadata(t *testing.T) { ctx := context.Background() - dir := t.TempDir() - st := openLiveStack(t, dir) - seedProject(t, st.store, "mer") - - // Phase A: spawn with an agent session id, then kill so the row is terminal - // and Restore is legal. - sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ - ProjectID: "mer", Kind: domain.KindWorker, Prompt: "remember me", - }) + st := newStack(t) + sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "b", Prompt: "prompt"}) if err != nil { - t.Fatalf("spawn: %v", err) - } - // fold an AgentSessionID into the row — the LCM does this through the spawn - // outcome on Restore too, but a fresh spawn doesn't (the agent has not - // reported one yet). We patch via the store so the restore branch has - // something to resume from. Check ok/err: without it, a missed row would - // hand UpdateSession a zero-value record (ID==""), which matches no rows - // and returns nil — Phase B would then fail with a misleading "agent id - // lost across restart" rather than the real cause. - rec, ok, err := st.store.GetSession(ctx, sess.ID) - if err != nil || !ok { - t.Fatalf("get session for patch: ok=%v err=%v", ok, err) + t.Fatal(err) } - rec.Metadata.AgentSessionID = "agent-xyz" + rec, _, _ := st.store.GetSession(ctx, sess.ID) + rec.Metadata.AgentSessionID = "agent-x" if err := st.store.UpdateSession(ctx, rec); err != nil { - t.Fatalf("patch agent id: %v", err) - } - if _, err := st.sm.Kill(ctx, sess.ID, domain.TermManuallyKilled); err != nil { - t.Fatalf("kill: %v", err) - } - st.close(t) - - // Phase B: reopen against the same data dir; everything in memory is gone. - st2 := openLiveStack(t, dir) - defer st2.close(t) - - // Confirm the row survived the restart. - rec2, ok, err := st2.store.GetSession(ctx, sess.ID) - if err != nil || !ok { - t.Fatalf("reopen get: ok=%v err=%v", ok, err) - } - if rec2.Metadata.AgentSessionID != "agent-xyz" { - t.Fatalf("agent session id lost across restart: %+v", rec2.Metadata) - } - if rec2.Lifecycle.Session.State != domain.SessionTerminated { - t.Fatalf("expected terminal after reopen, got %q", rec2.Lifecycle.Session.State) - } - - // Phase C: Restore — must drive a fresh OnSpawnCompleted and surface the - // preserved AgentSessionID into the new outcome. - restored, err := st2.sm.Restore(ctx, sess.ID) - if err != nil { - t.Fatalf("restore: %v", err) - } - if !restored.Lifecycle.IsAlive { - t.Fatal("restored session should be is_alive after spawn-completed") - } - if restored.Metadata.AgentSessionID != "agent-xyz" { - t.Fatalf("restored row dropped AgentSessionID: %+v", restored.Metadata) - } -} - -// TestCIFailureAndRecovery drives the CI-failed reaction path: a failing -// observation injects a nudge into the agent (messenger), a recovery -// observation (CI passing) flips state without re-firing the nudge, and the -// pr_checks history records both runs so the brake's "last 3 all failed" query -// reads the truth. -func TestCIFailureAndRecovery_NudgeThenClears(t *testing.T) { - t.Parallel() - ctx := context.Background() - st := openLiveStack(t, t.TempDir()) - defer st.close(t) - seedProject(t, st.store, "mer") - - sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."}) - if err != nil { - t.Fatalf("spawn: %v", err) - } - // Move the session out of not_started so the reaction path engages on real - // PR facts (not_started doesn't react on PRs). - if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{ - Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(), - }); err != nil { - t.Fatalf("activity: %v", err) - } - _ = st.messenger.drain() // ignore startup nudges, focus on CI - - prURL := "https://github.com/repo/mer/pull/2" - // Failing CI: handleCIFailure should send a CI-failed nudge with the log - // tail injected. - if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ - Fetched: true, URL: prURL, Number: 2, - CI: domain.CIFailing, Mergeability: domain.MergeUnstable, - Checks: []domain.PRCheckRow{{ - Name: "ci/build", CommitHash: "c1", Status: "failed", LogTail: "panic: nil map", CreatedAt: time.Now(), - }}, - }); err != nil { - t.Fatalf("apply pr (failing): %v", err) + t.Fatal(err) } - got := st.messenger.drain() - if len(got) == 0 { - t.Fatal("expected CI-failed nudge to the agent") + if _, err := st.sm.Kill(ctx, sess.ID); err != nil { + t.Fatal(err) } - if !strings.Contains(got[0], "CI is failing") || !strings.Contains(got[0], "panic: nil map") { - t.Fatalf("ci-failed message missing content: %q", got[0]) - } - - // Brake confirmation: only one failure so far, RecentCheckStatuses should - // reflect it. - history, err := st.store.RecentCheckStatuses(ctx, prURL, "ci/build", 3) + restored, err := st.sm.Restore(ctx, sess.ID) if err != nil { - t.Fatalf("recent checks: %v", err) - } - if len(history) != 1 || history[0] != "failed" { - t.Fatalf("ci history wrong: %v", history) - } - - // Recovery: CI passing on a new commit. With the dedupe slot still on - // rxCIFailed, the dispatch path moves to rxApprovedGreen (mergeable) and - // the human notifier is the one that pages. - if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ - Fetched: true, URL: prURL, Number: 2, - CI: domain.CIPassing, Mergeability: domain.MergeMergeable, - Checks: []domain.PRCheckRow{{ - Name: "ci/build", CommitHash: "c2", Status: "passed", CreatedAt: time.Now(), - }}, - }); err != nil { - t.Fatalf("apply pr (recovery): %v", err) + t.Fatal(err) } - ev := st.notifier.drain() - if len(ev) == 0 { - t.Fatal("recovery: notifier should have received an event (approved-and-green)") - } - if !anyEventType(ev, "reaction.approved-and-green") { - t.Fatalf("recovery should notify approved-and-green, got %+v", ev) - } - - // And the pr row reflects the recovery in the canonical fact store. - prRow, ok, _ := st.store.GetPR(ctx, prURL) - if !ok || prRow.CI != domain.CIPassing { - t.Fatalf("pr ci_state should be passing post-recovery: %+v", prRow) + if restored.IsTerminated || restored.Metadata.AgentSessionID != "agent-x" { + t.Fatalf("restored wrong: %+v", restored) } } -// TestDetectingPersistsAcrossRestart drives the runtime quarantine path: a -// failed probe puts the session into the detecting state, which means the -// decider's anti-flap memory MUST be flushed to the detecting_* columns and -// survive a restart. A subsequent alive probe must clear it. -func TestDetectingPersistsAcrossRestart(t *testing.T) { - t.Parallel() +func TestCDCPollerReceivesSessionAndPREvents(t *testing.T) { ctx := context.Background() - dir := t.TempDir() - st := openLiveStack(t, dir) - seedProject(t, st.store, "mer") - - sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."}) + st := newStack(t) + b := cdc.NewBroadcaster() + var got []cdc.Event + b.Subscribe(func(e cdc.Event) { got = append(got, e) }) + poller := cdc.NewPoller(st.store, b, cdc.PollerConfig{}) + sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) if err != nil { - t.Fatalf("spawn: %v", err) - } - // Move to working so the runtime decider doesn't bail on not_started. - if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{ - Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(), - }); err != nil { - t.Fatalf("activity: %v", err) + t.Fatal(err) } - // One failed probe should park the session in detecting with attempts=1. - if err := st.lcm.ApplyRuntimeObservation(ctx, sess.ID, ports.RuntimeFacts{ - ObservedAt: time.Now(), - Runtime: ports.ProbeFailed, - Process: ports.ProbeFailed, - }); err != nil { - t.Fatalf("apply runtime: %v", err) + if err := st.prm.ApplyObservation(ctx, sess.ID, ports.PRObservation{Fetched: true, URL: "pr1", Number: 1, Review: domain.ReviewApproved}); err != nil { + t.Fatal(err) } - rec, ok, err := st.store.GetSession(ctx, sess.ID) - if err != nil || !ok { - t.Fatalf("get session post-probe: ok=%v err=%v", ok, err) - } - if rec.Lifecycle.Session.State != domain.SessionDetecting { - t.Fatalf("expected detecting state, got %q", rec.Lifecycle.Session.State) - } - if rec.Lifecycle.Detecting == nil || rec.Lifecycle.Detecting.Attempts == 0 { - t.Fatalf("detecting memory should be populated: %+v", rec.Lifecycle.Detecting) - } - - // Restart: close, reopen, verify the detecting_* columns round-tripped. - st.close(t) - st2 := openLiveStack(t, dir) - defer st2.close(t) - - rec2, ok, _ := st2.store.GetSession(ctx, sess.ID) - if !ok || rec2.Lifecycle.Detecting == nil { - t.Fatalf("detecting lost across restart: %+v", rec2.Lifecycle) - } - if rec2.Lifecycle.Detecting.Attempts != rec.Lifecycle.Detecting.Attempts { - t.Fatalf("attempts round-trip mismatch: pre=%d post=%d", - rec.Lifecycle.Detecting.Attempts, rec2.Lifecycle.Detecting.Attempts) - } - if rec2.Lifecycle.Detecting.EvidenceHash != rec.Lifecycle.Detecting.EvidenceHash { - t.Fatal("evidence hash dropped across restart") - } - - // Recovery probe — alive — must clear detecting and flip state out of it. - if err := st2.lcm.ApplyRuntimeObservation(ctx, sess.ID, ports.RuntimeFacts{ - ObservedAt: time.Now(), - Runtime: ports.ProbeAlive, - Process: ports.ProbeAlive, - }); err != nil { - t.Fatalf("recovery probe: %v", err) - } - rec3, ok3, err := st2.store.GetSession(ctx, sess.ID) - if err != nil || !ok3 { - t.Fatalf("get session post-recovery: ok=%v err=%v", ok3, err) - } - if rec3.Lifecycle.Detecting != nil { - t.Fatalf("alive probe should clear detecting, got %+v", rec3.Lifecycle.Detecting) - } - if rec3.Lifecycle.Session.State == domain.SessionDetecting { - t.Fatalf("session state should leave detecting, got %q", rec3.Lifecycle.Session.State) - } -} - -// TestCDCPollerReceivesAllStages drives the full real pipeline including the -// in-process CDC poller — proving the trigger writes become broadcaster events -// in the same order the storage layer observes them. -func TestCDCPollerReceivesAllStages(t *testing.T) { - t.Parallel() - ctx := context.Background() - st := openLiveStack(t, t.TempDir()) - defer st.close(t) - seedProject(t, st.store, "mer") - - bcast := cdc.NewBroadcaster() - src := pollerSource{st.store} - poller := cdc.NewPoller(src, bcast, cdc.PollerConfig{Batch: 100}) - - var ( - mu sync.Mutex - events []cdc.Event - ) - bcast.Subscribe(func(e cdc.Event) { - mu.Lock() - defer mu.Unlock() - events = append(events, e) - }) - - sess, err := st.sm.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "."}) - if err != nil { - t.Fatalf("spawn: %v", err) - } - if err := st.lcm.ApplyActivitySignal(ctx, sess.ID, ports.ActivitySignal{ - Valid: true, State: domain.ActivityActive, Source: domain.SourceHook, Timestamp: time.Now(), - }); err != nil { - t.Fatalf("activity: %v", err) - } - if err := st.lcm.ApplyPRObservation(ctx, sess.ID, ports.PRObservation{ - Fetched: true, URL: "https://github.com/repo/mer/pull/3", Number: 3, - CI: domain.CIPassing, Mergeability: domain.MergeMergeable, - }); err != nil { - t.Fatalf("apply pr: %v", err) - } - if err := poller.Poll(ctx); err != nil { - t.Fatalf("poll: %v", err) - } - - mu.Lock() - defer mu.Unlock() - types := map[cdc.EventType]bool{} - for _, e := range events { - types[e.Type] = true - } - for _, want := range []cdc.EventType{cdc.EventSessionCreated, cdc.EventSessionUpdated, cdc.EventPRCreated} { - if !types[want] { - t.Fatalf("poller missed event %q (got %+v)", want, types) - } - } - // Seq monotonicity invariant — the wiring assumes it; assert it here. - var prev int64 - for _, e := range events { - if e.Seq <= prev { - t.Fatalf("seq not monotonic: %d after %d", e.Seq, prev) - } - prev = e.Seq - } -} - -func TestLifecycleDurableNotification_NeedsInput(t *testing.T) { - t.Parallel() - ctx := context.Background() - store, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - defer store.Close() - seedProject(t, store, "mer") - rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-1", "feat/input")) - if err != nil { - t.Fatalf("create session: %v", err) - } - lcm := durableLifecycle(store, &captureMessenger{}) - startSeq, _ := store.MaxChangeLogSeq(ctx) - - if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{ - Valid: true, State: domain.ActivityWaitingInput, Source: domain.SourceHook, Timestamp: time.Now(), - }); err != nil { - t.Fatalf("activity: %v", err) - } - - notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10}) - if err != nil { - t.Fatalf("list notifications: %v", err) - } - if len(notifications) != 1 || notifications[0].SemanticType != "session.needs_input" || notifications[0].DedupeKey == "" { - t.Fatalf("needs_input notification missing: %+v", notifications) + t.Fatal(err) } - assertNotificationCreatedCDC(t, store, startSeq) -} - -func TestLifecycleDurableNotification_ApprovedAndGreen(t *testing.T) { - t.Parallel() - ctx := context.Background() - store, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - defer store.Close() - seedProject(t, store, "mer") - rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-2", "feat/green")) - if err != nil { - t.Fatalf("create session: %v", err) - } - lcm := durableLifecycle(store, &captureMessenger{}) - - if err := lcm.ApplyPRObservation(ctx, rec.ID, ports.PRObservation{ - Fetched: true, URL: "https://github.com/org/repo/pull/2", Number: 2, - CI: domain.CIPassing, Review: domain.ReviewApproved, Mergeability: domain.MergeMergeable, - }); err != nil { - t.Fatalf("apply pr: %v", err) - } - notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10}) - if err != nil { - t.Fatalf("list notifications: %v", err) - } - if len(notifications) != 1 || notifications[0].SemanticType != "merge.ready" { - t.Fatalf("approved-and-green notification missing: %+v", notifications) - } -} - -func TestLifecycleDurableNotification_PRMerged(t *testing.T) { - t.Parallel() - ctx := context.Background() - store, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - defer store.Close() - seedProject(t, store, "mer") - rec, err := store.CreateSession(ctx, durableRecord("mer", "MER-3", "feat/merge")) - if err != nil { - t.Fatalf("create session: %v", err) - } - lcm := durableLifecycle(store, &captureMessenger{}) - startSeq, _ := store.MaxChangeLogSeq(ctx) - - if err := lcm.ApplyPRObservation(ctx, rec.ID, ports.PRObservation{ - Fetched: true, URL: "https://github.com/org/repo/pull/3", Number: 3, Merged: true, - CI: domain.CIPassing, Review: domain.ReviewApproved, Mergeability: domain.MergeMergeable, - }); err != nil { - t.Fatalf("apply pr: %v", err) - } - notifications, err := store.ListNotifications(ctx, sqlite.NotificationFilter{SessionID: string(rec.ID), Limit: 10}) - if err != nil { - t.Fatalf("list notifications: %v", err) - } - if len(notifications) != 1 || notifications[0].SemanticType != "pr.merged" { - t.Fatalf("pr_merged notification missing: %+v", notifications) - } - assertNotificationCreatedCDC(t, store, startSeq) -} - -func assertNotificationCreatedCDC(t *testing.T, store *sqlite.Store, after int64) { - t.Helper() - evs, err := store.ReadChangeLogAfter(context.Background(), after, 20) - if err != nil { - t.Fatalf("read change_log: %v", err) - } - for _, e := range evs { - if e.EventType == string(cdc.EventNotificationCreated) { - return - } - } - t.Fatalf("missing notification_created CDC after %d: %+v", after, evs) -} - -// ---- small helpers ---- - -type pollerSource struct{ *sqlite.Store } - -func (s pollerSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) { - rows, err := s.ReadChangeLogAfter(ctx, after, limit) - if err != nil { - return nil, err - } - out := make([]cdc.Event, len(rows)) - for i, r := range rows { - out[i] = cdc.Event{ - Seq: r.Seq, - ProjectID: r.ProjectID, - SessionID: r.SessionID, - Type: cdc.EventType(r.EventType), - Payload: []byte(r.Payload), - CreatedAt: r.CreatedAt, - } - } - return out, nil -} -func (s pollerSource) LatestSeq(ctx context.Context) (int64, error) { - return s.MaxChangeLogSeq(ctx) -} - -func anyEventType(evs []ports.Event, t string) bool { - for _, e := range evs { - if e.Type == t { - return true - } + if len(got) < 2 { + t.Fatalf("want CDC events, got %d", len(got)) } - return false } diff --git a/backend/internal/lifecycle/decide_bridge.go b/backend/internal/lifecycle/decide_bridge.go deleted file mode 100644 index 4f88cbe52f..0000000000 --- a/backend/internal/lifecycle/decide_bridge.go +++ /dev/null @@ -1,112 +0,0 @@ -package lifecycle - -import ( - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/domain/decide" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -// defaultRecentActivityWindow is how fresh the last activity must be for the -// probe decider to treat the agent as "recently active" — which keeps an -// ambiguous dead-runtime probe in detecting instead of concluding death. -const defaultRecentActivityWindow = 60 * time.Second - -// probeInput maps a raw RuntimeFacts (plus the prior detecting memory and last -// activity) into the pure decider's input. A failed/unknown probe is reported as -// such, never as a death — that routes to the detecting quarantine. -func probeInput(f ports.RuntimeFacts, cur domain.CanonicalSessionLifecycle, window time.Duration) decide.ProbeInput { - now := nowOr(f.ObservedAt) - - var runtimeAlive, runtimeFailed bool - switch f.Runtime { - case ports.ProbeAlive: - runtimeAlive = true - case ports.ProbeFailed, ports.ProbeUnknown: - runtimeFailed = true // ambiguous: quarantine, never conclude death - } - - var process decide.ProcessLiveness - var processFailed bool - switch f.Process { - case ports.ProbeAlive: - process = decide.ProcessAlive - case ports.ProbeDead: - process = decide.ProcessDead - case ports.ProbeFailed: - process, processFailed = decide.ProcessIndeterminate, true - default: - process = decide.ProcessIndeterminate - } - - return decide.ProbeInput{ - RuntimeAlive: runtimeAlive, - RuntimeFailed: runtimeFailed, - Process: process, - ProcessFailed: processFailed, - RecentActivity: hasRecentActivity(cur.Activity, now, window), - Prior: cur.Detecting, - Now: now, - } -} - -// hasRecentActivity answers the decider's "heard from the agent recently?" -// question. Sticky states (waiting_input/blocked) count as recent (a live-but- -// paused agent); an explicit exited never counts; else age the timestamp. -func hasRecentActivity(a domain.ActivitySubstate, now time.Time, window time.Duration) bool { - switch { - case a.State == domain.ActivityExited: - return false - case a.State.IsSticky(): - return true - case a.LastActivityAt.IsZero(): - return false - default: - return now.Sub(a.LastActivityAt) <= window - } -} - -// activityToSession maps an activity classification onto the session state. -// exited returns ok=false: only the probe pipeline may conclude death. -func activityToSession(a domain.ActivityState) (domain.SessionState, bool) { - switch a { - case domain.ActivityActive: - return domain.SessionWorking, true - case domain.ActivityReady, domain.ActivityIdle: - return domain.SessionIdle, true - case domain.ActivityWaitingInput: - return domain.SessionNeedsInput, true - case domain.ActivityBlocked: - return domain.SessionStuck, true - default: - return "", false - } -} - -// isTerminal reports a final session state — reopened only by an explicit -// Restore, never by an observation. -func isTerminal(s domain.SessionState) bool { - return s == domain.SessionDone || s == domain.SessionTerminated -} - -// writeRuntimeSession reports whether a probe verdict may write the session -// state. A death-axis verdict (detecting/stuck/terminated) always writes; a -// healthy "working" verdict only recovers a detecting session — it must not -// clobber an activity-owned idle/needs_input. -func writeRuntimeSession(d decide.LifecycleDecision, cur domain.CanonicalSessionLifecycle) bool { - if isTerminal(cur.Session.State) { - return false - } - if d.SessionState == domain.SessionWorking { - return cur.Session.State == domain.SessionDetecting - } - return true -} - -func nowOr(t time.Time) time.Time { - if t.IsZero() { - return time.Now() - } - return t -} diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 19eada0150..03eee005d9 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -1,8 +1,7 @@ -// Package lifecycle implements ports.LifecycleManager: the synchronous -// observe -> decide -> persist reducer. Every Apply*/On* entrypoint loads the -// session, runs the pure decider, and persists the full row under a single write -// lock. The DB triggers emit the CDC; the engine never writes the change log. -// After a transition it fires the mapped reaction (see reactions.go). +// Package lifecycle implements the synchronous reducer that writes durable +// session lifecycle facts. It deliberately keeps the session model small: +// activity_state plus an is_terminated bit are the only persisted status-like +// facts on the session row. package lifecycle import ( @@ -12,206 +11,92 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/domain/decide" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// Manager is the lifecycle engine. mu serialises the load->decide->persist -// read-modify-write across sessions; reactions dispatch after the lock releases -// so a slow agent send never blocks the write path. +type sessionStore interface { + GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) + UpdateSession(ctx context.Context, rec domain.SessionRecord) error +} + +// Manager reduces runtime, activity, spawn, and termination observations into durable session facts. +// It also owns agent nudges caused by PR observations, including merge-conflict, CI-failure, and review-feedback prompts. type Manager struct { - store ports.SessionStore - pr ports.PRWriter - notifier ports.Notifier + store sessionStore messenger ports.AgentMessenger mu sync.Mutex window time.Duration clock func() time.Time - - // in-memory ACT state (policy, not canonical truth — reset on restart). - react reactionState + react reactionState } -var _ ports.LifecycleManager = (*Manager)(nil) - -// New builds a Lifecycle Manager over its collaborators: the session store it -// is the sole writer of, the PR-facts writer, the notifier, and the messenger -// used to nudge running agents. -func New(store ports.SessionStore, pr ports.PRWriter, notifier ports.Notifier, messenger ports.AgentMessenger) *Manager { - return &Manager{ - store: store, - pr: pr, - notifier: notifier, - messenger: messenger, - window: defaultRecentActivityWindow, - clock: time.Now, - react: newReactionState(), - } +// New builds a Lifecycle Manager over the session store it writes and the messenger it uses for agent nudges. +func New(store sessionStore, messenger ports.AgentMessenger) *Manager { + return &Manager{store: store, messenger: messenger, window: defaultRecentActivityWindow, clock: time.Now, react: newReactionState()} } -// mutate runs the shared pipeline: load -> decideFn -> persist (only if changed). -// It returns whether a write happened. A stray observation for an unknown session -// is a clean no-op. -func (m *Manager) mutate( - ctx context.Context, - id domain.SessionID, - fn func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool), -) (bool, error) { +func (m *Manager) mutate(ctx context.Context, id domain.SessionID, fn func(domain.SessionRecord, time.Time) (domain.SessionRecord, bool)) error { m.mu.Lock() defer m.mu.Unlock() rec, ok, err := m.store.GetSession(ctx, id) if err != nil || !ok { - return false, err + return err } - next, changed := fn(rec.Lifecycle) + now := m.clock() + next, changed := fn(rec, now) if !changed { - return false, nil + return nil } - next.Version = domain.LifecycleVersion - rec.Lifecycle = next - rec.UpdatedAt = m.clock() - if err := m.store.UpdateSession(ctx, rec); err != nil { - return false, err + next.UpdatedAt = now + if err := m.store.UpdateSession(ctx, next); err != nil { + return err } - return true, nil + return nil } -// ---- OBSERVE entrypoints ---- - -// ApplyRuntimeObservation feeds the probe decider. is_alive always tracks the -// verdict; the session state follows the runtime-write rule; a non-detecting -// verdict clears stale detecting memory. +// ApplyRuntimeObservation only writes when runtime liveness is unambiguous. A +// failed probe or liveness disagreement is ignored; no transient lifecycle state is stored. func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error { - changed, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) { - d := decide.ResolveProbeDecision(probeInput(f, cur, m.window)) - next := cur - ch := false - if next.IsAlive != d.IsAlive { - next.IsAlive, ch = d.IsAlive, true - } - if !isTerminal(cur.Session.State) { - if writeRuntimeSession(d, cur) { - ch = setSessionState(&next, d.SessionState, d.TerminationReason) || ch - } - ch = setDetecting(&next, d.Detecting) || ch + return m.mutate(ctx, id, func(cur domain.SessionRecord, now time.Time) (domain.SessionRecord, bool) { + if cur.IsTerminated || !runtimeClearlyDead(f, cur.Activity, now, m.window) { + return cur, false } - return next, ch + next := cur + next.IsTerminated = true + next.Activity = domain.ActivitySubstate{State: domain.ActivityExited, LastActivityAt: timeOr(f.ObservedAt, now), Source: domain.SourceRuntime} + return next, true }) - if err != nil || !changed { - return err - } - return m.runReactions(ctx, id, reactionContent{}) } -// ApplyActivitySignal updates the activity axis. Only a valid signal is -// authoritative, and it is proof of life: it may resolve a detecting session and -// move the session out of any non-terminal state. +// ApplyActivitySignal records an authoritative agent activity signal. func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error { if !s.Valid { return nil } - changed, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) { - if isTerminal(cur.Session.State) { + return m.mutate(ctx, id, func(cur domain.SessionRecord, now time.Time) (domain.SessionRecord, bool) { + if cur.IsTerminated { return cur, false } - next := cur - ch := false - act := domain.ActivitySubstate{State: s.State, LastActivityAt: nowOr(s.Timestamp), Source: s.Source} - if !sameActivity(cur.Activity, act) { - next.Activity, ch = act, true + if !s.Source.CanOverride(cur.Activity.Source) { + return cur, false } - if st, ok := activityToSession(s.State); ok { - ch = setSessionState(&next, st, domain.TermNone) || ch - if next.Detecting != nil { - next.Detecting, ch = nil, true - } + next := cur + act := domain.ActivitySubstate{State: s.State, LastActivityAt: timeOr(s.Timestamp, now), Source: s.Source} + if sameActivity(cur.Activity, act) { + return cur, false } - if s.State != domain.ActivityExited && !next.IsAlive { - next.IsAlive, ch = true, true + next.Activity = act + if s.State == domain.ActivityExited { + next.IsTerminated = true } - return next, ch + return next, true }) - if err != nil || !changed { - return err - } - return m.runReactions(ctx, id, reactionContent{}) -} - -// ApplyPRObservation records the observed PR facts in the pr tables, terminates -// the session on a merge, and fires the PR-driven reactions. A failed fetch is -// dropped (failed probe != "PR closed"). -func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o ports.PRObservation) error { - if !o.Fetched { - return nil - } - rec, ok, err := m.store.GetSession(ctx, id) - if err != nil || !ok { - return err - } - if err := m.writePR(ctx, id, o); err != nil { - return err - } - - if o.Merged { - changed, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) { - if isTerminal(cur.Session.State) { - return cur, false - } - next := cur - next.Session.State = domain.SessionTerminated - next.TerminationReason = domain.TermPRMerged - next.IsAlive = false - next.Detecting = nil - return next, true - }) - if err != nil { - return err - } - if changed { - m.clearReactions(id) - return m.fireNotify(ctx, id, rec.ProjectID, rxMerged, reactions[rxMerged]) - } - return nil - } - - return m.runReactions(ctx, id, prContent(o)) } -// writePR persists the observation's scalar facts, check runs, and comment set -// in one atomic store call. PR-table CDC is emitted by the DB triggers. -func (m *Manager) writePR(ctx context.Context, id domain.SessionID, o ports.PRObservation) error { - now := m.clock() - row := domain.PRRow{ - URL: o.URL, SessionID: string(id), Number: o.Number, - Draft: o.Draft, Merged: o.Merged, Closed: o.Closed, - CI: o.CI, Review: o.Review, Mergeability: o.Mergeability, UpdatedAt: now, - } - checks := make([]domain.PRCheckRow, len(o.Checks)) - for i, c := range o.Checks { - c.PRURL = o.URL - if c.CreatedAt.IsZero() { - c.CreatedAt = now - } - checks[i] = c - } - comments := make([]domain.PRComment, len(o.Comments)) - for i, c := range o.Comments { - if c.CreatedAt.IsZero() { - c.CreatedAt = now - } - comments[i] = c - } - return m.pr.WritePR(ctx, row, checks, comments) -} - -// ---- mutation commands from the Session Manager ---- - -// OnSpawnCompleted marks a session live and folds in its handles. It serves a -// fresh spawn (not_started -> live) and a restore (terminal -> reopened): both -// land at not_started + is_alive, with the agent acknowledging via first activity. -func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error { +// MarkSpawned marks a newly spawned or restored session live and stores runtime/workspace handles. +func (m *Manager) MarkSpawned(ctx context.Context, id domain.SessionID, metadata domain.SessionMetadata) error { m.mu.Lock() defer m.mu.Unlock() rec, ok, err := m.store.GetSession(ctx, id) @@ -219,115 +104,32 @@ func (m *Manager) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o p return err } if !ok { - return fmt.Errorf("lifecycle: OnSpawnCompleted for unknown session %q", id) + return fmt.Errorf("lifecycle: MarkSpawned for unknown session %q", id) } - rec.Lifecycle.Version = domain.LifecycleVersion - rec.Lifecycle.Session.State = domain.SessionNotStarted - rec.Lifecycle.TerminationReason = domain.TermNone - rec.Lifecycle.IsAlive = true - rec.Lifecycle.Detecting = nil - rec.Metadata = mergeMetadata(rec.Metadata, spawnMetadata(o)) - rec.UpdatedAt = m.clock() + now := m.clock() + rec.IsTerminated = false + rec.Activity = domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: now, Source: domain.SourceRuntime} + rec.Metadata = mergeMetadata(rec.Metadata, metadata) + rec.UpdatedAt = now return m.store.UpdateSession(ctx, rec) } -// OnKillRequested is the explicit terminal-write path (the one terminal that does -// not go through the inferred-death decider). It fires no reaction — an explicit -// kill is a human action — but drops the session's ACT state. -func (m *Manager) OnKillRequested(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) error { - _, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle) (domain.CanonicalSessionLifecycle, bool) { - if isTerminal(cur.Session.State) { +// MarkTerminated marks a session terminated without tearing down external resources. +func (m *Manager) MarkTerminated(ctx context.Context, id domain.SessionID) error { + return m.mutate(ctx, id, func(cur domain.SessionRecord, now time.Time) (domain.SessionRecord, bool) { + if cur.IsTerminated { return cur, false } - if reason == domain.TermNone { - reason = domain.TermManuallyKilled - } - next := cur - next.Session.State = domain.SessionTerminated - next.TerminationReason = reason - next.IsAlive = false - next.Detecting = nil - return next, true + cur.IsTerminated = true + cur.Activity = domain.ActivitySubstate{State: domain.ActivityExited, LastActivityAt: now, Source: domain.SourceRuntime} + return cur, true }) - m.clearReactions(id) - return err } -// RunningSessions snapshots every non-terminal session for the reaper to probe. -// Detecting sessions are included — a fresh probe is the only fact that recovers -// or escalates them. -func (m *Manager) RunningSessions(ctx context.Context) ([]domain.SessionRecord, error) { - all, err := m.store.ListAllSessions(ctx) - if err != nil { - return nil, err - } - out := make([]domain.SessionRecord, 0, len(all)) - for _, rec := range all { - if !isTerminal(rec.Lifecycle.Session.State) { - out = append(out, rec) - } - } - return out, nil -} - -// ---- diff + metadata helpers ---- - -// setSessionState sets the state (and, for a terminal state, the reason) when it -// differs. An empty state means "decider doesn't address the session axis". -func setSessionState(next *domain.CanonicalSessionLifecycle, st domain.SessionState, reason domain.TerminationReason) bool { - if st == "" { - return false - } - changed := false - if next.Session.State != st { - next.Session.State, changed = st, true - } - want := domain.TermNone - if st == domain.SessionTerminated { - want = reason - } - if next.TerminationReason != want { - next.TerminationReason, changed = want, true - } - return changed -} - -func setDetecting(next *domain.CanonicalSessionLifecycle, d *domain.DetectingState) bool { - if d != nil { - if next.Detecting != nil && *next.Detecting == *d { - return false - } - dc := *d - next.Detecting = &dc - return true - } - if next.Detecting != nil { - next.Detecting = nil - return true - } - return false -} - -// sameActivity compares with time-aware equality (== on time.Time is -// monotonic-clock sensitive and would spuriously report changes). func sameActivity(a, b domain.ActivitySubstate) bool { return a.State == b.State && a.Source == b.Source && a.LastActivityAt.Equal(b.LastActivityAt) } -func spawnMetadata(o ports.SpawnOutcome) domain.SessionMetadata { - return domain.SessionMetadata{ - Branch: o.Branch, - WorkspacePath: o.WorkspacePath, - RuntimeHandleID: o.RuntimeHandle.ID, - RuntimeName: o.RuntimeHandle.RuntimeName, - AgentSessionID: o.AgentSessionID, - Prompt: o.Prompt, - } -} - -// mergeMetadata overlays set fields of in onto base without clobbering an -// existing value with an empty one (a partial spawn write keeps the branch set -// at creation). func mergeMetadata(base, in domain.SessionMetadata) domain.SessionMetadata { set := func(dst *string, v string) { if v != "" { @@ -337,7 +139,6 @@ func mergeMetadata(base, in domain.SessionMetadata) domain.SessionMetadata { set(&base.Branch, in.Branch) set(&base.WorkspacePath, in.WorkspacePath) set(&base.RuntimeHandleID, in.RuntimeHandleID) - set(&base.RuntimeName, in.RuntimeName) set(&base.AgentSessionID, in.AgentSessionID) set(&base.Prompt, in.Prompt) return base diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index 8adfd862ad..19f3616c5f 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -2,7 +2,7 @@ package lifecycle import ( "context" - "fmt" + "errors" "strings" "testing" "time" @@ -13,353 +13,199 @@ import ( var ctx = context.Background() -// ---- fakes ---- - -// fakeStore is a mini SessionStore + PRWriter: it derives PRFacts and recent -// check statuses from what the engine writes, so PR-reaction tests exercise the -// write path and the read-back together. type fakeStore struct { sessions map[domain.SessionID]domain.SessionRecord - pr map[domain.SessionID]domain.PRRow - comments map[string][]domain.PRComment - checks []domain.PRCheckRow - num int } func newFakeStore() *fakeStore { - return &fakeStore{ - sessions: map[domain.SessionID]domain.SessionRecord{}, - pr: map[domain.SessionID]domain.PRRow{}, - comments: map[string][]domain.PRComment{}, - } + return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}} } -func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { - f.num++ - rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num)) - f.sessions[rec.ID] = rec - return rec, nil -} -func (f *fakeStore) UpdateSession(_ context.Context, rec domain.SessionRecord) error { - f.sessions[rec.ID] = rec - return nil -} func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { r, ok := f.sessions[id] return r, ok, nil } -func (f *fakeStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) { - var out []domain.SessionRecord - for _, r := range f.sessions { - if r.ProjectID == p { - out = append(out, r) - } - } - return out, nil -} -func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) { - out := make([]domain.SessionRecord, 0, len(f.sessions)) - for _, r := range f.sessions { - out = append(out, r) - } - return out, nil -} -func (f *fakeStore) PRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, error) { - r, ok := f.pr[id] - if !ok { - return domain.PRFacts{}, nil - } - facts := domain.PRFacts{ - URL: r.URL, Number: r.Number, Exists: true, - Draft: r.Draft, Merged: r.Merged, Closed: r.Closed, - CI: r.CI, Review: r.Review, Mergeability: r.Mergeability, - } - for _, c := range f.comments[r.URL] { - if !c.Resolved { - facts.ReviewComments = true - break - } - } - return facts, nil -} -func (f *fakeStore) WritePR(_ context.Context, pr domain.PRRow, checks []domain.PRCheckRow, comments []domain.PRComment) error { - f.pr[domain.SessionID(pr.SessionID)] = pr - f.checks = append(f.checks, checks...) - f.comments[pr.URL] = comments - return nil -} -func (f *fakeStore) RecentCheckStatuses(_ context.Context, url, name string, limit int) ([]string, error) { - var out []string - for i := len(f.checks) - 1; i >= 0 && len(out) < limit; i-- { - if f.checks[i].PRURL == url && f.checks[i].Name == name { - out = append(out, f.checks[i].Status) - } - } - return out, nil -} - -type fakeNotifier struct{ events []ports.Event } - -func (f *fakeNotifier) Notify(_ context.Context, e ports.Event) error { - f.events = append(f.events, e) - return nil -} -func (f *fakeNotifier) last() string { - if len(f.events) == 0 { - return "" - } - return f.events[len(f.events)-1].Type -} - -type fakeMessenger struct{ msgs []string } -func (f *fakeMessenger) Send(_ context.Context, _ domain.SessionID, m string) error { - f.msgs = append(f.msgs, m) +func (f *fakeStore) UpdateSession(_ context.Context, rec domain.SessionRecord) error { + f.sessions[rec.ID] = rec return nil } -func newManager() (*Manager, *fakeStore, *fakeNotifier, *fakeMessenger) { - st, n, msg := newFakeStore(), &fakeNotifier{}, &fakeMessenger{} - return New(st, st, n, msg), st, n, msg +type fakeMessenger struct { + msgs []string + err error } -func working(id domain.SessionID) domain.SessionRecord { - return domain.SessionRecord{ - ID: id, ProjectID: "mer", - Lifecycle: domain.CanonicalSessionLifecycle{ - Version: domain.LifecycleVersion, - Session: domain.SessionSubstate{State: domain.SessionWorking}, - IsAlive: true, - }, +func (f *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error { + if f.err != nil { + return f.err } + f.msgs = append(f.msgs, msg) + return nil } -func openPR(o ports.PRObservation) ports.PRObservation { - o.Fetched, o.URL, o.Number = true, "https://example/pr/1", 1 - return o -} - -// ---- runtime observations ---- - -func TestRuntimeObservation_InferredDeath(t *testing.T) { - m, st, n, _ := newManager() - st.sessions["mer-1"] = working("mer-1") - - if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Runtime: ports.ProbeDead, Process: ports.ProbeDead}); err != nil { - t.Fatal(err) - } - got := st.sessions["mer-1"].Lifecycle - if got.Session.State != domain.SessionTerminated || got.TerminationReason != domain.TermRuntimeLost || got.IsAlive { - t.Fatalf("want terminated/runtime_lost/dead, got %+v", got) - } - if n.last() != "reaction.agent-exited" { - t.Fatalf("want agent-exited notify, got %q", n.last()) - } +func newManager() (*Manager, *fakeStore, *fakeMessenger) { + st := newFakeStore() + msg := &fakeMessenger{} + return New(st, msg), st, msg } -func TestRuntimeObservation_FailedProbeQuarantines(t *testing.T) { - m, st, _, _ := newManager() - st.sessions["mer-1"] = working("mer-1") - - if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Runtime: ports.ProbeFailed, Process: ports.ProbeFailed}); err != nil { - t.Fatal(err) - } - got := st.sessions["mer-1"].Lifecycle - if got.Session.State != domain.SessionDetecting || !got.IsAlive || got.Detecting == nil { - t.Fatalf("failed probe should quarantine alive, got %+v", got) - } +func working(id domain.SessionID) domain.SessionRecord { + return domain.SessionRecord{ID: id, ProjectID: "mer", Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: time.Now(), Source: domain.SourceNative}} } -func TestRuntimeObservation_RecoversDetecting(t *testing.T) { - m, st, _, _ := newManager() +func TestRuntimeObservation_InferredDeathSetsTerminated(t *testing.T) { + m, st, _ := newManager() rec := working("mer-1") - rec.Lifecycle.Session.State = domain.SessionDetecting - rec.Lifecycle.Detecting = &domain.DetectingState{Attempts: 1} + rec.Activity.LastActivityAt = time.Now().Add(-2 * time.Minute) st.sessions["mer-1"] = rec - - if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Runtime: ports.ProbeAlive, Process: ports.ProbeAlive}); err != nil { + if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Probe: ports.ProbeDead}); err != nil { t.Fatal(err) } - got := st.sessions["mer-1"].Lifecycle - if got.Session.State != domain.SessionWorking || got.Detecting != nil { - t.Fatalf("healthy probe should recover to working, got %+v", got) + got := st.sessions["mer-1"] + if !got.IsTerminated || got.Activity.State != domain.ActivityExited { + t.Fatalf("want terminated/exited, got %+v", got) } } -// ---- activity signals ---- - -func TestActivity_WaitingInputPagesHuman(t *testing.T) { - m, st, n, _ := newManager() +func TestRuntimeObservation_FailedProbeDoesNotMutate(t *testing.T) { + m, st, _ := newManager() st.sessions["mer-1"] = working("mer-1") - - if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityWaitingInput, Timestamp: time.Now()}); err != nil { + before := st.sessions["mer-1"] + if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Probe: ports.ProbeFailed}); err != nil { t.Fatal(err) } - if st.sessions["mer-1"].Lifecycle.Session.State != domain.SessionNeedsInput { - t.Fatalf("want needs_input, got %v", st.sessions["mer-1"].Lifecycle.Session.State) - } - if n.last() != "reaction.agent-needs-input" { - t.Fatalf("want needs-input notify, got %q", n.last()) + if st.sessions["mer-1"] != before { + t.Fatalf("failed probe should not persist a state, got %+v", st.sessions["mer-1"]) } } func TestActivity_InvalidIsIgnored(t *testing.T) { - m, st, _, _ := newManager() + m, st, _ := newManager() st.sessions["mer-1"] = working("mer-1") before := st.sessions["mer-1"] - if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: false, State: domain.ActivityIdle}); err != nil { t.Fatal(err) } if st.sessions["mer-1"] != before { - t.Fatal("invalid signal must not mutate the session") + t.Fatal("invalid signal must not mutate") } } -// ---- PR observations ---- - -func TestPR_CIFailingNudgesAgentWithLogs(t *testing.T) { - m, st, _, msg := newManager() +func TestActivity_WeakerSourceDoesNotOverrideStronger(t *testing.T) { + m, st, _ := newManager() st.sessions["mer-1"] = working("mer-1") - - o := openPR(ports.PRObservation{CI: domain.CIFailing, Checks: []domain.PRCheckRow{{Name: "build", CommitHash: "c1", Status: "failed", LogTail: "boom"}}}) - if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { + before := st.sessions["mer-1"] + if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityIdle, Source: domain.SourceRuntime}); err != nil { t.Fatal(err) } - if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "boom") { - t.Fatalf("want one CI nudge with log tail, got %v", msg.msgs) + if st.sessions["mer-1"] != before { + t.Fatalf("weaker runtime signal should not override native activity, got %+v", st.sessions["mer-1"]) } } -func TestPR_CIBrakeEscalatesAfterThreeFails(t *testing.T) { - m, st, n, msg := newManager() - st.sessions["mer-1"] = working("mer-1") - - for _, commit := range []string{"c1", "c2", "c3"} { - o := openPR(ports.PRObservation{CI: domain.CIFailing, Checks: []domain.PRCheckRow{{Name: "build", CommitHash: commit, Status: "failed", LogTail: "boom"}}}) - if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { - t.Fatal(err) - } - } - if len(msg.msgs) != 2 { - t.Fatalf("want 2 nudges then escalate, got %d nudges", len(msg.msgs)) +func TestActivity_StrongerSourceOverridesWeaker(t *testing.T) { + m, st, _ := newManager() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: time.Now(), Source: domain.SourceRuntime}} + if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityActive, Source: domain.SourceNative}); err != nil { + t.Fatal(err) } - if n.last() != "reaction.escalated" { - t.Fatalf("3rd failure should escalate, got %q", n.last()) + got := st.sessions["mer-1"].Activity + if got.State != domain.ActivityActive || got.Source != domain.SourceNative { + t.Fatalf("stronger native signal should override runtime, got %+v", got) } } -func TestPR_ReviewCommentsInjectedRegardlessOfAuthor(t *testing.T) { - m, st, _, msg := newManager() +func TestMarkTerminated(t *testing.T) { + m, st, _ := newManager() st.sessions["mer-1"] = working("mer-1") - - o := openPR(ports.PRObservation{ - Review: domain.ReviewChangesRequest, - Comments: []domain.PRComment{{ID: "1", Author: "greptileai", Body: "use a constant here"}}, - }) - if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { + if err := m.MarkTerminated(ctx, "mer-1"); err != nil { t.Fatal(err) } - if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "use a constant here") { - t.Fatalf("review feedback should be injected verbatim, got %v", msg.msgs) + got := st.sessions["mer-1"] + if !got.IsTerminated || got.Activity.State != domain.ActivityExited { + t.Fatalf("want terminated/exited, got %+v", got) } } -func TestPR_ApprovedAndGreenNotifies(t *testing.T) { - m, st, n, _ := newManager() +func TestMarkSpawnedStoresRuntimeMetadata(t *testing.T) { + m, st, _ := newManager() st.sessions["mer-1"] = working("mer-1") - - o := openPR(ports.PRObservation{Review: domain.ReviewApproved, Mergeability: domain.MergeMergeable}) - if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", IsTerminated: true} + metadata := domain.SessionMetadata{Branch: "b", WorkspacePath: "/ws", RuntimeHandleID: "h1", AgentSessionID: "agent", Prompt: "prompt"} + if err := m.MarkSpawned(ctx, "mer-1", metadata); err != nil { t.Fatal(err) } - if n.last() != "reaction.approved-and-green" { - t.Fatalf("want approved-and-green, got %q", n.last()) + got := st.sessions["mer-1"] + if got.IsTerminated || got.Activity.State != domain.ActivityIdle || got.Metadata.RuntimeHandleID != "h1" { + t.Fatalf("spawn metadata wrong: %+v", got) } } -func TestPR_MergeTerminatesSession(t *testing.T) { - m, st, n, _ := newManager() +func TestPRObservation_CIFailingNudgesAgentWithLogs(t *testing.T) { + m, st, msg := newManager() st.sessions["mer-1"] = working("mer-1") - - o := openPR(ports.PRObservation{Merged: true}) + o := ports.PRObservation{Fetched: true, URL: "pr1", CI: domain.CIFailing, Checks: []ports.PRCheckObservation{{Name: "build", CommitHash: "c1", Status: domain.PRCheckFailed, LogTail: "boom"}}} if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { t.Fatal(err) } - got := st.sessions["mer-1"].Lifecycle - if got.Session.State != domain.SessionTerminated || got.TerminationReason != domain.TermPRMerged { - t.Fatalf("merge should terminate with pr_merged, got %+v", got) - } - if n.last() != "reaction.pr-merged" { - t.Fatalf("want pr-merged notify, got %q", n.last()) + if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "boom") { + t.Fatalf("want one CI nudge with log tail, got %v", msg.msgs) } } -func TestPR_FailedFetchIsDropped(t *testing.T) { - m, st, _, msg := newManager() +func TestPRObservation_ReviewCommentsNudgeAgent(t *testing.T) { + m, st, msg := newManager() st.sessions["mer-1"] = working("mer-1") - - if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: false, CI: domain.CIFailing}); err != nil { + o := ports.PRObservation{Fetched: true, URL: "pr1", Review: domain.ReviewChangesRequest, Comments: []ports.PRCommentObservation{{ID: "1", Body: "fix this"}}} + if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { t.Fatal(err) } - if len(msg.msgs) != 0 || len(st.pr) != 0 { - t.Fatal("a failed fetch must write nothing and fire nothing") + if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "fix this") { + t.Fatalf("want review nudge, got %v", msg.msgs) } } -// ---- explicit kill ---- - -func TestKill_TerminatesWithoutReacting(t *testing.T) { - m, st, n, _ := newManager() +func TestPRObservation_MergeConflictNudgesAgent(t *testing.T) { + m, st, msg := newManager() st.sessions["mer-1"] = working("mer-1") - - if err := m.OnKillRequested(ctx, "mer-1", domain.TermManuallyKilled); err != nil { + o := ports.PRObservation{Fetched: true, URL: "pr1", Mergeability: domain.MergeConflicting} + if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { t.Fatal(err) } - got := st.sessions["mer-1"].Lifecycle - if got.Session.State != domain.SessionTerminated || got.TerminationReason != domain.TermManuallyKilled || got.IsAlive { - t.Fatalf("want terminated/manually_killed/dead, got %+v", got) - } - if len(n.events) != 0 { - t.Fatal("an explicit kill must not fire a reaction") + if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "merge conflicts") { + t.Fatalf("want merge-conflict nudge, got %v", msg.msgs) } } -// ---- duration escalation ---- - -func TestTickEscalations_DurationPagesHuman(t *testing.T) { - m, st, n, msg := newManager() - now := time.Now() - m.clock = func() time.Time { return now } +func TestPRObservation_MergedTerminatesWithoutNudge(t *testing.T) { + m, st, msg := newManager() st.sessions["mer-1"] = working("mer-1") - - o := openPR(ports.PRObservation{Mergeability: domain.MergeConflicting}) - if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { + if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: true, URL: "pr1", Merged: true}); err != nil { t.Fatal(err) } - if len(msg.msgs) != 1 { - t.Fatalf("merge-conflict should nudge once, got %d", len(msg.msgs)) + got := st.sessions["mer-1"] + if !got.IsTerminated || got.Activity.State != domain.ActivityExited { + t.Fatalf("merged PR should terminate session, got %+v", got) } - if err := m.TickEscalations(ctx, now.Add(16*time.Minute)); err != nil { - t.Fatal(err) - } - if n.last() != "reaction.escalated" { - t.Fatalf("unaddressed conflict should escalate after 15m, got %q", n.last()) + if len(msg.msgs) != 0 { + t.Fatalf("merged PR should not send nudge, got %v", msg.msgs) } } -func TestRunningSessions_ExcludesTerminal(t *testing.T) { - m, st, _, _ := newManager() +func TestPRObservation_RetriesAfterMessengerFailure(t *testing.T) { + m, st, msg := newManager() st.sessions["mer-1"] = working("mer-1") - dead := working("mer-2") - dead.Lifecycle.Session.State = domain.SessionTerminated - st.sessions["mer-2"] = dead - - got, err := m.RunningSessions(ctx) - if err != nil { + o := ports.PRObservation{Fetched: true, URL: "pr1", Mergeability: domain.MergeConflicting} + msg.err = errors.New("temporary send failure") + if err := m.ApplyPRObservation(ctx, "mer-1", o); err == nil { + t.Fatal("want send error") + } + msg.err = nil + if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil { t.Fatal(err) } - if len(got) != 1 || got[0].ID != "mer-1" { - t.Fatalf("want only the live session, got %+v", got) + if len(msg.msgs) != 1 { + t.Fatalf("want retry to send once, got %v", msg.msgs) } } diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index 44419aa651..3f056c55cc 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -1,413 +1,117 @@ package lifecycle -// reactions.go is the ACT layer: after a persisted transition the engine maps -// the session's (state, PR facts) to at most one reaction and dispatches it — -// nudging the agent or paging the human. Two reactions inject live content (CI -// logs, review comments) and re-fire when that content changes; the rest fire -// once on entry, with duration escalation driven by TickEscalations. -// -// Budgets are in-memory: a restart re-arms them, which costs a few extra nudges, -// never a missed page. - import ( "context" - "fmt" "strings" "sync" - "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -type reactionKey string - -const ( - rxCIFailed reactionKey = "ci-failed" - rxReviewComments reactionKey = "review-comments" - rxMergeConflicts reactionKey = "merge-conflicts" - rxIdle reactionKey = "agent-idle" - rxApprovedGreen reactionKey = "approved-and-green" - rxStuck reactionKey = "agent-stuck" - rxNeedsInput reactionKey = "agent-needs-input" - rxExited reactionKey = "agent-exited" - rxPRClosed reactionKey = "pr-closed" - rxMerged reactionKey = "pr-merged" -) - -// Brakes: stop auto-handling and page a human after this many failed attempts. -const ( - ciBrakeRuns = 3 // last N runs of a failing check all failed - reviewMaxNudge = 3 // re-nudged the agent N times over new review feedback -) - -// reactionConfig is one row of the reaction table. toAgent reactions nudge the -// agent; the rest notify the human. escalateAfter (when set) drives a -// duration-based escalation via TickEscalations. -type reactionConfig struct { - toAgent bool - message string - eventType string - priority ports.Priority - escalateAfter time.Duration -} - -var reactions = map[reactionKey]reactionConfig{ - rxCIFailed: {toAgent: true, eventType: "reaction.ci-failed", priority: ports.PriorityAction, message: "CI is failing on your PR. Review the output below and push a fix."}, - rxReviewComments: {toAgent: true, eventType: "reaction.review-comments", priority: ports.PriorityAction, message: "A reviewer left feedback on your PR. Address it and push."}, - rxMergeConflicts: {toAgent: true, eventType: "reaction.merge-conflicts", priority: ports.PriorityAction, escalateAfter: 15 * time.Minute, message: "Your PR has merge conflicts. Rebase onto the base branch and resolve them."}, - rxIdle: {toAgent: true, eventType: "reaction.agent-idle", priority: ports.PriorityInfo, escalateAfter: 15 * time.Minute, message: "You appear idle. Continue the task or say what is blocking you."}, - rxApprovedGreen: {eventType: "reaction.approved-and-green", priority: ports.PriorityAction, message: "PR is approved and green — ready to merge."}, - rxStuck: {eventType: "reaction.agent-stuck", priority: ports.PriorityUrgent, message: "Agent is stuck and needs attention."}, - rxNeedsInput: {eventType: "reaction.agent-needs-input", priority: ports.PriorityUrgent, message: "Agent needs input to continue."}, - rxExited: {eventType: "reaction.agent-exited", priority: ports.PriorityUrgent, message: "Agent process exited unexpectedly."}, - rxPRClosed: {eventType: "reaction.pr-closed", priority: ports.PriorityAction, message: "PR was closed without merging."}, - rxMerged: {eventType: "reaction.pr-merged", priority: ports.PriorityInfo, message: "PR merged — work complete."}, -} - -// reactionContent carries the live material the feedback reactions inject. Empty -// for runtime/activity transitions; populated from a PR observation. -type reactionContent struct { - ciCheck string - ciCommit string - ciURL string - ciLogTail string - comments []string - reviewSig string -} - -// prContent extracts the CI failure + review feedback from a PR observation. -func prContent(o ports.PRObservation) reactionContent { - c := reactionContent{} - for _, ch := range o.Checks { - if ch.Status == "failed" { - c.ciCheck, c.ciCommit, c.ciLogTail, c.ciURL = ch.Name, ch.CommitHash, ch.LogTail, o.URL - break - } - } - var ids []string - for _, cm := range o.Comments { - if cm.Resolved { - continue - } - c.comments = append(c.comments, cm.Body) - ids = append(ids, cm.ID) - } - c.reviewSig = strings.Join(ids, ",") - return c -} - -// ---- in-memory escalation state ---- - -type trackerKey struct { - id domain.SessionID - key reactionKey -} - -type tracker struct { - attempts int - firstAt time.Time - escalated bool - seenSig bool - lastSig string - projectID domain.ProjectID -} +const reviewMaxNudge = 3 type reactionState struct { mu sync.Mutex - trackers map[trackerKey]*tracker - lastKey map[domain.SessionID]reactionKey + seen map[string]string + attempts map[string]int } func newReactionState() reactionState { - return reactionState{trackers: map[trackerKey]*tracker{}, lastKey: map[domain.SessionID]reactionKey{}} + return reactionState{seen: map[string]string{}, attempts: map[string]int{}} } -// trackerFor returns the (id,key) tracker, creating it on first use. Caller holds mu. -func (rs *reactionState) trackerFor(id domain.SessionID, key reactionKey) *tracker { - k := trackerKey{id, key} - t := rs.trackers[k] - if t == nil { - t = &tracker{} - rs.trackers[k] = t +// ApplyPRObservation reacts to a fetched PR observation after the PR service has +// persisted it. It does not write PR rows; it owns PR-driven lifecycle effects +// and sends actionable agent nudges such as rebase, fix-CI, and +// address-review-feedback prompts. +func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o ports.PRObservation) error { + if !o.Fetched { + return nil } - return t -} - -func (m *Manager) clearReactions(id domain.SessionID) { - m.react.mu.Lock() - defer m.react.mu.Unlock() - for k := range m.react.trackers { - if k.id == id { - delete(m.react.trackers, k) - } + if o.Merged { + return m.MarkTerminated(ctx, id) + } + if o.Closed { + return nil } - delete(m.react.lastKey, id) -} - -// ---- dispatch ---- - -// runReactions is the chokepoint called after every persisted transition. It -// runs unlocked (the write lock is already released) so a busy agent send never -// blocks the write path. -func (m *Manager) runReactions(ctx context.Context, id domain.SessionID, content reactionContent) error { rec, ok, err := m.store.GetSession(ctx, id) if err != nil || !ok { return err } - lc := rec.Lifecycle - project := rec.ProjectID - - if isTerminal(lc.Session.State) { - err := m.dispatch(ctx, id, project, terminalReaction(lc.TerminationReason)) - m.clearReactions(id) // incident over: drop budgets after the final notify - return err - } - - pr, err := m.store.PRFactsForSession(ctx, id) - if err != nil { - return err + if rec.IsTerminated || rec.Activity.State == domain.ActivityBlocked || rec.Activity.State == domain.ActivityWaitingInput { + return nil } - - // Feedback reactions inject live content and re-fire as it changes — only - // while the agent can actually act on it. - if pr.Exists && !pr.Closed && !needsHuman(lc.Session.State) { - if pr.CI == domain.CIFailing && content.ciCheck != "" { - if err := m.handleCIFailure(ctx, id, project, content); err != nil { - return err + if o.CI == domain.CIFailing { + for _, ch := range o.Checks { + if ch.Status == domain.PRCheckFailed { + msg := "CI is failing on your PR. Review the output below and push a fix." + if ch.LogTail != "" { + msg += "\n\nFailing output:\n" + ch.LogTail + } + return m.sendOnce(ctx, id, "ci:"+o.URL+":"+ch.Name, ch.CommitHash+":"+ch.LogTail, msg, 0) } } - if hasReviewFeedback(pr) { - if err := m.handleReviewFeedback(ctx, id, project, content); err != nil { - return err - } - } - } - - return m.dispatch(ctx, id, project, reactionFor(lc, pr)) -} - -// dispatch fires the entry reaction for key, deduped so a steady state does not -// re-fire. Leaving a reaction drops its budget. -func (m *Manager) dispatch(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey) error { - m.react.mu.Lock() - if m.react.lastKey[id] == key { - m.react.mu.Unlock() - return nil - } - if prev := m.react.lastKey[id]; prev != "" { - delete(m.react.trackers, trackerKey{id, prev}) - } - m.react.lastKey[id] = key - m.react.mu.Unlock() - - if key == "" { - return nil } - cfg := reactions[key] - if cfg.toAgent { - return m.fireAgentEntry(ctx, id, project, key, cfg) - } - return m.fireNotify(ctx, id, project, key, cfg) -} - -// reactionFor maps (session state, PR facts) to the reaction to enter. CI failure -// and review feedback return "" here — they are handled by the feedback path. -func reactionFor(lc domain.CanonicalSessionLifecycle, pr domain.PRFacts) reactionKey { - switch lc.Session.State { - case domain.SessionStuck: - return rxStuck - case domain.SessionNeedsInput: - return rxNeedsInput - } - if pr.Exists { - if pr.Closed { - if !pr.Merged { - return rxPRClosed - } - return "" + if o.Review == domain.ReviewChangesRequest || hasUnresolvedComments(o.Comments) { + comments, sig := reviewContent(o.Comments) + msg := "A reviewer left feedback on your PR. Address it and push." + if comments != "" { + msg += "\n\n" + comments } - switch { - case pr.CI == domain.CIFailing, hasReviewFeedback(pr): - return "" // feedback path - case pr.Mergeability == domain.MergeConflicting: - return rxMergeConflicts - case pr.Mergeability == domain.MergeMergeable, pr.Review == domain.ReviewApproved: - return rxApprovedGreen + if sig == "" { + sig = string(o.Review) } + return m.sendOnce(ctx, id, "review:"+o.URL, sig, msg, reviewMaxNudge) } - if lc.Session.State == domain.SessionIdle { - return rxIdle + if o.Mergeability == domain.MergeConflicting { + return m.sendOnce(ctx, id, "merge-conflict:"+o.URL, string(o.Mergeability), "Your PR has merge conflicts. Rebase onto the base branch and resolve them.", 0) } - return "" -} - -func hasReviewFeedback(pr domain.PRFacts) bool { - return pr.Review == domain.ReviewChangesRequest || pr.ReviewComments -} - -func needsHuman(s domain.SessionState) bool { - return s == domain.SessionStuck || s == domain.SessionNeedsInput + return nil } -// terminalReaction is the notify fired when a session reaches a terminal state by -// inferred death. An explicit kill goes through OnKillRequested (no reaction); -// auto_cleanup / pr_merged are notified elsewhere. -func terminalReaction(r domain.TerminationReason) reactionKey { - switch r { - case domain.TermRuntimeLost, domain.TermAgentProcessExited, domain.TermProbeFailure, domain.TermErrorInProcess: - return rxExited - default: - return "" +func hasUnresolvedComments(comments []ports.PRCommentObservation) bool { + for _, c := range comments { + if !c.Resolved { + return true + } } + return false } -// ---- feedback reactions (content-driven re-fire + brake) ---- - -func (m *Manager) handleCIFailure(ctx context.Context, id domain.SessionID, project domain.ProjectID, c reactionContent) error { - msg := reactions[rxCIFailed].message + "\n\nFailing output:\n" + c.ciLogTail - return m.fireFeedback(ctx, id, project, rxCIFailed, c.ciCommit, msg, func(int) (bool, error) { - st, err := m.pr.RecentCheckStatuses(ctx, c.ciURL, c.ciCheck, ciBrakeRuns) - if err != nil { - return false, err +func reviewContent(comments []ports.PRCommentObservation) (string, string) { + var bodies []string + var ids []string + for _, c := range comments { + if c.Resolved { + continue } - return allFailed(st, ciBrakeRuns), nil - }) -} - -func (m *Manager) handleReviewFeedback(ctx context.Context, id domain.SessionID, project domain.ProjectID, c reactionContent) error { - msg := reactions[rxReviewComments].message - if len(c.comments) > 0 { - msg += "\n\n" + strings.Join(c.comments, "\n\n") + bodies = append(bodies, c.Body) + ids = append(ids, c.ID) } - return m.fireFeedback(ctx, id, project, rxReviewComments, c.reviewSig, msg, func(attempts int) (bool, error) { - return attempts > reviewMaxNudge, nil - }) + return strings.Join(bodies, "\n\n"), strings.Join(ids, ",") } -// fireFeedback nudges the agent with fresh content, deduped by signature so the -// same content is not re-sent each poll. braked decides whether to escalate to a -// human instead (CI: history; review: attempt count). -func (m *Manager) fireFeedback(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, sig, message string, braked func(attempts int) (bool, error)) error { - m.react.mu.Lock() - t := m.react.trackerFor(id, key) - if project != "" { - t.projectID = project - } - if t.escalated || (t.seenSig && t.lastSig == sig) { - m.react.mu.Unlock() +func (m *Manager) sendOnce(ctx context.Context, id domain.SessionID, key, sig, msg string, maxAttempts int) error { + if m.messenger == nil { return nil } - t.seenSig, t.lastSig = true, sig - t.attempts++ - attempts, pid := t.attempts, t.projectID - m.react.lastKey[id] = key // feedback owns the slot so a later dispatch("") clears it - m.react.mu.Unlock() - - brake, err := braked(attempts) - if err != nil { - return err - } - if brake { - m.react.mu.Lock() - t.escalated = true - m.react.mu.Unlock() - cause := "max_attempts" - if key == rxCIFailed { - cause = "max_retries" - } - return m.escalate(ctx, id, pid, key, ports.EscalationEvent{Attempts: attempts, Cause: cause}) - } - return m.messenger.Send(ctx, id, message) -} - -// ---- entry reactions ---- - -// fireAgentEntry nudges the agent once on entry into a static reaction -// (idle/merge-conflicts); escalation is duration-based via TickEscalations. -func (m *Manager) fireAgentEntry(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, cfg reactionConfig) error { m.react.mu.Lock() - t := m.react.trackerFor(id, key) - if project != "" { - t.projectID = project - } - if t.escalated { + if m.react.seen[key] == sig { m.react.mu.Unlock() return nil } - if t.firstAt.IsZero() { - t.firstAt = m.clock() - } - t.attempts++ - m.react.mu.Unlock() - return m.messenger.Send(ctx, id, cfg.message) -} - -func (m *Manager) fireNotify(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, cfg reactionConfig) error { - return m.notifier.Notify(ctx, ports.Event{ - Type: cfg.eventType, Priority: cfg.priority, - SessionID: id, ProjectID: project, Message: cfg.message, - Reaction: &ports.ReactionEvent{Key: string(key), Action: "notify"}, - CauseKey: string(key), - OccurredAt: m.clock(), - }) -} - -func (m *Manager) escalate(ctx context.Context, id domain.SessionID, project domain.ProjectID, key reactionKey, esc ports.EscalationEvent) error { - if esc.Cause == "" { - esc.Cause = "max_attempts" - } - return m.notifier.Notify(ctx, ports.Event{ - Type: "reaction.escalated", Priority: ports.PriorityUrgent, - SessionID: id, ProjectID: project, - Message: fmt.Sprintf("Automatic handling of %q is exhausted — needs a human.", key), - Reaction: &ports.ReactionEvent{Key: string(key), Action: "escalated"}, - Escalation: &esc, - CauseKey: string(key) + ":" + esc.Cause, - OccurredAt: m.clock(), - }) -} - -// TickEscalations fires the duration-based escalations the synchronous engine -// cannot wake itself for. The reaper calls it on a timer. -func (m *Manager) TickEscalations(ctx context.Context, now time.Time) error { - type due struct { - id domain.SessionID - project domain.ProjectID - key reactionKey - attempts int - durationMs int64 + attempts := m.react.attempts[key] + if maxAttempts > 0 && attempts >= maxAttempts { + m.react.mu.Unlock() + return nil } - var fire []due - m.react.mu.Lock() - for k, t := range m.react.trackers { - if t.escalated { - continue - } - cfg := reactions[k.key] - if cfg.escalateAfter > 0 && !t.firstAt.IsZero() && now.Sub(t.firstAt) >= cfg.escalateAfter { - t.escalated = true - fire = append(fire, due{k.id, t.projectID, k.key, t.attempts, now.Sub(t.firstAt).Milliseconds()}) - } + if err := m.messenger.Send(ctx, id, msg); err != nil { + m.react.mu.Unlock() + return err } + m.react.seen[key] = sig + m.react.attempts[key] = attempts + 1 m.react.mu.Unlock() - - for _, d := range fire { - if err := m.escalate(ctx, d.id, d.project, d.key, ports.EscalationEvent{Attempts: d.attempts, Cause: "max_duration", DurationMs: d.durationMs}); err != nil { - return err - } - } return nil } - -func allFailed(statuses []string, n int) bool { - if len(statuses) < n { - return false - } - for i := 0; i < n; i++ { - if statuses[i] != "failed" { - return false - } - } - return true -} diff --git a/backend/internal/lifecycle/runtime.go b/backend/internal/lifecycle/runtime.go new file mode 100644 index 0000000000..58de7f5657 --- /dev/null +++ b/backend/internal/lifecycle/runtime.go @@ -0,0 +1,35 @@ +package lifecycle + +import ( + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const defaultRecentActivityWindow = 60 * time.Second + +func hasRecentActivity(a domain.ActivitySubstate, now time.Time, window time.Duration) bool { + switch { + case a.State == domain.ActivityExited: + return false + case a.State.IsSticky(): + return true + case a.LastActivityAt.IsZero(): + return false + default: + return now.Sub(a.LastActivityAt) <= window + } +} + +func runtimeClearlyDead(f ports.RuntimeFacts, activity domain.ActivitySubstate, now time.Time, window time.Duration) bool { + observedAt := timeOr(f.ObservedAt, now) + return f.Probe == ports.ProbeDead && !hasRecentActivity(activity, observedAt, window) +} + +func timeOr(t, fallback time.Time) time.Time { + if t.IsZero() { + return fallback + } + return t +} diff --git a/backend/internal/notification/dedupe.go b/backend/internal/notification/dedupe.go deleted file mode 100644 index a4eaf32634..0000000000 --- a/backend/internal/notification/dedupe.go +++ /dev/null @@ -1,74 +0,0 @@ -package notification - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -// ConditionHash returns a deterministic, compact hash over a condition vector. -func ConditionHash(parts ...string) string { - b, _ := json.Marshal(parts) - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:16]) -} - -// DedupeKey returns the stable durable notification idempotency key. -func DedupeKey(projectID domain.ProjectID, sessionID domain.SessionID, reactionKey, conditionHash string) string { - return fmt.Sprintf("v1:lifecycle:%s:%s:%s:%s", projectID, sessionID, reactionKey, conditionHash) -} - -// ComputeDedupeKey derives a restart-safe dedupe key from the lifecycle event -// plus current persisted state. It avoids PR updated_at because re-polling the -// same facts after daemon restart would otherwise create duplicate notifications. -func ComputeDedupeKey(event ports.Event, rec domain.SessionRecord, pr domain.PRFacts) string { - projectID := event.ProjectID - if projectID == "" { - projectID = rec.ProjectID - } - reactionKey := reactionKeyForEvent(event) - condition := []string{ - "session_state", string(rec.Lifecycle.Session.State), - "termination", string(rec.Lifecycle.TerminationReason), - "session_updated", timeKey(rec.UpdatedAt), - } - if pr.Exists { - condition = append(condition, - "pr_url", pr.URL, - "pr_number", fmt.Sprint(pr.Number), - "pr_draft", fmt.Sprint(pr.Draft), - "pr_merged", fmt.Sprint(pr.Merged), - "pr_closed", fmt.Sprint(pr.Closed), - "ci", string(pr.CI), - "review", string(pr.Review), - "mergeability", string(pr.Mergeability), - "review_comments", fmt.Sprint(pr.ReviewComments), - ) - } - if event.CauseKey != "" { - condition = append(condition, "cause_key", event.CauseKey) - } - if event.Escalation != nil { - condition = append(condition, "escalation_cause", event.Escalation.Cause) - } - return DedupeKey(projectID, event.SessionID, reactionKey, ConditionHash(condition...)) -} - -func reactionKeyForEvent(event ports.Event) string { - if event.Reaction != nil && event.Reaction.Key != "" { - return event.Reaction.Key - } - return reactionKeyFromType(event.Type) -} - -func timeKey(t time.Time) string { - if t.IsZero() { - return "" - } - return t.UTC().Format(time.RFC3339Nano) -} diff --git a/backend/internal/notification/dedupe_test.go b/backend/internal/notification/dedupe_test.go deleted file mode 100644 index 2730bc10ac..0000000000 --- a/backend/internal/notification/dedupe_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package notification - -import ( - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -func TestDedupeSameReactionConditionProducesSameKey(t *testing.T) { - rec := dedupeRecord("working", time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) - e := ports.Event{SessionID: "ao-1", Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"}} - - k1 := ComputeDedupeKey(e, rec, domain.PRFacts{}) - k2 := ComputeDedupeKey(e, rec, domain.PRFacts{}) - if k1 != k2 { - t.Fatalf("dedupe key unstable: %q != %q", k1, k2) - } -} - -func TestDedupeChangedConditionProducesNewKey(t *testing.T) { - e := ports.Event{SessionID: "ao-1", Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"}} - r1 := dedupeRecord("needs_input", time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) - r2 := dedupeRecord("needs_input", time.Date(2026, 1, 2, 3, 4, 6, 0, time.UTC)) - - if ComputeDedupeKey(e, r1, domain.PRFacts{}) == ComputeDedupeKey(e, r2, domain.PRFacts{}) { - t.Fatal("changed session updated timestamp should change dedupe key") - } -} - -func TestDedupeEscalationIncludesCauseAndDoesNotCollideWithBase(t *testing.T) { - rec := dedupeRecord("working", time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) - base := ports.Event{SessionID: "ao-1", Reaction: &ports.ReactionEvent{Key: "ci-failed", Action: "notify"}} - esc := ports.Event{ - SessionID: "ao-1", - Reaction: &ports.ReactionEvent{Key: "ci-failed", Action: "escalated"}, - Escalation: &ports.EscalationEvent{Attempts: 3, Cause: "max_retries"}, - } - otherCause := esc - otherCause.Escalation = &ports.EscalationEvent{Attempts: 3, Cause: "max_duration"} - - baseKey := ComputeDedupeKey(base, rec, domain.PRFacts{Exists: true, URL: "pr", CI: domain.CIFailing}) - escKey := ComputeDedupeKey(esc, rec, domain.PRFacts{Exists: true, URL: "pr", CI: domain.CIFailing}) - otherKey := ComputeDedupeKey(otherCause, rec, domain.PRFacts{Exists: true, URL: "pr", CI: domain.CIFailing}) - if baseKey == escKey { - t.Fatal("escalation dedupe key should not collide with base reaction") - } - if escKey == otherKey { - t.Fatal("escalation cause should affect dedupe key") - } -} - -func dedupeRecord(state domain.SessionState, updated time.Time) domain.SessionRecord { - return domain.SessionRecord{ - ID: "ao-1", - ProjectID: "ao", - Lifecycle: domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: state}, - }, - UpdatedAt: updated, - } -} diff --git a/backend/internal/notification/enqueuer.go b/backend/internal/notification/enqueuer.go deleted file mode 100644 index 686490d219..0000000000 --- a/backend/internal/notification/enqueuer.go +++ /dev/null @@ -1,53 +0,0 @@ -package notification - -import ( - "context" - "log/slog" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -// Store is the durable write-side used by the enqueuer. *sqlite.Store satisfies -// this interface. -type Store interface { - EnqueueNotification(ctx context.Context, row domain.Notification) (domain.Notification, bool, error) -} - -// Enqueuer is a store-backed ports.Notifier. It does not deliver to external -// sinks; it renders and persists the notification for later dashboard/app sinks. -type Enqueuer struct { - store Store - renderer *Renderer - logger *slog.Logger -} - -var _ ports.Notifier = (*Enqueuer)(nil) - -// NewEnqueuer returns a Notifier that renders events and persists the resulting -// notification rows via store, defaulting the logger to slog.Default. -func NewEnqueuer(store Store, renderer *Renderer, logger *slog.Logger) *Enqueuer { - if logger == nil { - logger = slog.Default() - } - return &Enqueuer{store: store, renderer: renderer, logger: logger} -} - -// Notify renders the event and enqueues the resulting notification row. -func (e *Enqueuer) Notify(ctx context.Context, event ports.Event) error { - row, err := e.renderer.Render(ctx, event) - if err != nil { - return err - } - saved, created, err := e.store.EnqueueNotification(ctx, row) - if err != nil { - return err - } - e.logger.DebugContext(ctx, "notification enqueued", - "id", saved.ID, - "session", saved.SessionID, - "semantic_type", saved.SemanticType, - "created", created, - ) - return nil -} diff --git a/backend/internal/notification/enqueuer_test.go b/backend/internal/notification/enqueuer_test.go deleted file mode 100644 index 1ed1446174..0000000000 --- a/backend/internal/notification/enqueuer_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package notification - -import ( - "context" - "io" - "log/slog" - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -type fakeNotificationStore struct { - row domain.Notification - created bool -} - -func (f *fakeNotificationStore) EnqueueNotification(_ context.Context, row domain.Notification) (domain.Notification, bool, error) { - f.row = row - f.created = true - return row, true, nil -} - -func TestEnqueuerRendersAndPersists(t *testing.T) { - store := &fakeNotificationStore{} - renderer := NewRenderer(fakeReader{rec: renderRecord()}) - enq := NewEnqueuer(store, renderer, slog.New(slog.NewTextHandler(io.Discard, nil))) - if err := enq.Notify(context.Background(), ports.Event{ - Type: "reaction.agent-needs-input", Priority: ports.PriorityUrgent, - ProjectID: "ao", SessionID: "ao-7", Message: "needs input", - Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"}, - }); err != nil { - t.Fatal(err) - } - if !store.created || store.row.SemanticType != "session.needs_input" || store.row.DedupeKey == "" { - t.Fatalf("store row not rendered: created=%v row=%+v", store.created, store.row) - } -} diff --git a/backend/internal/notification/payload.go b/backend/internal/notification/payload.go deleted file mode 100644 index b4abaaca7f..0000000000 --- a/backend/internal/notification/payload.go +++ /dev/null @@ -1,75 +0,0 @@ -package notification - -// PayloadSchemaVersion is the durable notification payload contract version. -const PayloadSchemaVersion = 3 - -// Payload is the provider-neutral, rich notification data shape persisted in -// SQLite. It intentionally mirrors legacy AO's NotificationData V3 while only -// filling fields the Go rewrite can source today. -type Payload struct { - SchemaVersion int `json:"schemaVersion"` - SemanticType string `json:"semanticType"` - Subject SubjectPayload `json:"subject"` - Reaction *ReactionPayload `json:"reaction,omitempty"` - Escalation *EscalationPayload `json:"escalation,omitempty"` - CI *CIPayload `json:"ci,omitempty"` - Review *ReviewPayload `json:"review,omitempty"` - Merge *MergePayload `json:"merge,omitempty"` -} - -// SubjectPayload identifies what a notification is about — the session and, -// when relevant, its PR, issue, and branch. -type SubjectPayload struct { - Session *SessionSubjectPayload `json:"session,omitempty"` - PR *PRSubjectPayload `json:"pr,omitempty"` - Issue *IssueSubjectPayload `json:"issue,omitempty"` - Branch string `json:"branch,omitempty"` -} - -// SessionSubjectPayload identifies the session a notification concerns. -type SessionSubjectPayload struct { - ID string `json:"id"` - ProjectID string `json:"projectId"` -} - -// PRSubjectPayload identifies the PR a notification concerns. -type PRSubjectPayload struct { - Number int `json:"number,omitempty"` - URL string `json:"url,omitempty"` - Draft bool `json:"draft,omitempty"` -} - -// IssueSubjectPayload identifies the tracker issue a notification concerns. -type IssueSubjectPayload struct { - ID string `json:"id,omitempty"` -} - -// ReactionPayload carries the reaction that produced the notification. -type ReactionPayload struct { - Key string `json:"key"` - Action string `json:"action"` -} - -// EscalationPayload carries the escalation that produced the notification. -type EscalationPayload struct { - Attempts int `json:"attempts"` - Cause string `json:"cause"` - DurationMs int64 `json:"durationMs"` -} - -// CIPayload is the CI context of a notification. -type CIPayload struct { - Status string `json:"status"` -} - -// ReviewPayload is the review context of a notification. -type ReviewPayload struct { - Decision string `json:"decision"` -} - -// MergePayload is the merge-readiness context of a notification. -type MergePayload struct { - Ready *bool `json:"ready,omitempty"` - Conflicts *bool `json:"conflicts,omitempty"` - IsBehind *bool `json:"isBehind,omitempty"` -} diff --git a/backend/internal/notification/renderer.go b/backend/internal/notification/renderer.go deleted file mode 100644 index e10872cf57..0000000000 --- a/backend/internal/notification/renderer.go +++ /dev/null @@ -1,201 +0,0 @@ -package notification - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -// Reader is the subset of durable state the renderer rehydrates. *sqlite.Store -// satisfies it directly. -type Reader interface { - GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) - PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) -} - -// Renderer converts lifecycle notification events into durable notification rows. -type Renderer struct { - reader Reader - clock func() time.Time -} - -// NewRenderer returns a Renderer that sources session/PR facts via reader. -func NewRenderer(reader Reader) *Renderer { - return &Renderer{reader: reader, clock: time.Now} -} - -// Render builds a durable Notification (subject + typed payload) from a -// lifecycle Event. -func (r *Renderer) Render(ctx context.Context, event ports.Event) (domain.Notification, error) { - if event.SessionID == "" { - return domain.Notification{}, fmt.Errorf("render notification: missing session id") - } - rec, ok, err := r.reader.GetSession(ctx, event.SessionID) - if err != nil { - return domain.Notification{}, fmt.Errorf("render notification: get session %s: %w", event.SessionID, err) - } - if !ok { - return domain.Notification{}, fmt.Errorf("render notification: session %s not found", event.SessionID) - } - pr, err := r.reader.PRFactsForSession(ctx, event.SessionID) - if err != nil { - return domain.Notification{}, fmt.Errorf("render notification: pr facts for %s: %w", event.SessionID, err) - } - - projectID := event.ProjectID - if projectID == "" { - projectID = rec.ProjectID - } - reaction := reactionPayload(event) - semanticType := SemanticTypeForReaction(reaction.Key) - if semanticType == "" { - semanticType = event.Type - } - payload := Payload{ - SchemaVersion: PayloadSchemaVersion, - SemanticType: semanticType, - Subject: SubjectPayload{ - Session: &SessionSubjectPayload{ID: string(event.SessionID), ProjectID: string(projectID)}, - Branch: rec.Metadata.Branch, - }, - Reaction: &reaction, - } - if rec.IssueID != "" { - payload.Subject.Issue = &IssueSubjectPayload{ID: string(rec.IssueID)} - } - if pr.Exists { - payload.Subject.PR = &PRSubjectPayload{Number: pr.Number, URL: pr.URL, Draft: pr.Draft} - if pr.CI != "" { - payload.CI = &CIPayload{Status: string(pr.CI)} - } - if pr.Review != "" { - payload.Review = &ReviewPayload{Decision: string(pr.Review)} - } - payload.Merge = mergePayload(pr.Mergeability) - } - if event.Escalation != nil { - payload.Escalation = &EscalationPayload{ - Attempts: event.Escalation.Attempts, - Cause: event.Escalation.Cause, - DurationMs: event.Escalation.DurationMs, - } - } - - payloadJSON, err := json.Marshal(payload) - if err != nil { - return domain.Notification{}, fmt.Errorf("render notification payload: %w", err) - } - - occurredAt := event.OccurredAt - if occurredAt.IsZero() { - occurredAt = r.clock().UTC() - } - priority := string(event.Priority) - if priority == "" { - priority = string(ports.PriorityInfo) - } - dedupeKey := event.DedupeKey - if dedupeKey == "" { - dedupeKey = ComputeDedupeKey(event, rec, pr) - } - causeKey := event.CauseKey - if causeKey == "" { - causeKey = reaction.Key - if event.Escalation != nil && event.Escalation.Cause != "" { - causeKey += ":" + event.Escalation.Cause - } - } - - return domain.Notification{ - ProjectID: projectID, - SessionID: event.SessionID, - Source: "lifecycle", - EventType: event.Type, - SemanticType: semanticType, - Priority: priority, - Message: event.Message, - Payload: payloadJSON, - Actions: actionsFor(projectID, event.SessionID, pr), - DedupeKey: dedupeKey, - CauseKey: causeKey, - CreatedAt: occurredAt, - UpdatedAt: occurredAt, - }, nil -} - -func reactionPayload(event ports.Event) ReactionPayload { - key := reactionKeyFromType(event.Type) - action := "notify" - if event.Reaction != nil { - if event.Reaction.Key != "" { - key = event.Reaction.Key - } - if event.Reaction.Action != "" { - action = event.Reaction.Action - } - } - if event.Escalation != nil && event.Reaction == nil { - action = "escalated" - } - return ReactionPayload{Key: key, Action: action} -} - -func reactionKeyFromType(t string) string { - if strings.HasPrefix(t, "reaction.") { - return strings.TrimPrefix(t, "reaction.") - } - return t -} - -func mergePayload(m domain.Mergeability) *MergePayload { - if m == "" { - return nil - } - ready := m == domain.MergeMergeable - conflicts := m == domain.MergeConflicting - return &MergePayload{Ready: &ready, Conflicts: &conflicts} -} - -func actionsFor(projectID domain.ProjectID, sessionID domain.SessionID, pr domain.PRFacts) []domain.NotificationAction { - actions := []domain.NotificationAction{{ - ID: "open-session", - Kind: "route", - Label: "Open session", - Route: fmt.Sprintf("/projects/%s/sessions/%s", projectID, sessionID), - }} - if pr.Exists && pr.URL != "" { - actions = append(actions, domain.NotificationAction{ID: "open-pr", Kind: "url", Label: "Open PR", URL: pr.URL}) - } - return actions -} - -// SemanticTypeForReaction maps internal reaction keys to public semantic types. -func SemanticTypeForReaction(key string) string { - switch key { - case "approved-and-green": - return "merge.ready" - case "agent-stuck": - return "session.stuck" - case "agent-needs-input": - return "session.needs_input" - case "agent-exited": - return "session.exited" - case "pr-closed": - return "pr.closed" - case "pr-merged": - return "pr.merged" - case "ci-failed": - return "ci.failing" - case "review-comments": - return "review.changes_requested" - case "merge-conflicts": - return "merge.conflicts" - default: - return "" - } -} diff --git a/backend/internal/notification/renderer_test.go b/backend/internal/notification/renderer_test.go deleted file mode 100644 index 4cf70c9722..0000000000 --- a/backend/internal/notification/renderer_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package notification - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -type fakeReader struct { - rec domain.SessionRecord - pr domain.PRFacts -} - -func (f fakeReader) GetSession(context.Context, domain.SessionID) (domain.SessionRecord, bool, error) { - return f.rec, true, nil -} -func (f fakeReader) PRFactsForSession(context.Context, domain.SessionID) (domain.PRFacts, error) { - return f.pr, nil -} - -func TestSemanticTypeMapping(t *testing.T) { - cases := map[string]string{ - "approved-and-green": "merge.ready", - "agent-stuck": "session.stuck", - "agent-needs-input": "session.needs_input", - "agent-exited": "session.exited", - "pr-closed": "pr.closed", - "pr-merged": "pr.merged", - "ci-failed": "ci.failing", - "review-comments": "review.changes_requested", - "merge-conflicts": "merge.conflicts", - } - for key, want := range cases { - if got := SemanticTypeForReaction(key); got != want { - t.Fatalf("SemanticTypeForReaction(%q) = %q, want %q", key, got, want) - } - } -} - -func TestRendererPayloadIncludesSessionProjectIssueAndBranch(t *testing.T) { - r := NewRenderer(fakeReader{rec: renderRecord()}) - row, err := r.Render(context.Background(), ports.Event{ - Type: "reaction.agent-needs-input", Priority: ports.PriorityUrgent, - ProjectID: "ao", SessionID: "ao-7", Message: "needs input", - Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"}, - OccurredAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), - }) - if err != nil { - t.Fatal(err) - } - var p Payload - if err := json.Unmarshal(row.Payload, &p); err != nil { - t.Fatal(err) - } - if p.SchemaVersion != 3 || p.SemanticType != "session.needs_input" { - t.Fatalf("payload header = %+v", p) - } - if p.Subject.Session == nil || p.Subject.Session.ID != "ao-7" || p.Subject.Session.ProjectID != "ao" { - t.Fatalf("session subject missing: %+v", p.Subject.Session) - } - if p.Subject.Issue == nil || p.Subject.Issue.ID != "AO-12" || p.Subject.Branch != "feat/example" { - t.Fatalf("issue/branch missing: %+v", p.Subject) - } -} - -func TestRendererPRPayloadIncludesFacts(t *testing.T) { - r := NewRenderer(fakeReader{rec: renderRecord(), pr: domain.PRFacts{ - Exists: true, URL: "https://github.com/org/repo/pull/12", Number: 12, - CI: domain.CIFailing, Review: domain.ReviewChangesRequest, Mergeability: domain.MergeConflicting, - }}) - row, err := r.Render(context.Background(), ports.Event{ - Type: "reaction.review-comments", Priority: ports.PriorityAction, - ProjectID: "ao", SessionID: "ao-7", Message: "review", - Reaction: &ports.ReactionEvent{Key: "review-comments", Action: "notify"}, - }) - if err != nil { - t.Fatal(err) - } - var p Payload - if err := json.Unmarshal(row.Payload, &p); err != nil { - t.Fatal(err) - } - if p.Subject.PR == nil || p.Subject.PR.URL != "https://github.com/org/repo/pull/12" || p.Subject.PR.Number != 12 { - t.Fatalf("pr subject missing: %+v", p.Subject.PR) - } - if p.CI == nil || p.CI.Status != "failing" { - t.Fatalf("ci missing: %+v", p.CI) - } - if p.Review == nil || p.Review.Decision != "changes_requested" { - t.Fatalf("review missing: %+v", p.Review) - } - if p.Merge == nil || p.Merge.Conflicts == nil || *p.Merge.Conflicts != true || p.Merge.Ready == nil || *p.Merge.Ready != false { - t.Fatalf("merge missing: %+v", p.Merge) - } -} - -func TestRendererEscalationPayloadIncludesDetails(t *testing.T) { - r := NewRenderer(fakeReader{rec: renderRecord()}) - row, err := r.Render(context.Background(), ports.Event{ - Type: "reaction.escalated", Priority: ports.PriorityUrgent, - ProjectID: "ao", SessionID: "ao-7", Message: "escalated", - Reaction: &ports.ReactionEvent{Key: "ci-failed", Action: "escalated"}, - Escalation: &ports.EscalationEvent{Attempts: 3, Cause: "max_retries", DurationMs: 42}, - }) - if err != nil { - t.Fatal(err) - } - var p Payload - if err := json.Unmarshal(row.Payload, &p); err != nil { - t.Fatal(err) - } - if p.Reaction == nil || p.Reaction.Key != "ci-failed" || p.Reaction.Action != "escalated" { - t.Fatalf("reaction missing: %+v", p.Reaction) - } - if p.Escalation == nil || p.Escalation.Attempts != 3 || p.Escalation.Cause != "max_retries" || p.Escalation.DurationMs != 42 { - t.Fatalf("escalation missing: %+v", p.Escalation) - } -} - -func renderRecord() domain.SessionRecord { - return domain.SessionRecord{ - ID: "ao-7", - ProjectID: "ao", - IssueID: "AO-12", - Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionNeedsInput}}, - Metadata: domain.SessionMetadata{Branch: "feat/example"}, - UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), - } -} diff --git a/backend/internal/observe/reaper/reaper.go b/backend/internal/observe/reaper/reaper.go index 7edee2b10a..16812c9ba7 100644 --- a/backend/internal/observe/reaper/reaper.go +++ b/backend/internal/observe/reaper/reaper.go @@ -1,13 +1,9 @@ // Package reaper implements the OBSERVE-layer polling timer that supplies the -// LCM with the two facts the LCM cannot wake itself to discover: a periodic -// duration-based escalation heartbeat, and per-session runtime liveness probes. +// LCM with per-session runtime liveness probes. // -// The reaper sits OUTSIDE the LCM's per-session serial loop. It only REPORTS -// facts — it never decides whether a session is "truly" dead. The decider -// (anti-flap Detecting quarantine, terminal-session rules) is owned by the LCM -// and consumes these facts through the regular ApplyRuntimeObservation entry -// point. A probe error is reported as a probe-failure fact, never collapsed to -// "alive" or "dead", so the LCM's failed-probe ≠ dead invariant holds. +// The reaper only reports facts — it never writes session rows directly. The LCM +// consumes these facts through ApplyRuntimeObservation. A probe error is +// reported as a probe-failure fact, never collapsed to "alive" or "dead". package reaper import ( @@ -23,33 +19,14 @@ import ( // the design doc's 5s sampling window for runtime liveness. const DefaultTickInterval = 5 * time.Second -// RuntimeRegistry resolves a runtime adapter by the RuntimeName recorded in a -// session's RuntimeHandle. The reaper looks the runtime up per-session so a -// single reaper instance can probe tmux- and zellij-backed sessions side by -// side without knowing about either at construction. -type RuntimeRegistry interface { - Runtime(name string) (ports.Runtime, bool) -} - -// MapRegistry is the trivial RuntimeRegistry: a name->runtime map. Callers -// that need dynamic registration can implement RuntimeRegistry themselves. -type MapRegistry map[string]ports.Runtime - -// Runtime implements RuntimeRegistry. -func (m MapRegistry) Runtime(name string) (ports.Runtime, bool) { - rt, ok := m[name] - return rt, ok -} - // Config holds the externally-tunable knobs for a Reaper. Every field is -// optional; zero values fall back to safe defaults so production wiring (which -// only needs to inject the LCM and registry) and tests (which inject a clock -// plus a fast tick) can both stay terse. +// optional; zero values fall back to safe defaults so production wiring and +// tests can both stay terse. type Config struct { // Tick is the interval between ticks. <=0 means DefaultTickInterval. Tick time.Duration - // Clock supplies ObservedAt and TickEscalations now stamps. nil means - // time.Now. Injected in tests so assertions don't race wallclock. + // Clock supplies ObservedAt stamps. nil means time.Now. Injected in tests so + // assertions don't race wallclock. Clock func() time.Time // Logger receives operational diagnostics (probe errors, skipped sessions, // LCM call failures). The reaper logs but does not propagate these errors @@ -58,23 +35,36 @@ type Config struct { Logger *slog.Logger } +type sessionSource interface { + ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) +} + +type runtimeObservationSink interface { + ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error +} + +type runtimeProber interface { + IsAlive(context.Context, ports.RuntimeHandle) (bool, error) +} + // Reaper is the polling timer. Construct it with New; start the background // goroutine with Start, or drive a single cycle synchronously with Tick. type Reaper struct { - lcm ports.LifecycleManager - registry RuntimeRegistry + sink runtimeObservationSink + sessions sessionSource + runtime runtimeProber tick time.Duration clock func() time.Time logger *slog.Logger } -// New constructs a Reaper. The LCM is the sole writer destination (the reaper -// reports facts via ApplyRuntimeObservation and TickEscalations); the registry -// resolves the runtime adapter to use per session. -func New(lcm ports.LifecycleManager, registry RuntimeRegistry, cfg Config) *Reaper { +// New constructs a Reaper. sink is the lifecycle fact destination; sessions +// supplies the rows to probe; runtime checks whether a stored handle is alive. +func New(sink runtimeObservationSink, sessions sessionSource, runtime runtimeProber, cfg Config) *Reaper { r := &Reaper{ - lcm: lcm, - registry: registry, + sink: sink, + sessions: sessions, + runtime: runtime, tick: cfg.Tick, clock: cfg.Clock, logger: cfg.Logger, @@ -117,35 +107,27 @@ func (r *Reaper) loop(ctx context.Context, done chan<- struct{}) { } } -// Tick runs one observation cycle: it always fires TickEscalations first (the -// duration-based escalation heartbeat, which the synchronous LCM cannot wake -// itself to drive), then enumerates the LCM's running sessions, probes each -// one's runtime, and reports any non-alive result back as a fact. +// Tick runs one observation cycle: it enumerates non-terminated sessions, +// probes each one's runtime, and reports each result back as a fact. // // Tick is exported so the daemon (and tests) can drive cycles synchronously, // and so the Start goroutine has a single chokepoint to log against. // -// Errors: only the RunningSessions failure is propagated, since it short- -// circuits the rest of the cycle. TickEscalations and per-session -// ApplyRuntimeObservation failures are logged but never propagated — one -// failed call must not bring down the loop. +// Errors: only the session-listing failure is propagated, since it short- +// circuits the rest of the cycle. Per-session ApplyRuntimeObservation failures +// are logged but never propagated — one failed call must not bring down the loop. func (r *Reaper) Tick(ctx context.Context) error { now := r.clock() - // Heartbeat is best-effort and runs before enumeration so duration-based - // escalations still fire if the running-set lookup is the thing that - // errored. The LCM's TickEscalations is itself idempotent (no canonical - // writes) — at worst we miss escalating once and pick it up next tick. - if err := r.lcm.TickEscalations(ctx, now); err != nil { - r.logger.Error("reaper: TickEscalations failed", "err", err) - } - - sessions, err := r.lcm.RunningSessions(ctx) + sessions, err := r.sessions.ListAllSessions(ctx) if err != nil { return err } for _, sess := range sessions { + if sess.IsTerminated { + continue + } r.probeOne(ctx, sess, now) } return nil @@ -153,62 +135,47 @@ func (r *Reaper) Tick(ctx context.Context) error { // probeOne handles a single session's probe + fact-report. Every probe result — // alive, dead, or failed — is reported as a fact to the LCM. The reaper does -// not optimize away the "alive" case, because a session in Detecting (whose -// runtime axis is NOT alive) is included in the running set and needs the -// alive probe to recover; the reaper has no business deciding what counts as -// a no-op. The LCM's ApplyRuntimeObservation diffs against canonical and -// only Upserts on actual change, so steady-state alive is already cheap. +// not optimize away the "alive" case; the reaper has no business deciding what +// counts as a no-op. The LCM diffs and only writes on actual change. func (r *Reaper) probeOne(ctx context.Context, sess domain.SessionRecord, now time.Time) { handle, ok := handleFromRecord(sess) if !ok { // A session in the running-set without a handle is an anomaly worth - // surfacing (OnSpawnCompleted should have set both keys). Warn rather + // surfacing (MarkSpawned should have set both keys). Warn rather // than Debug so it doesn't hide behind a noisy log level. r.logger.Warn("reaper: session has no runtime handle metadata, skipping", "session", sess.ID) return } - rt, ok := r.registry.Runtime(handle.RuntimeName) - if !ok { - r.logger.Warn("reaper: no runtime registered for session, skipping", - "session", sess.ID, "runtime", handle.RuntimeName) - return - } - - alive, probeErr := rt.IsAlive(ctx, handle) + alive, probeErr := r.runtime.IsAlive(ctx, handle) facts := ports.RuntimeFacts{ObservedAt: now} switch { case probeErr != nil: // Failed probe must NOT be collapsed to alive — that would let a - // transient tmux/zellij outage hide a really-dead session, and a + // transient Zellij outage hide a really-dead session, and a // transient adapter bug terminate a really-alive one. Report failed - // and let the LCM's detecting quarantine arbitrate. - facts.Runtime = ports.ProbeFailed - facts.Process = ports.ProbeFailed + // and let the LCM arbitrate. + facts.Probe = ports.ProbeFailed r.logger.Debug("reaper: probe error reported as failed fact", - "session", sess.ID, "runtime", handle.RuntimeName, "err", probeErr) + "session", sess.ID, "err", probeErr) case alive: - facts.Runtime = ports.ProbeAlive - facts.Process = ports.ProbeAlive + facts.Probe = ports.ProbeAlive default: - facts.Runtime = ports.ProbeDead - facts.Process = ports.ProbeDead + facts.Probe = ports.ProbeDead } - if err := r.lcm.ApplyRuntimeObservation(ctx, sess.ID, facts); err != nil { + if err := r.sink.ApplyRuntimeObservation(ctx, sess.ID, facts); err != nil { r.logger.Error("reaper: ApplyRuntimeObservation failed", "session", sess.ID, "err", err) } } // handleFromRecord reconstructs the RuntimeHandle stored on the session by -// OnSpawnCompleted. Both fields are required; either being empty is the -// "session lacks a probable handle" signal that probeOne uses to skip. +// MarkSpawned. An empty handle id means the session cannot be probed. func handleFromRecord(rec domain.SessionRecord) (ports.RuntimeHandle, bool) { id := rec.Metadata.RuntimeHandleID - name := rec.Metadata.RuntimeName - if id == "" || name == "" { + if id == "" { return ports.RuntimeHandle{}, false } - return ports.RuntimeHandle{ID: id, RuntimeName: name}, true + return ports.RuntimeHandle{ID: id}, true } diff --git a/backend/internal/observe/reaper/reaper_test.go b/backend/internal/observe/reaper/reaper_test.go index ffb3eed453..a2c8457837 100644 --- a/backend/internal/observe/reaper/reaper_test.go +++ b/backend/internal/observe/reaper/reaper_test.go @@ -6,7 +6,6 @@ import ( "io" "log/slog" "testing" - "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -15,14 +14,9 @@ import ( var ctx = context.Background() type fakeLCM struct { - running []domain.SessionRecord - observed map[domain.SessionID]ports.RuntimeFacts - escalated int + observed map[domain.SessionID]ports.RuntimeFacts } -func (l *fakeLCM) RunningSessions(context.Context) ([]domain.SessionRecord, error) { - return l.running, nil -} func (l *fakeLCM) ApplyRuntimeObservation(_ context.Context, id domain.SessionID, f ports.RuntimeFacts) error { if l.observed == nil { l.observed = map[domain.SessionID]ports.RuntimeFacts{} @@ -30,18 +24,11 @@ func (l *fakeLCM) ApplyRuntimeObservation(_ context.Context, id domain.SessionID l.observed[id] = f return nil } -func (l *fakeLCM) TickEscalations(context.Context, time.Time) error { l.escalated++; return nil } -func (l *fakeLCM) ApplyActivitySignal(context.Context, domain.SessionID, ports.ActivitySignal) error { - return nil -} -func (l *fakeLCM) ApplyPRObservation(context.Context, domain.SessionID, ports.PRObservation) error { - return nil -} -func (l *fakeLCM) OnSpawnCompleted(context.Context, domain.SessionID, ports.SpawnOutcome) error { - return nil -} -func (l *fakeLCM) OnKillRequested(context.Context, domain.SessionID, domain.TerminationReason) error { - return nil + +type fakeSessions struct{ rows []domain.SessionRecord } + +func (s fakeSessions) ListAllSessions(context.Context) ([]domain.SessionRecord, error) { + return s.rows, nil } type fakeRuntime struct { @@ -49,10 +36,6 @@ type fakeRuntime struct { err error } -func (r fakeRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) { - return ports.RuntimeHandle{}, nil -} -func (r fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil } func (r fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { return r.alive, r.err } @@ -60,53 +43,57 @@ func (r fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) func probableSession(id domain.SessionID) domain.SessionRecord { return domain.SessionRecord{ ID: id, - Metadata: domain.SessionMetadata{RuntimeHandleID: "h1", RuntimeName: "tmux"}, - Lifecycle: domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: domain.SessionWorking}, - }, + Activity: domain.ActivitySubstate{State: domain.ActivityActive}, + Metadata: domain.SessionMetadata{RuntimeHandleID: "h1"}, } } func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -func newReaper(lcm *fakeLCM, rt fakeRuntime) *Reaper { - return New(lcm, MapRegistry{"tmux": rt}, Config{Logger: quietLogger()}) +func newReaper(lcm *fakeLCM, sessions fakeSessions, rt fakeRuntime) *Reaper { + return New(lcm, sessions, rt, Config{Logger: quietLogger()}) } func TestTick_ReportsAliveProbe(t *testing.T) { - lcm := &fakeLCM{running: []domain.SessionRecord{probableSession("mer-1")}} - if err := newReaper(lcm, fakeRuntime{alive: true}).Tick(ctx); err != nil { + lcm := &fakeLCM{} + sessions := fakeSessions{rows: []domain.SessionRecord{probableSession("mer-1")}} + if err := newReaper(lcm, sessions, fakeRuntime{alive: true}).Tick(ctx); err != nil { t.Fatal(err) } - if lcm.observed["mer-1"].Runtime != ports.ProbeAlive { - t.Fatalf("want alive probe, got %q", lcm.observed["mer-1"].Runtime) + if lcm.observed["mer-1"].Probe != ports.ProbeAlive { + t.Fatalf("want alive probe, got %q", lcm.observed["mer-1"].Probe) } } func TestTick_ReportsProbeErrorAsFailed(t *testing.T) { - lcm := &fakeLCM{running: []domain.SessionRecord{probableSession("mer-1")}} - if err := newReaper(lcm, fakeRuntime{err: errors.New("tmux gone")}).Tick(ctx); err != nil { + lcm := &fakeLCM{} + sessions := fakeSessions{rows: []domain.SessionRecord{probableSession("mer-1")}} + if err := newReaper(lcm, sessions, fakeRuntime{err: errors.New("Zellij gone")}).Tick(ctx); err != nil { t.Fatal(err) } - if lcm.observed["mer-1"].Runtime != ports.ProbeFailed { - t.Fatalf("probe error must be reported as failed, got %q", lcm.observed["mer-1"].Runtime) + if lcm.observed["mer-1"].Probe != ports.ProbeFailed { + t.Fatalf("probe error must be reported as failed, got %q", lcm.observed["mer-1"].Probe) } } -func TestTick_FiresEscalationHeartbeat(t *testing.T) { +func TestTick_SkipsTerminatedSession(t *testing.T) { lcm := &fakeLCM{} - if err := newReaper(lcm, fakeRuntime{}).Tick(ctx); err != nil { + dead := probableSession("mer-1") + dead.IsTerminated = true + sessions := fakeSessions{rows: []domain.SessionRecord{dead}} + if err := newReaper(lcm, sessions, fakeRuntime{alive: true}).Tick(ctx); err != nil { t.Fatal(err) } - if lcm.escalated != 1 { - t.Fatalf("tick must drive TickEscalations once, got %d", lcm.escalated) + if _, probed := lcm.observed["mer-1"]; probed { + t.Fatal("terminated sessions must not be probed") } } func TestTick_SkipsSessionWithoutHandle(t *testing.T) { + lcm := &fakeLCM{} noHandle := domain.SessionRecord{ID: "mer-1"} // no runtime metadata - lcm := &fakeLCM{running: []domain.SessionRecord{noHandle}} - if err := newReaper(lcm, fakeRuntime{alive: true}).Tick(ctx); err != nil { + sessions := fakeSessions{rows: []domain.SessionRecord{noHandle}} + if err := newReaper(lcm, sessions, fakeRuntime{alive: true}).Tick(ctx); err != nil { t.Fatal(err) } if _, probed := lcm.observed["mer-1"]; probed { diff --git a/backend/internal/ports/doc.go b/backend/internal/ports/doc.go new file mode 100644 index 0000000000..cbcc39a9c8 --- /dev/null +++ b/backend/internal/ports/doc.go @@ -0,0 +1,5 @@ +// Package ports declares boundary interfaces and DTOs used to connect core +// services to replaceable adapters such as runtimes, workspaces, trackers, and +// storage writers. Domain models stay in internal/domain; generated storage rows +// stay inside storage packages. +package ports diff --git a/backend/internal/ports/facts.go b/backend/internal/ports/facts.go deleted file mode 100644 index b119ecf634..0000000000 --- a/backend/internal/ports/facts.go +++ /dev/null @@ -1,69 +0,0 @@ -// Package ports declares the boundary contracts for the lifecycle lane: the -// inbound interfaces the engine implements, the outbound interfaces its adapters -// implement, and the plain DTOs that cross those edges. It holds no logic. -package ports - -import ( - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// ProbeResult is a single liveness reading. "failed" (the probe errored/timed -// out) and "unknown" (ran but couldn't tell) are kept distinct from dead — both -// route to the detecting quarantine, never to a death conclusion. -type ProbeResult string - -// Probe readings. Alive/Dead are conclusions; Failed/Unknown route to the -// detecting quarantine instead of a death decision. -const ( - ProbeAlive ProbeResult = "alive" - ProbeDead ProbeResult = "dead" - ProbeFailed ProbeResult = "failed" - ProbeUnknown ProbeResult = "unknown" -) - -// RuntimeFacts is what the reaper reports each probe: is the runtime container -// up, and is the agent process inside it up. -type RuntimeFacts struct { - ObservedAt time.Time - Runtime ProbeResult - Process ProbeResult -} - -// ActivitySignal is pushed by the agent hooks. Only a Valid signal is -// authoritative; a stale/absent one is ignored rather than read as idleness. -type ActivitySignal struct { - Valid bool - State domain.ActivityState - Timestamp time.Time - Source domain.ActivitySource -} - -// PRObservation is what the SCM poller reports for one PR. Fetched is the -// failed-fetch guard: when false the rest is meaningless and the engine must not -// read it as "PR closed". Checks/Comments are the current full sets (the engine -// records the checks and replaces the comment set). -type PRObservation struct { - Fetched bool - URL string - Number int - Draft bool - Merged bool - Closed bool - CI domain.CIState - Review domain.ReviewDecision - Mergeability domain.Mergeability - Checks []domain.PRCheckRow - Comments []domain.PRComment -} - -// SpawnOutcome is what the Session Manager reports once a spawn is live: the -// handles needed for later teardown/restore. -type SpawnOutcome struct { - Branch string - WorkspacePath string - RuntimeHandle RuntimeHandle - AgentSessionID string - Prompt string -} diff --git a/backend/internal/ports/inbound.go b/backend/internal/ports/inbound.go deleted file mode 100644 index fa472d0038..0000000000 --- a/backend/internal/ports/inbound.go +++ /dev/null @@ -1,53 +0,0 @@ -package ports - -import ( - "context" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// LifecycleManager is the inbound contract the engine implements. Observers -// (reaper, SCM poller, activity hooks) and the Session Manager call in; the LCM -// is the sole writer of canonical transitions and the only place reactions fire. -type LifecycleManager interface { - ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f RuntimeFacts) error - ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ActivitySignal) error - ApplyPRObservation(ctx context.Context, id domain.SessionID, o PRObservation) error - - // OnSpawnCompleted marks a session live and records its handles. It works for - // a fresh spawn (not_started -> live) and a restore (terminal -> reopened). - OnSpawnCompleted(ctx context.Context, id domain.SessionID, o SpawnOutcome) error - OnKillRequested(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) error - - // TickEscalations fires the duration-based escalations the synchronous LCM - // can't wake itself for; the reaper calls it on a timer. - TickEscalations(ctx context.Context, now time.Time) error - // RunningSessions snapshots every non-terminal session for the reaper to probe. - RunningSessions(ctx context.Context) ([]domain.SessionRecord, error) -} - -// SessionManager is the inbound contract the API/CLI call for explicit -// mutations. It drives the runtime/agent/workspace plugins and routes canonical -// writes to the LCM. -type SessionManager interface { - Spawn(ctx context.Context, cfg SpawnConfig) (domain.Session, error) - Kill(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) (freed bool, err error) - Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) - List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) - Get(ctx context.Context, id domain.SessionID) (domain.Session, error) - Send(ctx context.Context, id domain.SessionID, message string) error - Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) -} - -// SpawnConfig is the request to start a new session: which project/issue, which -// agent harness, and the branch/prompt/rules the agent launches with. -type SpawnConfig struct { - ProjectID domain.ProjectID - IssueID domain.IssueID - Kind domain.SessionKind - Harness domain.AgentHarness - Branch string - Prompt string - AgentRules string -} diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 58e1f509e7..765785c466 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -2,95 +2,28 @@ package ports import ( "context" - "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// SessionStore persists session records and serves the derived read-model's PR -// facts. The Session Manager creates rows; the Lifecycle Manager is the sole -// writer of canonical transitions thereafter. -type SessionStore interface { - CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) - UpdateSession(ctx context.Context, rec domain.SessionRecord) error - GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) - ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) - ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) - // PRFactsForSession returns the PR facts that drive a session's display - // status: the most-recently-updated non-closed PR, else the most recent. - // Zero value (Exists=false) means the session has no PR. - PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) -} - // PRWriter records the PR facts a PR observation carries. The pr table's own DB // triggers emit the CDC; this just writes the rows. type PRWriter interface { // WritePR persists a full PR observation — scalar facts, check runs, and the // replacement comment set — in one transaction, so the rows and the CDC // events they emit are all-or-nothing. - WritePR(ctx context.Context, pr domain.PRRow, checks []domain.PRCheckRow, comments []domain.PRComment) error - // RecentCheckStatuses reads the last `limit` runs of a check (the CI brake). - RecentCheckStatuses(ctx context.Context, prURL, name string, limit int) ([]string, error) -} - -// Notifier delivers an event to the human (desktop/Slack later). Push, never poll. -type Notifier interface { - Notify(ctx context.Context, event Event) error + WritePR(ctx context.Context, pr domain.PullRequest, checks []domain.PullRequestCheck, comments []domain.PullRequestComment) error } -// AgentMessenger injects a message into a running agent (busy-detecting until the -// agent is ready). Used by the auto-nudge reactions. +// AgentMessenger injects a message into a running agent. type AgentMessenger interface { Send(ctx context.Context, id domain.SessionID, message string) error } -// Priority ranks a notification's urgency so a notifier can decide how loudly -// to surface it, from PriorityUrgent down to PriorityInfo. -type Priority string - -// Notification priorities, highest urgency first. -const ( - PriorityUrgent Priority = "urgent" - PriorityAction Priority = "action" - PriorityWarning Priority = "warning" - PriorityInfo Priority = "info" -) - -// Event is a human-facing notification produced by a reaction. It carries the -// stable reaction/escalation context a durable notification renderer needs, -// while lifecycle remains responsible for deciding what should notify. -type Event struct { - Type string - Priority Priority - SessionID domain.SessionID - ProjectID domain.ProjectID - Message string - Reaction *ReactionEvent - Escalation *EscalationEvent - DedupeKey string - CauseKey string - OccurredAt time.Time -} - -// ReactionEvent is the reaction context carried on an Event: which reaction -// fired and whether it merely notified or escalated. -type ReactionEvent struct { - Key string // agent-needs-input, approved-and-green, ci-failed, etc. - Action string // notify | escalated -} - -// EscalationEvent is the escalation context carried on an Event once a reaction -// has exhausted its retry/attempt/duration budget. -type EscalationEvent struct { - Attempts int - Cause string // max_retries | max_attempts | max_duration - DurationMs int64 -} - -// ---- runtime / agent / workspace plugin ports (used by the Session Manager) ---- +// ---- runtime / agent / workspace plugin ports ---- -// Runtime is where a session's agent process runs — a tmux/zellij session or a -// bare process. The Session Manager creates one per session and tears it down. +// Runtime is the full runtime adapter contract: session creation/teardown plus +// liveness probing for reapers and terminal attachment. type Runtime interface { Create(ctx context.Context, cfg RuntimeConfig) (RuntimeHandle, error) Destroy(ctx context.Context, handle RuntimeHandle) error @@ -105,10 +38,10 @@ type RuntimeConfig struct { Env map[string]string } -// RuntimeHandle identifies a live runtime instance (e.g. a tmux session). +// RuntimeHandle identifies a live runtime instance. Its ID is opaque outside +// the concrete runtime adapter. type RuntimeHandle struct { - ID string - RuntimeName string + ID string } // Agent is the AI coding tool driving a session (claude-code, codex, …): it diff --git a/backend/internal/ports/pr_observations.go b/backend/internal/ports/pr_observations.go new file mode 100644 index 0000000000..91eac64b09 --- /dev/null +++ b/backend/internal/ports/pr_observations.go @@ -0,0 +1,40 @@ +package ports + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// PRObservation is what the SCM poller reports for one PR. Fetched is the +// failed-fetch guard: when false the rest is meaningless and lifecycle must not +// read it as "PR closed". Checks/Comments are observation DTOs, not persistence +// rows; the PR Manager owns mapping them into stored domain.PullRequest rows. +type PRObservation struct { + Fetched bool + URL string + Number int + Draft bool + Merged bool + Closed bool + CI domain.CIState + Review domain.ReviewDecision + Mergeability domain.Mergeability + Checks []PRCheckObservation + Comments []PRCommentObservation +} + +// PRCheckObservation is one SCM check result on the observed PR. +type PRCheckObservation struct { + Name string + CommitHash string + Status domain.PRCheckStatus + URL string + LogTail string +} + +// PRCommentObservation is one review comment observed on the PR. +type PRCommentObservation struct { + ID string + Author string + File string + Line int + Body string + Resolved bool +} diff --git a/backend/internal/ports/runtime_observations.go b/backend/internal/ports/runtime_observations.go new file mode 100644 index 0000000000..f81ffe67f1 --- /dev/null +++ b/backend/internal/ports/runtime_observations.go @@ -0,0 +1,34 @@ +package ports + +import ( + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// ProbeResult is a single liveness reading. "failed" means the probe errored +// or timed out and is never treated as a death conclusion. +type ProbeResult string + +// Probe readings. Alive/Dead are conclusions; Failed is ignored by lifecycle +// because it is not a reliable death decision. +const ( + ProbeAlive ProbeResult = "alive" + ProbeDead ProbeResult = "dead" + ProbeFailed ProbeResult = "failed" +) + +// RuntimeFacts is what the reaper reports each probe of a session runtime. +type RuntimeFacts struct { + ObservedAt time.Time + Probe ProbeResult +} + +// ActivitySignal is pushed by the agent hooks. Only a Valid signal is +// authoritative; a stale/absent one is ignored rather than read as idleness. +type ActivitySignal struct { + Valid bool + State domain.ActivityState + Timestamp time.Time + Source domain.ActivitySource +} diff --git a/backend/internal/ports/session.go b/backend/internal/ports/session.go new file mode 100644 index 0000000000..5696424504 --- /dev/null +++ b/backend/internal/ports/session.go @@ -0,0 +1,15 @@ +package ports + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// SpawnConfig is the request to start a new session: which project/issue, which +// agent harness, and the branch/prompt/rules the agent launches with. +type SpawnConfig struct { + ProjectID domain.ProjectID + IssueID domain.IssueID + Kind domain.SessionKind + Harness domain.AgentHarness + Branch string + Prompt string + AgentRules string +} diff --git a/backend/internal/ports/tracker.go b/backend/internal/ports/tracker.go index d9fac9104d..11411d9258 100644 --- a/backend/internal/ports/tracker.go +++ b/backend/internal/ports/tracker.go @@ -6,8 +6,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// Tracker is the outbound port for issue trackers (GitHub Issues, GitLab -// Issues, Linear). v1 is read-only: +// Tracker is the outbound read-only port for issue trackers: // // - Get returns a normalized snapshot of one issue, used by spawn-bootstrap // to hydrate the agent prompt. @@ -16,13 +15,8 @@ import ( // - Preflight verifies the configured credential is actually valid against // the provider so daemons fail fast at startup, not at first request. // -// Mirroring agent lifecycle back onto the tracker (Comment, Transition) is -// deferred to issue #40. The observer / polling loop is deferred to #35. -// -// All v1 providers share this interface. Provider differences (label vs -// state machine vs close reason) are absorbed inside each adapter via -// domain.NormalizedIssueState. Fields on domain.Issue exist only when every -// provider can populate them; richer per-provider metadata belongs behind a +// Provider differences are absorbed inside each adapter via +// domain.NormalizedIssueState. Richer per-provider metadata belongs behind a // separate port. type Tracker interface { Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) diff --git a/backend/internal/pr/manager.go b/backend/internal/pr/manager.go new file mode 100644 index 0000000000..86696ca0c8 --- /dev/null +++ b/backend/internal/pr/manager.go @@ -0,0 +1,67 @@ +// Package pr records SCM observations for pull requests associated with sessions. +package pr + +import ( + "context" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type lifecycle interface { + ApplyPRObservation(ctx context.Context, id domain.SessionID, o ports.PRObservation) error +} + +// Manager persists PR observations and forwards them to lifecycle for agent +// nudges and direct lifecycle effects. +type Manager struct { + writer ports.PRWriter + lifecycle lifecycle + clock func() time.Time +} + +// Deps are the collaborators a PR Manager needs. +type Deps struct { + Writer ports.PRWriter + Lifecycle lifecycle + Clock func() time.Time +} + +// New builds a PR Manager from its dependencies, defaulting the clock to time.Now. +func New(d Deps) *Manager { + m := &Manager{writer: d.Writer, lifecycle: d.Lifecycle, clock: d.Clock} + if m.clock == nil { + m.clock = time.Now + } + return m +} + +// ApplyObservation records a successfully fetched PR observation. Failed fetches +// are ignored because their fields are not authoritative facts. +func (m *Manager) ApplyObservation(ctx context.Context, id domain.SessionID, o ports.PRObservation) error { + if !o.Fetched { + return nil + } + if err := m.write(ctx, id, o); err != nil { + return err + } + if m.lifecycle == nil { + return nil + } + return m.lifecycle.ApplyPRObservation(ctx, id, o) +} + +func (m *Manager) write(ctx context.Context, id domain.SessionID, o ports.PRObservation) error { + now := m.clock() + row := domain.PullRequest{URL: o.URL, SessionID: id, Number: o.Number, Draft: o.Draft, Merged: o.Merged, Closed: o.Closed, CI: o.CI, Review: o.Review, Mergeability: o.Mergeability, UpdatedAt: now} + checks := make([]domain.PullRequestCheck, len(o.Checks)) + for i, c := range o.Checks { + checks[i] = domain.PullRequestCheck{Name: c.Name, CommitHash: c.CommitHash, Status: c.Status, URL: c.URL, LogTail: c.LogTail, CreatedAt: now} + } + comments := make([]domain.PullRequestComment, len(o.Comments)) + for i, c := range o.Comments { + comments[i] = domain.PullRequestComment{ID: c.ID, Author: c.Author, File: c.File, Line: c.Line, Body: c.Body, Resolved: c.Resolved, CreatedAt: now} + } + return m.writer.WritePR(ctx, row, checks, comments) +} diff --git a/backend/internal/pr/manager_test.go b/backend/internal/pr/manager_test.go new file mode 100644 index 0000000000..92acea5b52 --- /dev/null +++ b/backend/internal/pr/manager_test.go @@ -0,0 +1,87 @@ +package pr + +import ( + "context" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type fakeWriter struct { + pr map[domain.SessionID]domain.PullRequest + comments map[string][]domain.PullRequestComment + checks []domain.PullRequestCheck +} + +func (f *fakeWriter) WritePR(_ context.Context, pr domain.PullRequest, checks []domain.PullRequestCheck, comments []domain.PullRequestComment) error { + f.pr[pr.SessionID] = pr + f.checks = append(f.checks, checks...) + f.comments[pr.URL] = comments + return nil +} + +type fakeLifecycle struct { + observed []ports.PRObservation +} + +func (f *fakeLifecycle) ApplyPRObservation(_ context.Context, _ domain.SessionID, o ports.PRObservation) error { + f.observed = append(f.observed, o) + return nil +} + +func newPRManager() (*Manager, *fakeWriter, *fakeLifecycle) { + fw := &fakeWriter{pr: map[domain.SessionID]domain.PullRequest{}, comments: map[string][]domain.PullRequestComment{}} + fl := &fakeLifecycle{} + m := New(Deps{ + Writer: fw, + Lifecycle: fl, + Clock: func() time.Time { return time.Unix(1, 0).UTC() }, + }) + return m, fw, fl +} + +func TestApplyObservation_WritesPRChecksAndComments(t *testing.T) { + m, fw, fl := newPRManager() + o := ports.PRObservation{ + Fetched: true, URL: "https://example/pr/1", Number: 1, CI: domain.CIFailing, + Checks: []ports.PRCheckObservation{{Name: "build", CommitHash: "c1", Status: domain.PRCheckFailed, LogTail: "boom"}}, + Comments: []ports.PRCommentObservation{{ID: "1", Author: "greptileai", Body: "use a constant here"}}, + } + if err := m.ApplyObservation(context.Background(), "mer-1", o); err != nil { + t.Fatal(err) + } + if got := fw.pr["mer-1"]; got.URL != o.URL || got.CI != domain.CIFailing { + t.Fatalf("pr not written: %+v", got) + } + if len(fw.checks) != 1 || fw.checks[0].CreatedAt.IsZero() { + t.Fatalf("checks not normalized: %+v", fw.checks) + } + if len(fw.comments[o.URL]) != 1 || fw.comments[o.URL][0].CreatedAt.IsZero() { + t.Fatalf("comments not normalized: %+v", fw.comments) + } + if len(fl.observed) != 1 || fl.observed[0].URL != o.URL { + t.Fatalf("PR observation should be forwarded to lifecycle, got %v", fl.observed) + } +} + +func TestApplyObservation_MergedForwardsToLifecycle(t *testing.T) { + m, _, fl := newPRManager() + if err := m.ApplyObservation(context.Background(), "mer-1", ports.PRObservation{Fetched: true, URL: "pr1", Number: 1, Merged: true}); err != nil { + t.Fatal(err) + } + if len(fl.observed) != 1 || !fl.observed[0].Merged { + t.Fatalf("merged PR should be forwarded to lifecycle, got %v", fl.observed) + } +} + +func TestApplyObservation_FailedFetchIsDropped(t *testing.T) { + m, fw, fl := newPRManager() + if err := m.ApplyObservation(context.Background(), "mer-1", ports.PRObservation{Fetched: false, URL: "pr1", CI: domain.CIFailing}); err != nil { + t.Fatal(err) + } + if len(fw.pr) != 0 || len(fl.observed) != 0 { + t.Fatalf("failed fetch must write nothing, pr=%v observed=%v", fw.pr, fl.observed) + } +} diff --git a/backend/internal/processalive/process_unix.go b/backend/internal/processalive/process_unix.go new file mode 100644 index 0000000000..bf9349ad2c --- /dev/null +++ b/backend/internal/processalive/process_unix.go @@ -0,0 +1,20 @@ +//go:build !windows + +// Package processalive probes whether an operating-system process id still +// maps to a live process. +package processalive + +import ( + "errors" + "syscall" +) + +// Alive reports whether pid exists. EPERM counts as alive: the process exists +// even if the current user cannot signal it. +func Alive(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} diff --git a/backend/internal/processalive/process_windows.go b/backend/internal/processalive/process_windows.go new file mode 100644 index 0000000000..225726bf00 --- /dev/null +++ b/backend/internal/processalive/process_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +// Package processalive probes whether an operating-system process id still +// maps to a live process. +package processalive + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +// Alive reports whether pid exists. Access denied counts as alive: the process +// exists even if the current user cannot wait on it. +func Alive(pid int) bool { + if pid <= 0 { + return false + } + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if err != nil { + return errors.Is(err, windows.ERROR_ACCESS_DENIED) + } + defer windows.CloseHandle(handle) + + status, err := windows.WaitForSingleObject(handle, 0) + if err != nil { + return false + } + return status == uint32(windows.WAIT_TIMEOUT) +} diff --git a/backend/internal/project/dto.go b/backend/internal/project/dto.go index 0e6f5ee5ed..7146d455bf 100644 --- a/backend/internal/project/dto.go +++ b/backend/internal/project/dto.go @@ -30,11 +30,9 @@ type AddInput struct { // behaviour fields are mutable; identity fields (projectId, path, repo, // defaultBranch) are rejected by the handler with a 400 IDENTITY_FROZEN. type UpdateConfigInput struct { - Agent *string `json:"agent,omitempty"` - Runtime *string `json:"runtime,omitempty"` - Tracker *TrackerConfig `json:"tracker,omitempty"` - SCM *SCMConfig `json:"scm,omitempty"` - Reactions *map[string]*ReactionConfig `json:"reactions,omitempty"` + Agent *string `json:"agent,omitempty"` + Tracker *TrackerConfig `json:"tracker,omitempty"` + SCM *SCMConfig `json:"scm,omitempty"` } // RemoveResult reports what DELETE /api/v1/projects/{id} actually did. diff --git a/backend/internal/project/memory_store.go b/backend/internal/project/memory_store.go index e947136c26..c9f91a2ae0 100644 --- a/backend/internal/project/memory_store.go +++ b/backend/internal/project/memory_store.go @@ -18,8 +18,8 @@ type Row struct { ArchivedAt time.Time } -// Store is the project persistence the manager depends on; both the sqlite -// store and MemoryStore satisfy it. +// Store is the project persistence the manager depends on. MemoryStore is the +// current in-process implementation; the sqlite adapter uses the same row shape. type Store interface { List(ctx context.Context) ([]Row, error) Get(ctx context.Context, id string) (Row, bool, error) diff --git a/backend/internal/project/project.go b/backend/internal/project/project.go index a997519dff..14bf731ac0 100644 --- a/backend/internal/project/project.go +++ b/backend/internal/project/project.go @@ -2,12 +2,9 @@ // the HTTP layer calls and the request/response DTOs that cross it (dto.go). // // This is the pilot for the feature-package layout the backend is migrating -// toward: a resource's interface and DTOs live with the resource, not in a -// central catch-all. Controllers depend on project.Manager and nothing -// beneath it — whether the implementation reaches into the config registry, -// the lifecycle manager (to stop sessions on remove), or a workspace adapter -// (to destroy worktrees) is a private concern of the impl, which lands in a -// later handler-impl PR. This PR defines only the contract. +// toward: a resource's interface, implementation, and DTOs live with the +// resource, not in a central catch-all. Controllers depend on project.Manager +// and nothing beneath it. package project import ( @@ -17,7 +14,7 @@ import ( ) // Manager is the inbound contract for the /api/v1/projects surface. One -// implementation (this package, later); the HTTP controller is the consumer. +// implementation lives in this package; the HTTP controller is the consumer. type Manager interface { // List returns every registered project, including degraded entries // (those whose config failed to load but whose registry entry survives). diff --git a/backend/internal/project/types.go b/backend/internal/project/types.go index 65e5daa290..9e1e8b944e 100644 --- a/backend/internal/project/types.go +++ b/backend/internal/project/types.go @@ -10,11 +10,9 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain" // transport DTOs (dto.go) together is the feature-package layout the backend // is migrating toward. -// Summary is the row shape returned by GET /api/v1/projects. It mirrors the TS -// ProjectInfo (packages/web/src/lib/project-name.ts) so the existing dashboard -// list view reads the Go daemon's response unchanged. ResolveError is set only -// for degraded projects (registry entry survives but config failed to load), -// so the list shows them with a warning instead of dropping them silently. +// Summary is the row shape returned by GET /api/v1/projects. ResolveError is +// set only for degraded projects, so the list can show them with a warning +// instead of dropping them silently. type Summary struct { ID domain.ProjectID `json:"id"` Name string `json:"name"` @@ -26,16 +24,14 @@ type Summary struct { // project resolves cleanly. It joins the registry identity fields with the // project's behaviour config. type Project struct { - ID domain.ProjectID `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - Repo string `json:"repo"` // "owner/name" or "" - DefaultBranch string `json:"defaultBranch"` - Agent string `json:"agent,omitempty"` - Runtime string `json:"runtime,omitempty"` - Tracker *TrackerConfig `json:"tracker,omitempty"` - SCM *SCMConfig `json:"scm,omitempty"` - Reactions map[string]*ReactionConfig `json:"reactions,omitempty"` + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Repo string `json:"repo"` // "owner/name" or "" + DefaultBranch string `json:"defaultBranch"` + Agent string `json:"agent,omitempty"` + Tracker *TrackerConfig `json:"tracker,omitempty"` + SCM *SCMConfig `json:"scm,omitempty"` } // Degraded is returned in place of Project when the project's config failed to @@ -49,11 +45,9 @@ type Degraded struct { ResolveError string `json:"resolveError"` } -// Behaviour-config shapes ported from the TS Zod schemas (packages/core/src/ -// config.ts). Only the fields the projects API actually exposes are modelled; -// the passthrough/unknown-key round-trip the legacy schemas allowed lands with -// the handler implementation (and the SQLite persistence work), not in this -// interface-only PR. +// Behaviour-config shapes exposed by the projects API. Runtime selection and +// reaction rules are intentionally absent: the daemon has one runtime adapter and +// lifecycle owns agent nudges. // TrackerConfig mirrors TrackerConfigSchema. type TrackerConfig struct { @@ -80,17 +74,3 @@ type SCMWebhookConfig struct { DeliveryHeader string `json:"deliveryHeader,omitempty"` MaxBodyBytes int `json:"maxBodyBytes,omitempty"` } - -// ReactionConfig mirrors ReactionConfigSchema. EscalateAfter is either ms -// (number) or a duration string ("30m") in the TS schema, so it stays open as -// `any` until handler validation lands. -type ReactionConfig struct { - Auto *bool `json:"auto,omitempty"` - Action string `json:"action,omitempty"` // send-to-agent | notify | auto-merge - Message string `json:"message,omitempty"` - Priority string `json:"priority,omitempty"` // urgent | action | warning | info - Retries *int `json:"retries,omitempty"` - EscalateAfter any `json:"escalateAfter,omitempty"` - Threshold string `json:"threshold,omitempty"` - IncludeSummary *bool `json:"includeSummary,omitempty"` -} diff --git a/backend/internal/runfile/process_unix.go b/backend/internal/runfile/process_unix.go deleted file mode 100644 index efe957e182..0000000000 --- a/backend/internal/runfile/process_unix.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build unix - -package runfile - -import ( - "errors" - "os" - "syscall" -) - -// processAlive probes existence with signal 0: kill(pid, 0) returns nil if the -// process exists and we can signal it, EPERM if it exists but is owned by -// another user, and ESRCH (or any other error from FindProcess) if it is gone. -func processAlive(pid int) bool { - proc, err := os.FindProcess(pid) - if err != nil { - return false - } - err = proc.Signal(syscall.Signal(0)) - if err == nil { - return true - } - return errors.Is(err, syscall.EPERM) -} diff --git a/backend/internal/runfile/process_windows.go b/backend/internal/runfile/process_windows.go deleted file mode 100644 index 1f8e78fee0..0000000000 --- a/backend/internal/runfile/process_windows.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build windows - -package runfile - -import ( - "syscall" -) - -// processAlive opens the process with the minimum-rights query flag. On -// Windows, OpenProcess returns ERROR_INVALID_PARAMETER for a PID that no -// longer maps to a live process, and a usable handle when one is. We close -// the handle immediately; the only thing we needed was the open's outcome. -func processAlive(pid int) bool { - const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 - h, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) - if err != nil { - return false - } - _ = syscall.CloseHandle(h) - return true -} diff --git a/backend/internal/runfile/runfile.go b/backend/internal/runfile/runfile.go index 3db84590c5..92718d34c1 100644 --- a/backend/internal/runfile/runfile.go +++ b/backend/internal/runfile/runfile.go @@ -12,6 +12,8 @@ import ( "os" "path/filepath" "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/processalive" ) // Info is the on-disk handshake payload. @@ -86,6 +88,20 @@ func Remove(path string) error { return nil } +// RemoveIfOwned deletes running.json only if it still belongs to ownerPID. This +// prevents a shutting-down daemon from removing a successor's freshly written +// handshake after an overlapping restart. +func RemoveIfOwned(path string, ownerPID int) error { + info, err := Read(path) + if err != nil { + return err + } + if info == nil || info.PID != ownerPID { + return nil + } + return Remove(path) +} + // CheckStale inspects an existing run-file before the new daemon binds. It // returns: // @@ -104,7 +120,7 @@ func CheckStale(path string) (*Info, error) { if info == nil || info.PID <= 0 { return nil, nil } - if processAlive(info.PID) { + if processalive.Alive(info.PID) { return info, nil } return nil, nil diff --git a/backend/internal/runfile/runfile_test.go b/backend/internal/runfile/runfile_test.go index fbdf74e07f..6a926874b1 100644 --- a/backend/internal/runfile/runfile_test.go +++ b/backend/internal/runfile/runfile_test.go @@ -75,6 +75,32 @@ func TestRemoveIdempotent(t *testing.T) { } } +func TestRemoveIfOwnedDoesNotDeleteSuccessorRunfile(t *testing.T) { + path := filepath.Join(t.TempDir(), "running.json") + if err := Write(path, Info{PID: 1, Port: 3001}); err != nil { + t.Fatalf("Write predecessor: %v", err) + } + if err := Write(path, Info{PID: 2, Port: 3002}); err != nil { + t.Fatalf("Write successor: %v", err) + } + if err := RemoveIfOwned(path, 1); err != nil { + t.Fatalf("RemoveIfOwned predecessor: %v", err) + } + got, err := Read(path) + if err != nil { + t.Fatalf("Read: %v", err) + } + if got == nil || got.PID != 2 || got.Port != 3002 { + t.Fatalf("successor runfile was removed or changed: %+v", got) + } + if err := RemoveIfOwned(path, 2); err != nil { + t.Fatalf("RemoveIfOwned successor: %v", err) + } + if got, err := Read(path); err != nil || got != nil { + t.Fatalf("after owner removal got=%+v err=%v", got, err) + } +} + func TestCheckStaleDeadPID(t *testing.T) { path := filepath.Join(t.TempDir(), "running.json") // PID 0x7FFFFFFF is effectively guaranteed not to exist. diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index 37b1de813e..82576dad41 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -1,7 +1,6 @@ -// Package session implements ports.SessionManager: the explicit-mutation half of -// the lane. It drives the runtime/agent/workspace plugins to create and tear -// down sessions, routes canonical writes to the LCM, and is the single producer -// of the derived display status (attached on read in List/Get). +// Package session drives the runtime/agent/workspace plugins to create and tear +// down sessions, routes durable lifecycle fact writes through lifecycle, and +// attaches derived display status on read. package session import ( @@ -28,27 +27,43 @@ const ( EnvIssueID = "AO_ISSUE_ID" ) -// Manager implements ports.SessionManager over the outbound ports. +type lifecycleRecorder interface { + MarkSpawned(ctx context.Context, id domain.SessionID, metadata domain.SessionMetadata) error + MarkTerminated(ctx context.Context, id domain.SessionID) error +} + +type runtimeController interface { + Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) + Destroy(ctx context.Context, handle ports.RuntimeHandle) error +} + +type sessionStore interface { + CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) + GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) + ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) + GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) +} + +// Manager coordinates session spawn, restore, kill, listing, and cleanup over +// the outbound ports. type Manager struct { - runtime ports.Runtime + runtime runtimeController agent ports.Agent workspace ports.Workspace - store ports.SessionStore + store sessionStore messenger ports.AgentMessenger - lcm ports.LifecycleManager + lcm lifecycleRecorder clock func() time.Time } -var _ ports.SessionManager = (*Manager)(nil) - // Deps are the collaborators a Session Manager needs; New wires them together. type Deps struct { - Runtime ports.Runtime + Runtime runtimeController Agent ports.Agent Workspace ports.Workspace - Store ports.SessionStore + Store sessionStore Messenger ports.AgentMessenger - Lifecycle ports.LifecycleManager + Lifecycle lifecycleRecorder Clock func() time.Time } @@ -72,7 +87,7 @@ func New(d Deps) *Manager { // Spawn creates the session row (which assigns the "{project}-{n}" id), then the // workspace and runtime, then reports completion to the LCM. A failure after the -// row exists routes it to a terminal errored state and rolls back what was built. +// row exists parks it as terminated and rolls back what was built. func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { rec, err := m.store.CreateSession(ctx, seedRecord(cfg, m.clock())) if err != nil { @@ -82,7 +97,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ProjectID: cfg.ProjectID, SessionID: id, Branch: cfg.Branch}) if err != nil { - m.markErrored(ctx, id) + m.markSpawnFailedTerminated(ctx, id) return domain.Session{}, fmt.Errorf("spawn %s: workspace: %w", id, err) } @@ -95,30 +110,30 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess }) if err != nil { _ = m.workspace.Destroy(ctx, ws) - m.markErrored(ctx, id) + m.markSpawnFailedTerminated(ctx, id) return domain.Session{}, fmt.Errorf("spawn %s: runtime: %w", id, err) } - outcome := ports.SpawnOutcome{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle, Prompt: agentCfg.Prompt} - if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { + metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, Prompt: agentCfg.Prompt} + if err := m.lcm.MarkSpawned(ctx, id, metadata); err != nil { _ = m.runtime.Destroy(ctx, handle) _ = m.workspace.Destroy(ctx, ws) - m.markErrored(ctx, id) + m.markSpawnFailedTerminated(ctx, id) return domain.Session{}, fmt.Errorf("spawn %s: completed: %w", id, err) } return m.Get(ctx, id) } -// markErrored best-effort parks an orphaned spawn in a terminal errored state -// (the store has no delete; a phantom "spawning" row is worse than a terminal one). -func (m *Manager) markErrored(ctx context.Context, id domain.SessionID) { - _ = m.lcm.OnKillRequested(ctx, id, domain.TermErrorInProcess) +// markSpawnFailedTerminated best-effort parks an orphaned spawn as terminated. +// The store has no delete; a phantom half-spawned row is worse than a terminal one. +func (m *Manager) markSpawnFailedTerminated(ctx context.Context, id domain.SessionID) { + _ = m.lcm.MarkTerminated(ctx, id) } // Kill records terminal intent with the LCM, then tears down the runtime and // workspace. A workspace teardown refused by the worktree-remove safety // (uncommitted work) surfaces as an error with freed=false and is never forced. -func (m *Manager) Kill(ctx context.Context, id domain.SessionID, reason domain.TerminationReason) (bool, error) { +func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { rec, ok, err := m.store.GetSession(ctx, id) if err != nil { return false, fmt.Errorf("kill %s: %w", id, err) @@ -131,7 +146,7 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID, reason domain.T if handle.ID == "" || ws.Path == "" { return false, fmt.Errorf("kill %s: %w", id, ErrIncompleteHandle) } - if err := m.lcm.OnKillRequested(ctx, id, reason); err != nil { + if err := m.lcm.MarkTerminated(ctx, id); err != nil { return false, fmt.Errorf("kill %s: %w", id, err) } if err := m.runtime.Destroy(ctx, handle); err != nil { @@ -144,7 +159,7 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID, reason domain.T } // Restore relaunches a torn-down session in its workspace. The fallible I/O runs -// before any canonical write, so a failure never resurrects the row or destroys +// before any durable session write, so a failure never resurrects the row or destroys // the worktree (it may hold the agent's prior work). func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, ok, err := m.store.GetSession(ctx, id) @@ -154,7 +169,7 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess if !ok { return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotFound) } - if !isTerminal(rec.Lifecycle.Session.State) { + if !rec.IsTerminated { return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotRestorable) } meta := rec.Metadata @@ -180,8 +195,8 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess if err != nil { return domain.Session{}, fmt.Errorf("restore %s: runtime: %w", id, err) } - outcome := ports.SpawnOutcome{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandle: handle, AgentSessionID: meta.AgentSessionID, Prompt: meta.Prompt} - if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { + metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, AgentSessionID: meta.AgentSessionID, Prompt: meta.Prompt} + if err := m.lcm.MarkSpawned(ctx, id, metadata); err != nil { _ = m.runtime.Destroy(ctx, handle) return domain.Session{}, fmt.Errorf("restore %s: completed: %w", id, err) } @@ -234,7 +249,7 @@ func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) ([]doma } var cleaned []domain.SessionID for _, rec := range recs { - if !isTerminal(rec.Lifecycle.Session.State) { + if !rec.IsTerminated { continue } ws := workspaceInfo(rec) @@ -255,15 +270,14 @@ func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) ([]doma // ---- helpers ---- func (m *Manager) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) { - pr, err := m.store.PRFactsForSession(ctx, rec.ID) + pr, ok, err := m.store.GetDisplayPRFactsForSession(ctx, rec.ID) if err != nil { return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err) } - return domain.Session{SessionRecord: rec, Status: domain.DeriveStatus(rec.Lifecycle, pr)}, nil -} - -func isTerminal(s domain.SessionState) bool { - return s == domain.SessionDone || s == domain.SessionTerminated + if !ok { + return domain.Session{SessionRecord: rec, Status: domain.DeriveStatus(rec, nil)}, nil + } + return domain.Session{SessionRecord: rec, Status: domain.DeriveStatus(rec, &pr)}, nil } func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { @@ -273,11 +287,8 @@ func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { Kind: cfg.Kind, CreatedAt: now, UpdatedAt: now, - Lifecycle: domain.CanonicalSessionLifecycle{ - Version: domain.LifecycleVersion, - Session: domain.SessionSubstate{State: domain.SessionNotStarted}, - Harness: cfg.Harness, - }, + Harness: cfg.Harness, + Activity: domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: now, Source: domain.SourceNone}, } } @@ -306,7 +317,7 @@ func spawnEnv(base map[string]string, id domain.SessionID, project domain.Projec } func runtimeHandle(meta domain.SessionMetadata) ports.RuntimeHandle { - return ports.RuntimeHandle{ID: meta.RuntimeHandleID, RuntimeName: meta.RuntimeName} + return ports.RuntimeHandle{ID: meta.RuntimeHandleID} } func workspaceInfo(rec domain.SessionRecord) ports.WorkspaceInfo { diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index 669e0c2544..228fac89c0 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -13,8 +13,6 @@ import ( var ctx = context.Background() -// ---- fakes ---- - type fakeStore struct { sessions map[domain.SessionID]domain.SessionRecord pr map[domain.SessionID]domain.PRFacts @@ -24,7 +22,6 @@ type fakeStore struct { func newFakeStore() *fakeStore { return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}} } - func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { f.num++ rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num)) @@ -48,59 +45,41 @@ func (f *fakeStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domai } return out, nil } -func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) { - out := make([]domain.SessionRecord, 0, len(f.sessions)) +func (f *fakeStore) ListAllSessions(context.Context) ([]domain.SessionRecord, error) { + var out []domain.SessionRecord for _, r := range f.sessions { out = append(out, r) } return out, nil } -func (f *fakeStore) PRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, error) { - return f.pr[id], nil +func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) { + if pr := f.pr[id]; pr.URL != "" { + return pr, true, nil + } + return domain.PRFacts{}, false, nil } -// fakeLCM is the minimal lifecycle the Session Manager drives: it persists the -// spawn/kill canonical writes into the store so Get reflects them. type fakeLCM struct { store *fakeStore completed int } -func (l *fakeLCM) OnSpawnCompleted(_ context.Context, id domain.SessionID, o ports.SpawnOutcome) error { +func (l *fakeLCM) MarkSpawned(_ context.Context, id domain.SessionID, metadata domain.SessionMetadata) error { l.completed++ rec := l.store.sessions[id] - rec.Lifecycle.Session.State = domain.SessionNotStarted - rec.Lifecycle.IsAlive = true - rec.Lifecycle.TerminationReason = domain.TermNone - rec.Metadata = domain.SessionMetadata{ - Branch: o.Branch, WorkspacePath: o.WorkspacePath, - RuntimeHandleID: o.RuntimeHandle.ID, RuntimeName: o.RuntimeHandle.RuntimeName, - AgentSessionID: o.AgentSessionID, Prompt: o.Prompt, - } + rec.IsTerminated = false + rec.Activity = domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: time.Now(), Source: domain.SourceRuntime} + rec.Metadata = metadata l.store.sessions[id] = rec return nil } -func (l *fakeLCM) OnKillRequested(_ context.Context, id domain.SessionID, reason domain.TerminationReason) error { +func (l *fakeLCM) MarkTerminated(_ context.Context, id domain.SessionID) error { rec := l.store.sessions[id] - rec.Lifecycle.Session.State = domain.SessionTerminated - rec.Lifecycle.TerminationReason = reason - rec.Lifecycle.IsAlive = false + rec.IsTerminated = true + rec.Activity = domain.ActivitySubstate{State: domain.ActivityExited, LastActivityAt: time.Now(), Source: domain.SourceRuntime} l.store.sessions[id] = rec return nil } -func (l *fakeLCM) ApplyRuntimeObservation(context.Context, domain.SessionID, ports.RuntimeFacts) error { - return nil -} -func (l *fakeLCM) ApplyActivitySignal(context.Context, domain.SessionID, ports.ActivitySignal) error { - return nil -} -func (l *fakeLCM) ApplyPRObservation(context.Context, domain.SessionID, ports.PRObservation) error { - return nil -} -func (l *fakeLCM) TickEscalations(context.Context, time.Time) error { return nil } -func (l *fakeLCM) RunningSessions(context.Context) ([]domain.SessionRecord, error) { - return nil, nil -} type fakeRuntime struct { createErr error @@ -112,12 +91,9 @@ func (r *fakeRuntime) Create(context.Context, ports.RuntimeConfig) (ports.Runtim return ports.RuntimeHandle{}, r.createErr } r.created++ - return ports.RuntimeHandle{ID: "h1", RuntimeName: "tmux"}, nil + return ports.RuntimeHandle{ID: "h1"}, nil } func (r *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { r.destroyed++; return nil } -func (r *fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { - return true, nil -} type fakeAgent struct{} @@ -154,143 +130,111 @@ func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) { st := newFakeStore() rt := &fakeRuntime{} ws := &fakeWorkspace{} - m := New(Deps{ - Runtime: rt, Agent: fakeAgent{}, Workspace: ws, - Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, - }) + m := New(Deps{Runtime: rt, Agent: fakeAgent{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}}) return m, st, rt, ws } - func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) { - st.sessions[id] = domain.SessionRecord{ - ID: id, ProjectID: "mer", Metadata: meta, - Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionTerminated}}, - } + st.sessions[id] = domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: meta, IsTerminated: true, Activity: domain.ActivitySubstate{State: domain.ActivityExited}} +} +func mkLive(id domain.SessionID) domain.SessionRecord { + return domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: domain.SessionMetadata{WorkspacePath: "/ws/" + string(id), RuntimeHandleID: "h1"}, Activity: domain.ActivitySubstate{State: domain.ActivityActive}} } -// ---- tests ---- - -func TestSpawn_AssignsIDAndGoesLive(t *testing.T) { +func TestSpawn_AssignsIDAndGoesIdle(t *testing.T) { m, st, rt, _ := newManager() - s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"}) if err != nil { t.Fatal(err) } if s.ID != "mer-1" { - t.Fatalf("store should assign mer-1, got %q", s.ID) + t.Fatalf("got %q", s.ID) } - if s.Status != domain.StatusSpawning { - t.Fatalf("fresh session displays spawning, got %q", s.Status) + if s.Status != domain.StatusIdle { + t.Fatalf("fresh session displays idle, got %q", s.Status) } if rt.created != 1 { - t.Fatalf("runtime not created") + t.Fatal("runtime not created") } if st.sessions["mer-1"].Metadata.RuntimeHandleID != "h1" { - t.Fatal("spawn handle not folded into the row") + t.Fatal("handle not folded") } } - func TestSpawn_RollsBackOnRuntimeFailure(t *testing.T) { m, st, _, ws := newManager() m.runtime = &fakeRuntime{createErr: errors.New("boom")} - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer"}); err == nil { - t.Fatal("expected spawn to fail") + t.Fatal("expected failure") } if ws.destroyed != 1 { - t.Fatal("workspace should be rolled back") + t.Fatal("workspace should roll back") } - if st.sessions["mer-1"].Lifecycle.Session.State != domain.SessionTerminated { - t.Fatal("orphaned spawn should be parked terminal") + if !st.sessions["mer-1"].IsTerminated { + t.Fatal("orphaned spawn should be terminated") } } - func TestKill_TearsDownRuntimeAndWorkspace(t *testing.T) { m, st, rt, ws := newManager() st.sessions["mer-1"] = mkLive("mer-1") - - freed, err := m.Kill(ctx, "mer-1", domain.TermManuallyKilled) + freed, err := m.Kill(ctx, "mer-1") if err != nil || !freed { - t.Fatalf("kill should free the workspace: freed=%v err=%v", freed, err) + t.Fatalf("freed=%v err=%v", freed, err) } if rt.destroyed != 1 || ws.destroyed != 1 { t.Fatal("kill should destroy runtime and workspace") } } - func TestKill_RefusesIncompleteHandle(t *testing.T) { m, st, _, _ := newManager() - st.sessions["mer-1"] = domain.SessionRecord{ // live, but no teardown handles - ID: "mer-1", ProjectID: "mer", - Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionWorking}, IsAlive: true}, - } - - if _, err := m.Kill(ctx, "mer-1", domain.TermManuallyKilled); !errors.Is(err, ErrIncompleteHandle) { + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.ActivitySubstate{State: domain.ActivityActive}} + if _, err := m.Kill(ctx, "mer-1"); !errors.Is(err, ErrIncompleteHandle) { t.Fatalf("want ErrIncompleteHandle, got %v", err) } } - func TestRestore_ReopensTerminal(t *testing.T) { m, st, rt, _ := newManager() seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}) - s, err := m.Restore(ctx, "mer-1") if err != nil { t.Fatal(err) } - if s.Status != domain.StatusSpawning { - t.Fatalf("restored session displays spawning, got %q", s.Status) + if s.Status != domain.StatusIdle { + t.Fatalf("restored displays idle, got %q", s.Status) } if rt.created != 1 { - t.Fatal("restore should relaunch the runtime") + t.Fatal("restore should relaunch") } } - func TestRestore_RefusesLiveSession(t *testing.T) { m, st, _, _ := newManager() st.sessions["mer-1"] = mkLive("mer-1") - if _, err := m.Restore(ctx, "mer-1"); !errors.Is(err, ErrNotRestorable) { t.Fatalf("want ErrNotRestorable, got %v", err) } } - func TestList_DerivesStatusFromPRFacts(t *testing.T) { m, st, _, _ := newManager() st.sessions["mer-1"] = mkLive("mer-1") - st.pr["mer-1"] = domain.PRFacts{Exists: true, CI: domain.CIFailing} - + st.pr["mer-1"] = domain.PRFacts{URL: "pr1", CI: domain.CIFailing} list, err := m.List(ctx, "mer") if err != nil { t.Fatal(err) } if len(list) != 1 || list[0].Status != domain.StatusCIFailed { - t.Fatalf("status should reflect PR facts, got %+v", list) + t.Fatalf("got %+v", list) } } - func TestCleanup_ReclaimsTerminalWorkspaces(t *testing.T) { m, st, _, ws := newManager() seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1"}) - st.sessions["mer-2"] = mkLive("mer-2") // live: must be skipped - + st.sessions["mer-2"] = mkLive("mer-2") cleaned, err := m.Cleanup(ctx, "mer") if err != nil { t.Fatal(err) } if len(cleaned) != 1 || cleaned[0] != "mer-1" { - t.Fatalf("only the terminal session should be reclaimed, got %v", cleaned) + t.Fatalf("got %v", cleaned) } if ws.destroyed != 1 { - t.Fatal("the live session's workspace must not be destroyed") - } -} - -func mkLive(id domain.SessionID) domain.SessionRecord { - return domain.SessionRecord{ - ID: id, ProjectID: "mer", - Metadata: domain.SessionMetadata{WorkspacePath: "/ws/" + string(id), RuntimeHandleID: "h1", RuntimeName: "tmux"}, - Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionWorking}, IsAlive: true}, + t.Fatal("live workspace must not be destroyed") } } diff --git a/backend/internal/storage/sqlite/changelog_store.go b/backend/internal/storage/sqlite/changelog_store.go deleted file mode 100644 index 927d796865..0000000000 --- a/backend/internal/storage/sqlite/changelog_store.go +++ /dev/null @@ -1,89 +0,0 @@ -package sqlite - -import ( - "context" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// ChangeLogRow is one durable CDC event. These rows are written by the DB -// triggers (migration 0001), never by application code; the store only reads -// them, for the CDC poller. -type ChangeLogRow struct { - Seq int64 - ProjectID string - SessionID string // empty when the event is project-level (NULL in the DB) - EventType string - Payload string - CreatedAt time.Time -} - -// ReadChangeLogAfter returns up to limit events with seq > after, in seq order -// — the CDC poller's read. The frontend's offset is `after`. -func (s *Store) ReadChangeLogAfter(ctx context.Context, after int64, limit int) ([]ChangeLogRow, error) { - rows, err := s.qr.ReadChangeLogAfter(ctx, gen.ReadChangeLogAfterParams{Seq: after, Limit: int64(limit)}) - if err != nil { - return nil, fmt.Errorf("read change_log after %d: %w", after, err) - } - out := make([]ChangeLogRow, 0, len(rows)) - for _, r := range rows { - out = append(out, changeLogRowFromGen(r)) - } - return out, nil -} - -// ReadChangeLogAfterForProject is the project-scoped variant — a client -// subscribed to one project reads only its events. -func (s *Store) ReadChangeLogAfterForProject(ctx context.Context, project string, after int64, limit int) ([]ChangeLogRow, error) { - rows, err := s.qr.ReadChangeLogAfterForProject(ctx, gen.ReadChangeLogAfterForProjectParams{ - ProjectID: project, Seq: after, Limit: int64(limit), - }) - if err != nil { - return nil, fmt.Errorf("read change_log for %s after %d: %w", project, after, err) - } - out := make([]ChangeLogRow, 0, len(rows)) - for _, r := range rows { - out = append(out, changeLogRowFromGen(r)) - } - return out, nil -} - -// MaxChangeLogSeq returns the highest seq (0 if empty) — a fresh consumer's -// starting offset. -func (s *Store) MaxChangeLogSeq(ctx context.Context) (int64, error) { - v, err := s.qr.MaxChangeLogSeq(ctx) - if err != nil { - return 0, fmt.Errorf("max change_log seq: %w", err) - } - return asInt64(v), nil -} - -func changeLogRowFromGen(r gen.ChangeLog) ChangeLogRow { - row := ChangeLogRow{ - Seq: r.Seq, - ProjectID: r.ProjectID, - EventType: r.EventType, - Payload: r.Payload, - CreatedAt: r.CreatedAt, - } - if r.SessionID.Valid { - row.SessionID = r.SessionID.String - } - return row -} - -// asInt64 coerces sqlc's interface{} result for COALESCE(MAX(...)) — sqlc's -// SQLite type inference can't narrow the aggregate, so the generated signature -// is interface{}. modernc returns int64 for an integer aggregate. -func asInt64(v interface{}) int64 { - switch n := v.(type) { - case int64: - return n - case int: - return int64(n) - default: - return 0 - } -} diff --git a/backend/internal/storage/sqlite/db.go b/backend/internal/storage/sqlite/db.go index 280b48e01b..e9e447e8f0 100644 --- a/backend/internal/storage/sqlite/db.go +++ b/backend/internal/storage/sqlite/db.go @@ -1,6 +1,6 @@ -// Package sqlite is the durable persistence adapter: the goose-managed schema, -// typed CRUD over sqlc-generated queries, and the read side of the -// trigger-driven CDC (it reads change_log; the DB triggers write it). +// Package sqlite owns SQLite connection setup and goose-managed schema +// migrations. Typed CRUD lives in the store subpackage; this package keeps the +// public Open entrypoint and compatibility aliases for callers. package sqlite import ( @@ -12,12 +12,18 @@ import ( "sync" "github.com/pressly/goose/v3" + + sqlitestore "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/store" + // modernc.org/sqlite is the pure-Go (CGO-free) SQLite driver — chosen so the // daemon cross-compiles and ships as a static binary with no libsqlite/CGO // toolchain dependency, at the cost of some raw throughput vs a C-backed driver. _ "modernc.org/sqlite" ) +// Store is the SQLite-backed persistence layer. +type Store = sqlitestore.Store + //go:embed migrations/*.sql var migrationsFS embed.FS @@ -68,7 +74,7 @@ func Open(dataDir string) (*Store, error) { readDB.SetMaxOpenConns(maxReaders) readDB.SetMaxIdleConns(maxReaders) - return NewStore(writeDB, readDB), nil + return sqlitestore.NewStore(writeDB, readDB), nil } // gooseMu serialises calls into goose. goose v3 keeps its baseFS / logger / diff --git a/backend/internal/storage/sqlite/gen/changelog.sql.go b/backend/internal/storage/sqlite/gen/changelog.sql.go index 6568fdcc11..c582a4c371 100644 --- a/backend/internal/storage/sqlite/gen/changelog.sql.go +++ b/backend/internal/storage/sqlite/gen/changelog.sql.go @@ -10,12 +10,12 @@ import ( ) const maxChangeLogSeq = `-- name: MaxChangeLogSeq :one -SELECT COALESCE(MAX(seq), 0) AS seq FROM change_log +SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) AS seq FROM change_log ` -func (q *Queries) MaxChangeLogSeq(ctx context.Context) (interface{}, error) { +func (q *Queries) MaxChangeLogSeq(ctx context.Context) (int64, error) { row := q.db.QueryRowContext(ctx, maxChangeLogSeq) - var seq interface{} + var seq int64 err := row.Scan(&seq) return seq, err } @@ -59,44 +59,3 @@ func (q *Queries) ReadChangeLogAfter(ctx context.Context, arg ReadChangeLogAfter } return items, nil } - -const readChangeLogAfterForProject = `-- name: ReadChangeLogAfterForProject :many -SELECT seq, project_id, session_id, event_type, payload, created_at -FROM change_log WHERE project_id = ? AND seq > ? ORDER BY seq LIMIT ? -` - -type ReadChangeLogAfterForProjectParams struct { - ProjectID string - Seq int64 - Limit int64 -} - -func (q *Queries) ReadChangeLogAfterForProject(ctx context.Context, arg ReadChangeLogAfterForProjectParams) ([]ChangeLog, error) { - rows, err := q.db.QueryContext(ctx, readChangeLogAfterForProject, arg.ProjectID, arg.Seq, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ChangeLog{} - for rows.Next() { - var i ChangeLog - if err := rows.Scan( - &i.Seq, - &i.ProjectID, - &i.SessionID, - &i.EventType, - &i.Payload, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 992c0ca03b..720343e00e 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -7,100 +7,77 @@ package gen import ( "database/sql" "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) type ChangeLog struct { Seq int64 - ProjectID string - SessionID sql.NullString - EventType string + ProjectID domain.ProjectID + SessionID *domain.SessionID + EventType cdc.EventType Payload string CreatedAt time.Time } -type Notification struct { - Seq int64 - ID string - ProjectID string - SessionID string - Source string - EventType string - SemanticType string - Priority string - Message string - PayloadJson string - ActionsJson string - DedupeKey string - CauseKey string - ReadAt sql.NullTime - ArchivedAt sql.NullTime - CreatedAt time.Time - UpdatedAt time.Time -} - -type Pr struct { - Url string - SessionID string +type PR struct { + URL string + SessionID domain.SessionID Number int64 - PrState string - ReviewDecision string - CiState string - Mergeability string + PRState domain.PRState + ReviewDecision domain.ReviewDecision + CIState domain.CIState + Mergeability domain.Mergeability UpdatedAt time.Time } -type PrCheck struct { - PrUrl string +type PRCheck struct { + PRURL string Name string CommitHash string - Status string - Url string + Status domain.PRCheckStatus + URL string LogTail string CreatedAt time.Time } -type PrComment struct { - PrUrl string +type PRComment struct { + PRURL string CommentID string Author string File string Line int64 Body string - Resolved int64 + Resolved bool CreatedAt time.Time } type Project struct { - ID string + ID domain.ProjectID Path string - RepoOriginUrl string + RepoOriginURL string DisplayName string RegisteredAt time.Time ArchivedAt sql.NullTime } type Session struct { - ID string - ProjectID string - Num int64 - IssueID string - Kind string - Harness string - SessionState string - TerminationReason string - IsAlive int64 - ActivityState string - ActivityLastAt time.Time - ActivitySource string - DetectingAttempts sql.NullInt64 - DetectingStartedAt sql.NullTime - DetectingEvidenceHash sql.NullString - Branch string - WorkspacePath string - RuntimeHandleID string - RuntimeName string - AgentSessionID string - Prompt string - CreatedAt time.Time - UpdatedAt time.Time + ID domain.SessionID + ProjectID domain.ProjectID + Num int64 + IssueID domain.IssueID + Kind domain.SessionKind + Harness domain.AgentHarness + ActivityState domain.ActivityState + ActivityLastAt time.Time + ActivitySource domain.ActivitySource + IsTerminated bool + Branch string + WorkspacePath string + RuntimeHandleID string + AgentSessionID string + Prompt string + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/backend/internal/storage/sqlite/gen/notifications.sql.go b/backend/internal/storage/sqlite/gen/notifications.sql.go deleted file mode 100644 index 7b2b5493d9..0000000000 --- a/backend/internal/storage/sqlite/gen/notifications.sql.go +++ /dev/null @@ -1,464 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: notifications.sql - -package gen - -import ( - "context" - "database/sql" - "time" -) - -const archiveNotification = `-- name: ArchiveNotification :one -UPDATE notifications -SET archived_at = ?, updated_at = ? -WHERE id = ? AND archived_at IS NULL -RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -` - -type ArchiveNotificationParams struct { - ArchivedAt sql.NullTime - UpdatedAt time.Time - ID string -} - -func (q *Queries) ArchiveNotification(ctx context.Context, arg ArchiveNotificationParams) (Notification, error) { - row := q.db.QueryRowContext(ctx, archiveNotification, arg.ArchivedAt, arg.UpdatedAt, arg.ID) - var i Notification - err := row.Scan( - &i.Seq, - &i.ID, - &i.ProjectID, - &i.SessionID, - &i.Source, - &i.EventType, - &i.SemanticType, - &i.Priority, - &i.Message, - &i.PayloadJson, - &i.ActionsJson, - &i.DedupeKey, - &i.CauseKey, - &i.ReadAt, - &i.ArchivedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const getNotification = `-- name: GetNotification :one -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications WHERE id = ? -` - -func (q *Queries) GetNotification(ctx context.Context, id string) (Notification, error) { - row := q.db.QueryRowContext(ctx, getNotification, id) - var i Notification - err := row.Scan( - &i.Seq, - &i.ID, - &i.ProjectID, - &i.SessionID, - &i.Source, - &i.EventType, - &i.SemanticType, - &i.Priority, - &i.Message, - &i.PayloadJson, - &i.ActionsJson, - &i.DedupeKey, - &i.CauseKey, - &i.ReadAt, - &i.ArchivedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const getNotificationByDedupeKey = `-- name: GetNotificationByDedupeKey :one -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications WHERE dedupe_key = ? -` - -func (q *Queries) GetNotificationByDedupeKey(ctx context.Context, dedupeKey string) (Notification, error) { - row := q.db.QueryRowContext(ctx, getNotificationByDedupeKey, dedupeKey) - var i Notification - err := row.Scan( - &i.Seq, - &i.ID, - &i.ProjectID, - &i.SessionID, - &i.Source, - &i.EventType, - &i.SemanticType, - &i.Priority, - &i.Message, - &i.PayloadJson, - &i.ActionsJson, - &i.DedupeKey, - &i.CauseKey, - &i.ReadAt, - &i.ArchivedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const insertNotification = `-- name: InsertNotification :one -INSERT INTO notifications ( - project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (dedupe_key) DO NOTHING -RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -` - -type InsertNotificationParams struct { - ProjectID string - SessionID string - Source string - EventType string - SemanticType string - Priority string - Message string - PayloadJson string - ActionsJson string - DedupeKey string - CauseKey string - CreatedAt time.Time - UpdatedAt time.Time -} - -func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotificationParams) (Notification, error) { - row := q.db.QueryRowContext(ctx, insertNotification, - arg.ProjectID, - arg.SessionID, - arg.Source, - arg.EventType, - arg.SemanticType, - arg.Priority, - arg.Message, - arg.PayloadJson, - arg.ActionsJson, - arg.DedupeKey, - arg.CauseKey, - arg.CreatedAt, - arg.UpdatedAt, - ) - var i Notification - err := row.Scan( - &i.Seq, - &i.ID, - &i.ProjectID, - &i.SessionID, - &i.Source, - &i.EventType, - &i.SemanticType, - &i.Priority, - &i.Message, - &i.PayloadJson, - &i.ActionsJson, - &i.DedupeKey, - &i.CauseKey, - &i.ReadAt, - &i.ArchivedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const listNotifications = `-- name: ListNotifications :many -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications -ORDER BY seq DESC -LIMIT ? -` - -func (q *Queries) ListNotifications(ctx context.Context, limit int64) ([]Notification, error) { - rows, err := q.db.QueryContext(ctx, listNotifications, limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Notification{} - for rows.Next() { - var i Notification - if err := rows.Scan( - &i.Seq, - &i.ID, - &i.ProjectID, - &i.SessionID, - &i.Source, - &i.EventType, - &i.SemanticType, - &i.Priority, - &i.Message, - &i.PayloadJson, - &i.ActionsJson, - &i.DedupeKey, - &i.CauseKey, - &i.ReadAt, - &i.ArchivedAt, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listNotificationsByProject = `-- name: ListNotificationsByProject :many -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications -WHERE project_id = ? -ORDER BY seq DESC -LIMIT ? -` - -type ListNotificationsByProjectParams struct { - ProjectID string - Limit int64 -} - -func (q *Queries) ListNotificationsByProject(ctx context.Context, arg ListNotificationsByProjectParams) ([]Notification, error) { - rows, err := q.db.QueryContext(ctx, listNotificationsByProject, arg.ProjectID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Notification{} - for rows.Next() { - var i Notification - if err := rows.Scan( - &i.Seq, - &i.ID, - &i.ProjectID, - &i.SessionID, - &i.Source, - &i.EventType, - &i.SemanticType, - &i.Priority, - &i.Message, - &i.PayloadJson, - &i.ActionsJson, - &i.DedupeKey, - &i.CauseKey, - &i.ReadAt, - &i.ArchivedAt, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listNotificationsBySession = `-- name: ListNotificationsBySession :many -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications -WHERE session_id = ? -ORDER BY seq DESC -LIMIT ? -` - -type ListNotificationsBySessionParams struct { - SessionID string - Limit int64 -} - -func (q *Queries) ListNotificationsBySession(ctx context.Context, arg ListNotificationsBySessionParams) ([]Notification, error) { - rows, err := q.db.QueryContext(ctx, listNotificationsBySession, arg.SessionID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Notification{} - for rows.Next() { - var i Notification - if err := rows.Scan( - &i.Seq, - &i.ID, - &i.ProjectID, - &i.SessionID, - &i.Source, - &i.EventType, - &i.SemanticType, - &i.Priority, - &i.Message, - &i.PayloadJson, - &i.ActionsJson, - &i.DedupeKey, - &i.CauseKey, - &i.ReadAt, - &i.ArchivedAt, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listUnreadNotifications = `-- name: ListUnreadNotifications :many -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications -WHERE read_at IS NULL AND archived_at IS NULL -ORDER BY seq DESC -LIMIT ? -` - -func (q *Queries) ListUnreadNotifications(ctx context.Context, limit int64) ([]Notification, error) { - rows, err := q.db.QueryContext(ctx, listUnreadNotifications, limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Notification{} - for rows.Next() { - var i Notification - if err := rows.Scan( - &i.Seq, - &i.ID, - &i.ProjectID, - &i.SessionID, - &i.Source, - &i.EventType, - &i.SemanticType, - &i.Priority, - &i.Message, - &i.PayloadJson, - &i.ActionsJson, - &i.DedupeKey, - &i.CauseKey, - &i.ReadAt, - &i.ArchivedAt, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const markNotificationRead = `-- name: MarkNotificationRead :one -UPDATE notifications -SET read_at = ?, updated_at = ? -WHERE id = ? AND read_at IS NULL -RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -` - -type MarkNotificationReadParams struct { - ReadAt sql.NullTime - UpdatedAt time.Time - ID string -} - -func (q *Queries) MarkNotificationRead(ctx context.Context, arg MarkNotificationReadParams) (Notification, error) { - row := q.db.QueryRowContext(ctx, markNotificationRead, arg.ReadAt, arg.UpdatedAt, arg.ID) - var i Notification - err := row.Scan( - &i.Seq, - &i.ID, - &i.ProjectID, - &i.SessionID, - &i.Source, - &i.EventType, - &i.SemanticType, - &i.Priority, - &i.Message, - &i.PayloadJson, - &i.ActionsJson, - &i.DedupeKey, - &i.CauseKey, - &i.ReadAt, - &i.ArchivedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const markNotificationUnread = `-- name: MarkNotificationUnread :one -UPDATE notifications -SET read_at = NULL, updated_at = ? -WHERE id = ? AND read_at IS NOT NULL -RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -` - -type MarkNotificationUnreadParams struct { - UpdatedAt time.Time - ID string -} - -func (q *Queries) MarkNotificationUnread(ctx context.Context, arg MarkNotificationUnreadParams) (Notification, error) { - row := q.db.QueryRowContext(ctx, markNotificationUnread, arg.UpdatedAt, arg.ID) - var i Notification - err := row.Scan( - &i.Seq, - &i.ID, - &i.ProjectID, - &i.SessionID, - &i.Source, - &i.EventType, - &i.SemanticType, - &i.Priority, - &i.Message, - &i.PayloadJson, - &i.ActionsJson, - &i.DedupeKey, - &i.CauseKey, - &i.ReadAt, - &i.ArchivedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} diff --git a/backend/internal/storage/sqlite/gen/pr.sql.go b/backend/internal/storage/sqlite/gen/pr.sql.go index f9fa362029..154885cfe3 100644 --- a/backend/internal/storage/sqlite/gen/pr.sql.go +++ b/backend/internal/storage/sqlite/gen/pr.sql.go @@ -8,31 +8,73 @@ package gen import ( "context" "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -const deletePR = `-- name: DeletePR :exec -DELETE FROM pr WHERE url = ? +const getDisplayPRFactsBySession = `-- name: GetDisplayPRFactsBySession :one +SELECT + pr.url, + pr.number, + pr.pr_state, + pr.review_decision, + pr.ci_state, + pr.mergeability, + EXISTS ( + SELECT 1 + FROM pr_comment + WHERE pr_comment.pr_url = pr.url + AND pr_comment.resolved = 0 + ) AS review_comments +FROM pr +WHERE pr.session_id = ? +ORDER BY + CASE WHEN pr.pr_state NOT IN ('merged', 'closed') THEN 0 ELSE 1 END, + pr.updated_at DESC +LIMIT 1 ` -func (q *Queries) DeletePR(ctx context.Context, url string) error { - _, err := q.db.ExecContext(ctx, deletePR, url) - return err +type GetDisplayPRFactsBySessionRow struct { + URL string + Number int64 + PRState domain.PRState + ReviewDecision domain.ReviewDecision + CIState domain.CIState + Mergeability domain.Mergeability + ReviewComments bool +} + +func (q *Queries) GetDisplayPRFactsBySession(ctx context.Context, sessionID domain.SessionID) (GetDisplayPRFactsBySessionRow, error) { + row := q.db.QueryRowContext(ctx, getDisplayPRFactsBySession, sessionID) + var i GetDisplayPRFactsBySessionRow + err := row.Scan( + &i.URL, + &i.Number, + &i.PRState, + &i.ReviewDecision, + &i.CIState, + &i.Mergeability, + &i.ReviewComments, + ) + return i, err } const getPR = `-- name: GetPR :one -SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at FROM pr WHERE url = ? +SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at +FROM pr +WHERE url = ? ` -func (q *Queries) GetPR(ctx context.Context, url string) (Pr, error) { +func (q *Queries) GetPR(ctx context.Context, url string) (PR, error) { row := q.db.QueryRowContext(ctx, getPR, url) - var i Pr + var i PR err := row.Scan( - &i.Url, + &i.URL, &i.SessionID, &i.Number, - &i.PrState, + &i.PRState, &i.ReviewDecision, - &i.CiState, + &i.CIState, &i.Mergeability, &i.UpdatedAt, ) @@ -40,25 +82,28 @@ func (q *Queries) GetPR(ctx context.Context, url string) (Pr, error) { } const listPRsBySession = `-- name: ListPRsBySession :many -SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at FROM pr WHERE session_id = ? ORDER BY updated_at DESC +SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at +FROM pr +WHERE session_id = ? +ORDER BY updated_at DESC ` -func (q *Queries) ListPRsBySession(ctx context.Context, sessionID string) ([]Pr, error) { +func (q *Queries) ListPRsBySession(ctx context.Context, sessionID domain.SessionID) ([]PR, error) { rows, err := q.db.QueryContext(ctx, listPRsBySession, sessionID) if err != nil { return nil, err } defer rows.Close() - items := []Pr{} + items := []PR{} for rows.Next() { - var i Pr + var i PR if err := rows.Scan( - &i.Url, + &i.URL, &i.SessionID, &i.Number, - &i.PrState, + &i.PRState, &i.ReviewDecision, - &i.CiState, + &i.CIState, &i.Mergeability, &i.UpdatedAt, ); err != nil { @@ -79,7 +124,6 @@ const upsertPR = `-- name: UpsertPR :exec INSERT INTO pr (url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (url) DO UPDATE SET - session_id = excluded.session_id, number = excluded.number, pr_state = excluded.pr_state, review_decision = excluded.review_decision, @@ -89,24 +133,24 @@ ON CONFLICT (url) DO UPDATE SET ` type UpsertPRParams struct { - Url string - SessionID string + URL string + SessionID domain.SessionID Number int64 - PrState string - ReviewDecision string - CiState string - Mergeability string + PRState domain.PRState + ReviewDecision domain.ReviewDecision + CIState domain.CIState + Mergeability domain.Mergeability UpdatedAt time.Time } func (q *Queries) UpsertPR(ctx context.Context, arg UpsertPRParams) error { _, err := q.db.ExecContext(ctx, upsertPR, - arg.Url, + arg.URL, arg.SessionID, arg.Number, - arg.PrState, + arg.PRState, arg.ReviewDecision, - arg.CiState, + arg.CIState, arg.Mergeability, arg.UpdatedAt, ) diff --git a/backend/internal/storage/sqlite/gen/pr_checks.sql.go b/backend/internal/storage/sqlite/gen/pr_checks.sql.go index 58668ab127..fde21e67ec 100644 --- a/backend/internal/storage/sqlite/gen/pr_checks.sql.go +++ b/backend/internal/storage/sqlite/gen/pr_checks.sql.go @@ -8,27 +8,30 @@ package gen import ( "context" "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) const listChecksByPR = `-- name: ListChecksByPR :many -SELECT pr_url, name, commit_hash, status, url, log_tail, created_at FROM pr_checks WHERE pr_url = ? ORDER BY name, created_at +SELECT pr_url, name, commit_hash, status, url, log_tail, created_at +FROM pr_checks WHERE pr_url = ? ORDER BY name, created_at ` -func (q *Queries) ListChecksByPR(ctx context.Context, prUrl string) ([]PrCheck, error) { +func (q *Queries) ListChecksByPR(ctx context.Context, prUrl string) ([]PRCheck, error) { rows, err := q.db.QueryContext(ctx, listChecksByPR, prUrl) if err != nil { return nil, err } defer rows.Close() - items := []PrCheck{} + items := []PRCheck{} for rows.Next() { - var i PrCheck + var i PRCheck if err := rows.Scan( - &i.PrUrl, + &i.PRURL, &i.Name, &i.CommitHash, &i.Status, - &i.Url, + &i.URL, &i.LogTail, &i.CreatedAt, ); err != nil { @@ -45,47 +48,6 @@ func (q *Queries) ListChecksByPR(ctx context.Context, prUrl string) ([]PrCheck, return items, nil } -const listRecentChecks = `-- name: ListRecentChecks :many -SELECT status, commit_hash, created_at FROM pr_checks -WHERE pr_url = ? AND name = ? -ORDER BY created_at DESC LIMIT ? -` - -type ListRecentChecksParams struct { - PrUrl string - Name string - Limit int64 -} - -type ListRecentChecksRow struct { - Status string - CommitHash string - CreatedAt time.Time -} - -func (q *Queries) ListRecentChecks(ctx context.Context, arg ListRecentChecksParams) ([]ListRecentChecksRow, error) { - rows, err := q.db.QueryContext(ctx, listRecentChecks, arg.PrUrl, arg.Name, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListRecentChecksRow{} - for rows.Next() { - var i ListRecentChecksRow - if err := rows.Scan(&i.Status, &i.CommitHash, &i.CreatedAt); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const upsertPRCheck = `-- name: UpsertPRCheck :exec INSERT INTO pr_checks (pr_url, name, commit_hash, status, url, log_tail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) @@ -96,22 +58,22 @@ ON CONFLICT (pr_url, name, commit_hash) DO UPDATE SET ` type UpsertPRCheckParams struct { - PrUrl string + PRURL string Name string CommitHash string - Status string - Url string + Status domain.PRCheckStatus + URL string LogTail string CreatedAt time.Time } func (q *Queries) UpsertPRCheck(ctx context.Context, arg UpsertPRCheckParams) error { _, err := q.db.ExecContext(ctx, upsertPRCheck, - arg.PrUrl, + arg.PRURL, arg.Name, arg.CommitHash, arg.Status, - arg.Url, + arg.URL, arg.LogTail, arg.CreatedAt, ) diff --git a/backend/internal/storage/sqlite/gen/pr_comment.sql.go b/backend/internal/storage/sqlite/gen/pr_comment.sql.go index a2f09f3443..f69cc17b39 100644 --- a/backend/internal/storage/sqlite/gen/pr_comment.sql.go +++ b/backend/internal/storage/sqlite/gen/pr_comment.sql.go @@ -19,21 +19,52 @@ func (q *Queries) DeletePRComments(ctx context.Context, prUrl string) error { return err } +const insertPRComment = `-- name: InsertPRComment :exec +INSERT INTO pr_comment (pr_url, comment_id, author, file, line, body, resolved, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +` + +type InsertPRCommentParams struct { + PRURL string + CommentID string + Author string + File string + Line int64 + Body string + Resolved bool + CreatedAt time.Time +} + +func (q *Queries) InsertPRComment(ctx context.Context, arg InsertPRCommentParams) error { + _, err := q.db.ExecContext(ctx, insertPRComment, + arg.PRURL, + arg.CommentID, + arg.Author, + arg.File, + arg.Line, + arg.Body, + arg.Resolved, + arg.CreatedAt, + ) + return err +} + const listPRComments = `-- name: ListPRComments :many -SELECT pr_url, comment_id, author, file, line, body, resolved, created_at FROM pr_comment WHERE pr_url = ? ORDER BY created_at, comment_id +SELECT pr_url, comment_id, author, file, line, body, resolved, created_at +FROM pr_comment WHERE pr_url = ? ORDER BY created_at, comment_id ` -func (q *Queries) ListPRComments(ctx context.Context, prUrl string) ([]PrComment, error) { +func (q *Queries) ListPRComments(ctx context.Context, prUrl string) ([]PRComment, error) { rows, err := q.db.QueryContext(ctx, listPRComments, prUrl) if err != nil { return nil, err } defer rows.Close() - items := []PrComment{} + items := []PRComment{} for rows.Next() { - var i PrComment + var i PRComment if err := rows.Scan( - &i.PrUrl, + &i.PRURL, &i.CommentID, &i.Author, &i.File, @@ -54,36 +85,3 @@ func (q *Queries) ListPRComments(ctx context.Context, prUrl string) ([]PrComment } return items, nil } - -const upsertPRComment = `-- name: UpsertPRComment :exec -INSERT INTO pr_comment (pr_url, comment_id, author, file, line, body, resolved, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (pr_url, comment_id) DO UPDATE SET - author = excluded.author, file = excluded.file, line = excluded.line, - body = excluded.body, resolved = excluded.resolved -` - -type UpsertPRCommentParams struct { - PrUrl string - CommentID string - Author string - File string - Line int64 - Body string - Resolved int64 - CreatedAt time.Time -} - -func (q *Queries) UpsertPRComment(ctx context.Context, arg UpsertPRCommentParams) error { - _, err := q.db.ExecContext(ctx, upsertPRComment, - arg.PrUrl, - arg.CommentID, - arg.Author, - arg.File, - arg.Line, - arg.Body, - arg.Resolved, - arg.CreatedAt, - ) - return err -} diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index a7c953cd3f..be255c5bd2 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -9,20 +9,44 @@ import ( "context" "database/sql" "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -const archiveProject = `-- name: ArchiveProject :exec +const archiveProject = `-- name: ArchiveProject :execrows UPDATE projects SET archived_at = ? WHERE id = ? ` type ArchiveProjectParams struct { ArchivedAt sql.NullTime - ID string + ID domain.ProjectID } -func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error { - _, err := q.db.ExecContext(ctx, archiveProject, arg.ArchivedAt, arg.ID) - return err +func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) (int64, error) { + result, err := q.db.ExecContext(ctx, archiveProject, arg.ArchivedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const findProjectByPath = `-- name: FindProjectByPath :one +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +FROM projects WHERE path = ? +` + +func (q *Queries) FindProjectByPath(ctx context.Context, path string) (Project, error) { + row := q.db.QueryRowContext(ctx, findProjectByPath, path) + var i Project + err := row.Scan( + &i.ID, + &i.Path, + &i.RepoOriginURL, + &i.DisplayName, + &i.RegisteredAt, + &i.ArchivedAt, + ) + return i, err } const getProject = `-- name: GetProject :one @@ -30,13 +54,13 @@ SELECT id, path, repo_origin_url, display_name, registered_at, archived_at FROM projects WHERE id = ? ` -func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { +func (q *Queries) GetProject(ctx context.Context, id domain.ProjectID) (Project, error) { row := q.db.QueryRowContext(ctx, getProject, id) var i Project err := row.Scan( &i.ID, &i.Path, - &i.RepoOriginUrl, + &i.RepoOriginURL, &i.DisplayName, &i.RegisteredAt, &i.ArchivedAt, @@ -61,7 +85,7 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { if err := rows.Scan( &i.ID, &i.Path, - &i.RepoOriginUrl, + &i.RepoOriginURL, &i.DisplayName, &i.RegisteredAt, &i.ArchivedAt, @@ -90,9 +114,9 @@ ON CONFLICT (id) DO UPDATE SET ` type UpsertProjectParams struct { - ID string + ID domain.ProjectID Path string - RepoOriginUrl string + RepoOriginURL string DisplayName string RegisteredAt time.Time ArchivedAt sql.NullTime @@ -102,7 +126,7 @@ func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) er _, err := q.db.ExecContext(ctx, upsertProject, arg.ID, arg.Path, - arg.RepoOriginUrl, + arg.RepoOriginURL, arg.DisplayName, arg.RegisteredAt, arg.ArchivedAt, diff --git a/backend/internal/storage/sqlite/gen/querier.go b/backend/internal/storage/sqlite/gen/querier.go deleted file mode 100644 index 4f91a9d544..0000000000 --- a/backend/internal/storage/sqlite/gen/querier.go +++ /dev/null @@ -1,48 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 - -package gen - -import ( - "context" -) - -type Querier interface { - ArchiveNotification(ctx context.Context, arg ArchiveNotificationParams) (Notification, error) - ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error - DeletePR(ctx context.Context, url string) error - DeletePRComments(ctx context.Context, prUrl string) error - DeleteSession(ctx context.Context, id string) error - GetNotification(ctx context.Context, id string) (Notification, error) - GetNotificationByDedupeKey(ctx context.Context, dedupeKey string) (Notification, error) - GetPR(ctx context.Context, url string) (Pr, error) - GetProject(ctx context.Context, id string) (Project, error) - GetSession(ctx context.Context, id string) (Session, error) - InsertNotification(ctx context.Context, arg InsertNotificationParams) (Notification, error) - InsertSession(ctx context.Context, arg InsertSessionParams) error - ListAllSessions(ctx context.Context) ([]Session, error) - ListChecksByPR(ctx context.Context, prUrl string) ([]PrCheck, error) - ListNotifications(ctx context.Context, limit int64) ([]Notification, error) - ListNotificationsByProject(ctx context.Context, arg ListNotificationsByProjectParams) ([]Notification, error) - ListNotificationsBySession(ctx context.Context, arg ListNotificationsBySessionParams) ([]Notification, error) - ListPRComments(ctx context.Context, prUrl string) ([]PrComment, error) - ListPRsBySession(ctx context.Context, sessionID string) ([]Pr, error) - ListProjects(ctx context.Context) ([]Project, error) - ListRecentChecks(ctx context.Context, arg ListRecentChecksParams) ([]ListRecentChecksRow, error) - ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) - ListUnreadNotifications(ctx context.Context, limit int64) ([]Notification, error) - MarkNotificationRead(ctx context.Context, arg MarkNotificationReadParams) (Notification, error) - MarkNotificationUnread(ctx context.Context, arg MarkNotificationUnreadParams) (Notification, error) - MaxChangeLogSeq(ctx context.Context) (interface{}, error) - NextSessionNum(ctx context.Context, projectID string) (int64, error) - ReadChangeLogAfter(ctx context.Context, arg ReadChangeLogAfterParams) ([]ChangeLog, error) - ReadChangeLogAfterForProject(ctx context.Context, arg ReadChangeLogAfterForProjectParams) ([]ChangeLog, error) - UpdateSession(ctx context.Context, arg UpdateSessionParams) error - UpsertPR(ctx context.Context, arg UpsertPRParams) error - UpsertPRCheck(ctx context.Context, arg UpsertPRCheckParams) error - UpsertPRComment(ctx context.Context, arg UpsertPRCommentParams) error - UpsertProject(ctx context.Context, arg UpsertProjectParams) error -} - -var _ Querier = (*Queries)(nil) diff --git a/backend/internal/storage/sqlite/gen/sessions.sql.go b/backend/internal/storage/sqlite/gen/sessions.sql.go index 5365a22c4c..2acc891882 100644 --- a/backend/internal/storage/sqlite/gen/sessions.sql.go +++ b/backend/internal/storage/sqlite/gen/sessions.sql.go @@ -7,24 +7,19 @@ package gen import ( "context" - "database/sql" "time" -) - -const deleteSession = `-- name: DeleteSession :exec -DELETE FROM sessions WHERE id = ? -` -func (q *Queries) DeleteSession(ctx context.Context, id string) error { - _, err := q.db.ExecContext(ctx, deleteSession, id) - return err -} + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) const getSession = `-- name: GetSession :one -SELECT id, project_id, num, issue_id, kind, harness, session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, created_at, updated_at FROM sessions WHERE id = ? +SELECT id, project_id, num, issue_id, kind, harness, + activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + runtime_handle_id, agent_session_id, prompt, created_at, updated_at +FROM sessions WHERE id = ? ` -func (q *Queries) GetSession(ctx context.Context, id string) (Session, error) { +func (q *Queries) GetSession(ctx context.Context, id domain.SessionID) (Session, error) { row := q.db.QueryRowContext(ctx, getSession, id) var i Session err := row.Scan( @@ -34,19 +29,13 @@ func (q *Queries) GetSession(ctx context.Context, id string) (Session, error) { &i.IssueID, &i.Kind, &i.Harness, - &i.SessionState, - &i.TerminationReason, - &i.IsAlive, &i.ActivityState, &i.ActivityLastAt, &i.ActivitySource, - &i.DetectingAttempts, - &i.DetectingStartedAt, - &i.DetectingEvidenceHash, + &i.IsTerminated, &i.Branch, &i.WorkspacePath, &i.RuntimeHandleID, - &i.RuntimeName, &i.AgentSessionID, &i.Prompt, &i.CreatedAt, @@ -58,38 +47,30 @@ func (q *Queries) GetSession(ctx context.Context, id string) (Session, error) { const insertSession = `-- name: InsertSession :exec INSERT INTO sessions ( id, project_id, num, issue_id, kind, harness, - session_state, termination_reason, is_alive, - activity_state, activity_last_at, activity_source, - detecting_attempts, detecting_started_at, detecting_evidence_hash, - branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, + activity_state, activity_last_at, activity_source, is_terminated, + branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertSessionParams struct { - ID string - ProjectID string - Num int64 - IssueID string - Kind string - Harness string - SessionState string - TerminationReason string - IsAlive int64 - ActivityState string - ActivityLastAt time.Time - ActivitySource string - DetectingAttempts sql.NullInt64 - DetectingStartedAt sql.NullTime - DetectingEvidenceHash sql.NullString - Branch string - WorkspacePath string - RuntimeHandleID string - RuntimeName string - AgentSessionID string - Prompt string - CreatedAt time.Time - UpdatedAt time.Time + ID domain.SessionID + ProjectID domain.ProjectID + Num int64 + IssueID domain.IssueID + Kind domain.SessionKind + Harness domain.AgentHarness + ActivityState domain.ActivityState + ActivityLastAt time.Time + ActivitySource domain.ActivitySource + IsTerminated bool + Branch string + WorkspacePath string + RuntimeHandleID string + AgentSessionID string + Prompt string + CreatedAt time.Time + UpdatedAt time.Time } func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) error { @@ -100,19 +81,13 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er arg.IssueID, arg.Kind, arg.Harness, - arg.SessionState, - arg.TerminationReason, - arg.IsAlive, arg.ActivityState, arg.ActivityLastAt, arg.ActivitySource, - arg.DetectingAttempts, - arg.DetectingStartedAt, - arg.DetectingEvidenceHash, + arg.IsTerminated, arg.Branch, arg.WorkspacePath, arg.RuntimeHandleID, - arg.RuntimeName, arg.AgentSessionID, arg.Prompt, arg.CreatedAt, @@ -122,7 +97,10 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er } const listAllSessions = `-- name: ListAllSessions :many -SELECT id, project_id, num, issue_id, kind, harness, session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, created_at, updated_at FROM sessions ORDER BY project_id, num +SELECT id, project_id, num, issue_id, kind, harness, + activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + runtime_handle_id, agent_session_id, prompt, created_at, updated_at +FROM sessions ORDER BY project_id, num ` func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { @@ -141,19 +119,13 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { &i.IssueID, &i.Kind, &i.Harness, - &i.SessionState, - &i.TerminationReason, - &i.IsAlive, &i.ActivityState, &i.ActivityLastAt, &i.ActivitySource, - &i.DetectingAttempts, - &i.DetectingStartedAt, - &i.DetectingEvidenceHash, + &i.IsTerminated, &i.Branch, &i.WorkspacePath, &i.RuntimeHandleID, - &i.RuntimeName, &i.AgentSessionID, &i.Prompt, &i.CreatedAt, @@ -173,10 +145,13 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { } const listSessionsByProject = `-- name: ListSessionsByProject :many -SELECT id, project_id, num, issue_id, kind, harness, session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, created_at, updated_at FROM sessions WHERE project_id = ? ORDER BY num +SELECT id, project_id, num, issue_id, kind, harness, + activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + runtime_handle_id, agent_session_id, prompt, created_at, updated_at +FROM sessions WHERE project_id = ? ORDER BY num ` -func (q *Queries) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) { +func (q *Queries) ListSessionsByProject(ctx context.Context, projectID domain.ProjectID) ([]Session, error) { rows, err := q.db.QueryContext(ctx, listSessionsByProject, projectID) if err != nil { return nil, err @@ -192,19 +167,13 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID string) ( &i.IssueID, &i.Kind, &i.Harness, - &i.SessionState, - &i.TerminationReason, - &i.IsAlive, &i.ActivityState, &i.ActivityLastAt, &i.ActivitySource, - &i.DetectingAttempts, - &i.DetectingStartedAt, - &i.DetectingEvidenceHash, + &i.IsTerminated, &i.Branch, &i.WorkspacePath, &i.RuntimeHandleID, - &i.RuntimeName, &i.AgentSessionID, &i.Prompt, &i.CreatedAt, @@ -227,7 +196,7 @@ const nextSessionNum = `-- name: NextSessionNum :one SELECT COALESCE(MAX(num), 0) + 1 AS next FROM sessions WHERE project_id = ? ` -func (q *Queries) NextSessionNum(ctx context.Context, projectID string) (int64, error) { +func (q *Queries) NextSessionNum(ctx context.Context, projectID domain.ProjectID) (int64, error) { row := q.db.QueryRowContext(ctx, nextSessionNum, projectID) var next int64 err := row.Scan(&next) @@ -237,35 +206,27 @@ func (q *Queries) NextSessionNum(ctx context.Context, projectID string) (int64, const updateSession = `-- name: UpdateSession :exec UPDATE sessions SET issue_id = ?, kind = ?, harness = ?, - session_state = ?, termination_reason = ?, is_alive = ?, - activity_state = ?, activity_last_at = ?, activity_source = ?, - detecting_attempts = ?, detecting_started_at = ?, detecting_evidence_hash = ?, - branch = ?, workspace_path = ?, runtime_handle_id = ?, runtime_name = ?, agent_session_id = ?, prompt = ?, + activity_state = ?, activity_last_at = ?, activity_source = ?, is_terminated = ?, + branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?, updated_at = ? WHERE id = ? ` type UpdateSessionParams struct { - IssueID string - Kind string - Harness string - SessionState string - TerminationReason string - IsAlive int64 - ActivityState string - ActivityLastAt time.Time - ActivitySource string - DetectingAttempts sql.NullInt64 - DetectingStartedAt sql.NullTime - DetectingEvidenceHash sql.NullString - Branch string - WorkspacePath string - RuntimeHandleID string - RuntimeName string - AgentSessionID string - Prompt string - UpdatedAt time.Time - ID string + IssueID domain.IssueID + Kind domain.SessionKind + Harness domain.AgentHarness + ActivityState domain.ActivityState + ActivityLastAt time.Time + ActivitySource domain.ActivitySource + IsTerminated bool + Branch string + WorkspacePath string + RuntimeHandleID string + AgentSessionID string + Prompt string + UpdatedAt time.Time + ID domain.SessionID } func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) error { @@ -273,19 +234,13 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) er arg.IssueID, arg.Kind, arg.Harness, - arg.SessionState, - arg.TerminationReason, - arg.IsAlive, arg.ActivityState, arg.ActivityLastAt, arg.ActivitySource, - arg.DetectingAttempts, - arg.DetectingStartedAt, - arg.DetectingEvidenceHash, + arg.IsTerminated, arg.Branch, arg.WorkspacePath, arg.RuntimeHandleID, - arg.RuntimeName, arg.AgentSessionID, arg.Prompt, arg.UpdatedAt, diff --git a/backend/internal/storage/sqlite/mapping.go b/backend/internal/storage/sqlite/mapping.go deleted file mode 100644 index 792854cf20..0000000000 --- a/backend/internal/storage/sqlite/mapping.go +++ /dev/null @@ -1,125 +0,0 @@ -package sqlite - -import ( - "database/sql" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -func boolToInt(b bool) int64 { - if b { - return 1 - } - return 0 -} - -// rowToRecord maps a stored session row to a domain record. The folded-in -// operational columns become Metadata; the canonical lifecycle is reassembled -// from the typed columns. Display status is never reconstructed here. -func rowToRecord(row gen.Session) domain.SessionRecord { - return domain.SessionRecord{ - ID: domain.SessionID(row.ID), - ProjectID: domain.ProjectID(row.ProjectID), - IssueID: domain.IssueID(row.IssueID), - Kind: domain.SessionKind(row.Kind), - Lifecycle: domain.CanonicalSessionLifecycle{ - Version: domain.LifecycleVersion, - Harness: domain.AgentHarness(row.Harness), - IsAlive: row.IsAlive != 0, - Session: domain.SessionSubstate{State: domain.SessionState(row.SessionState)}, - TerminationReason: domain.TerminationReason(row.TerminationReason), - Activity: domain.ActivitySubstate{ - State: domain.ActivityState(row.ActivityState), - LastActivityAt: row.ActivityLastAt, - Source: domain.ActivitySource(row.ActivitySource), - }, - Detecting: nullToDetecting(row), - }, - Metadata: domain.SessionMetadata{ - Branch: row.Branch, - WorkspacePath: row.WorkspacePath, - RuntimeHandleID: row.RuntimeHandleID, - RuntimeName: row.RuntimeName, - AgentSessionID: row.AgentSessionID, - Prompt: row.Prompt, - }, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } -} - -func recordToInsert(rec domain.SessionRecord, num int64) gen.InsertSessionParams { - da, ds, dh := detectingToNull(rec.Lifecycle.Detecting) - return gen.InsertSessionParams{ - ID: string(rec.ID), - ProjectID: string(rec.ProjectID), - Num: num, - IssueID: string(rec.IssueID), - Kind: string(rec.Kind), - Harness: string(rec.Lifecycle.Harness), - SessionState: string(rec.Lifecycle.Session.State), - TerminationReason: string(rec.Lifecycle.TerminationReason), - IsAlive: boolToInt(rec.Lifecycle.IsAlive), - ActivityState: string(rec.Lifecycle.Activity.State), - ActivityLastAt: rec.Lifecycle.Activity.LastActivityAt, - ActivitySource: string(rec.Lifecycle.Activity.Source), - DetectingAttempts: da, - DetectingStartedAt: ds, - DetectingEvidenceHash: dh, - Branch: rec.Metadata.Branch, - WorkspacePath: rec.Metadata.WorkspacePath, - RuntimeHandleID: rec.Metadata.RuntimeHandleID, - RuntimeName: rec.Metadata.RuntimeName, - AgentSessionID: rec.Metadata.AgentSessionID, - Prompt: rec.Metadata.Prompt, - CreatedAt: rec.CreatedAt, - UpdatedAt: rec.UpdatedAt, - } -} - -func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams { - da, ds, dh := detectingToNull(rec.Lifecycle.Detecting) - return gen.UpdateSessionParams{ - IssueID: string(rec.IssueID), - Kind: string(rec.Kind), - Harness: string(rec.Lifecycle.Harness), - SessionState: string(rec.Lifecycle.Session.State), - TerminationReason: string(rec.Lifecycle.TerminationReason), - IsAlive: boolToInt(rec.Lifecycle.IsAlive), - ActivityState: string(rec.Lifecycle.Activity.State), - ActivityLastAt: rec.Lifecycle.Activity.LastActivityAt, - ActivitySource: string(rec.Lifecycle.Activity.Source), - DetectingAttempts: da, - DetectingStartedAt: ds, - DetectingEvidenceHash: dh, - Branch: rec.Metadata.Branch, - WorkspacePath: rec.Metadata.WorkspacePath, - RuntimeHandleID: rec.Metadata.RuntimeHandleID, - RuntimeName: rec.Metadata.RuntimeName, - AgentSessionID: rec.Metadata.AgentSessionID, - Prompt: rec.Metadata.Prompt, - UpdatedAt: rec.UpdatedAt, - ID: string(rec.ID), - } -} - -func detectingToNull(d *domain.DetectingState) (sql.NullInt64, sql.NullTime, sql.NullString) { - if d == nil { - return sql.NullInt64{}, sql.NullTime{}, sql.NullString{} - } - return sql.NullInt64{Int64: int64(d.Attempts), Valid: true}, - sql.NullTime{Time: d.StartedAt, Valid: true}, - sql.NullString{String: d.EvidenceHash, Valid: true} -} - -func nullToDetecting(row gen.Session) *domain.DetectingState { - if !row.DetectingAttempts.Valid { - return nil - } - return &domain.DetectingState{ - Attempts: int(row.DetectingAttempts.Int64), - StartedAt: row.DetectingStartedAt.Time, - EvidenceHash: row.DetectingEvidenceHash.String, - } -} diff --git a/backend/internal/storage/sqlite/migrations/0001_init.sql b/backend/internal/storage/sqlite/migrations/0001_init.sql index 9d5a6a22da..d308fb337b 100644 --- a/backend/internal/storage/sqlite/migrations/0001_init.sql +++ b/backend/internal/storage/sqlite/migrations/0001_init.sql @@ -14,43 +14,30 @@ CREATE TABLE projects ( archived_at TIMESTAMP ); --- sessions is the canonical record. id is "{project_id}-{num}" (e.g. mer-1) — a --- single string key, so every inbound FK is single-column. num is the per-project --- counter (computed at insert under the write mutex). Operational metadata is --- folded in (no separate table). is_alive replaces the old runtime axis; there is --- no revision column — the per-session write mutex serializes and change_log.seq --- orders. The display status is derived on read (from this + the pr row), never --- stored. +-- sessions is the durable session fact row. id is "{project_id}-{num}" +-- (e.g. mer-1), so every inbound FK is single-column. num is the per-project +-- counter. The only persisted status-like facts are activity_state and +-- is_terminated; display status is derived on read from this row plus PR facts. CREATE TABLE sessions ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects (id), num INTEGER NOT NULL, issue_id TEXT NOT NULL DEFAULT '', - kind TEXT NOT NULL DEFAULT 'worker', + kind TEXT NOT NULL DEFAULT 'worker' + CHECK (kind IN ('worker', 'orchestrator')), harness TEXT NOT NULL DEFAULT '' CHECK (harness IN ('', 'claude-code', 'codex', 'aider', 'opencode')), - session_state TEXT NOT NULL - CHECK (session_state IN ('not_started', 'working', 'idle', 'needs_input', 'stuck', 'detecting', 'done', 'terminated')), - -- only terminal sessions carry a reason; '' otherwise. - termination_reason TEXT NOT NULL DEFAULT '' - CHECK (termination_reason IN ('', 'manually_killed', 'runtime_lost', 'agent_process_exited', 'probe_failure', 'error_in_process', 'auto_cleanup', 'pr_merged')), - is_alive INTEGER NOT NULL DEFAULT 0, - - activity_state TEXT NOT NULL DEFAULT 'idle', + activity_state TEXT NOT NULL DEFAULT 'idle' + CHECK (activity_state IN ('active', 'idle', 'waiting_input', 'blocked', 'exited')), activity_last_at TIMESTAMP NOT NULL, - activity_source TEXT NOT NULL DEFAULT 'none', - - -- detecting quarantine memory; NULL when the session is not in detecting. - detecting_attempts INTEGER, - detecting_started_at TIMESTAMP, - detecting_evidence_hash TEXT, + activity_source TEXT NOT NULL DEFAULT 'none' + CHECK (activity_source IN ('native', 'terminal', 'hook', 'runtime', 'none')), + is_terminated BOOLEAN NOT NULL DEFAULT FALSE, - -- folded-in operational handles (was the session_metadata table) branch TEXT NOT NULL DEFAULT '', workspace_path TEXT NOT NULL DEFAULT '', runtime_handle_id TEXT NOT NULL DEFAULT '', - runtime_name TEXT NOT NULL DEFAULT '', agent_session_id TEXT NOT NULL DEFAULT '', prompt TEXT NOT NULL DEFAULT '', @@ -80,9 +67,8 @@ CREATE TABLE pr ( ); CREATE INDEX idx_pr_session ON pr (session_id); --- pr_checks is CI run history: one row per (PR, check, commit). The CI-fix-loop --- brake is a LIMIT 3 query over it ("last 3 runs of this check all failed?") — no --- counter is stored. Re-polling the same commit upserts the same row. +-- pr_checks is CI run history: one row per (PR, check, commit). Re-polling the +-- same commit upserts the same row. CREATE TABLE pr_checks ( pr_url TEXT NOT NULL REFERENCES pr (url) ON DELETE CASCADE, name TEXT NOT NULL, @@ -120,8 +106,9 @@ CREATE TABLE change_log ( seq INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES projects (id), session_id TEXT REFERENCES sessions (id), - event_type TEXT NOT NULL, - payload TEXT NOT NULL, + event_type TEXT NOT NULL + CHECK (event_type IN ('session_created', 'session_updated', 'pr_created', 'pr_updated', 'pr_check_recorded')), + payload TEXT NOT NULL CHECK (json_valid(payload)), created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_change_log_project ON change_log (project_id, seq); @@ -138,8 +125,7 @@ AFTER INSERT ON sessions BEGIN INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) VALUES (NEW.project_id, NEW.id, 'session_created', - json_object('id', NEW.id, 'state', NEW.session_state, 'terminationReason', NEW.termination_reason, - 'isAlive', NEW.is_alive, 'activity', NEW.activity_state), + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), NEW.updated_at); END; -- +goose StatementEnd @@ -147,15 +133,12 @@ END; -- +goose StatementBegin CREATE TRIGGER sessions_cdc_update AFTER UPDATE ON sessions -WHEN OLD.session_state <> NEW.session_state - OR OLD.termination_reason <> NEW.termination_reason - OR OLD.is_alive <> NEW.is_alive - OR OLD.activity_state <> NEW.activity_state +WHEN OLD.activity_state <> NEW.activity_state + OR OLD.is_terminated <> NEW.is_terminated BEGIN INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) VALUES (NEW.project_id, NEW.id, 'session_updated', - json_object('id', NEW.id, 'state', NEW.session_state, 'terminationReason', NEW.termination_reason, - 'isAlive', NEW.is_alive, 'activity', NEW.activity_state), + json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), NEW.updated_at); END; -- +goose StatementEnd @@ -217,7 +200,7 @@ BEGIN (SELECT session_id FROM pr WHERE url = NEW.pr_url), 'pr_check_recorded', json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), - NEW.created_at); + datetime('now')); END; -- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/migrations/0002_notifications.sql b/backend/internal/storage/sqlite/migrations/0002_notifications.sql deleted file mode 100644 index 1ab12f5b7e..0000000000 --- a/backend/internal/storage/sqlite/migrations/0002_notifications.sql +++ /dev/null @@ -1,81 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE notifications ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT NOT NULL UNIQUE DEFAULT ('ntf_' || lower(hex(randomblob(16)))), - project_id TEXT NOT NULL REFERENCES projects(id), - session_id TEXT NOT NULL REFERENCES sessions(id), - source TEXT NOT NULL DEFAULT 'lifecycle' CHECK (source IN ('lifecycle')), - event_type TEXT NOT NULL, - semantic_type TEXT NOT NULL, - priority TEXT NOT NULL CHECK (priority IN ('urgent','action','warning','info')), - message TEXT NOT NULL, - payload_json TEXT NOT NULL CHECK (json_valid(payload_json)), - actions_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(actions_json)), - dedupe_key TEXT NOT NULL UNIQUE, - cause_key TEXT NOT NULL DEFAULT '', - read_at TIMESTAMP, - archived_at TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), - updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX idx_notifications_project_seq ON notifications(project_id, seq DESC); -CREATE INDEX idx_notifications_session_seq ON notifications(session_id, seq DESC); -CREATE INDEX idx_notifications_unread ON notifications(seq DESC) - WHERE read_at IS NULL AND archived_at IS NULL; --- +goose StatementEnd - --- +goose StatementBegin -CREATE TRIGGER notifications_cdc_insert -AFTER INSERT ON notifications -BEGIN - INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) - VALUES ( - NEW.project_id, - NEW.session_id, - 'notification_created', - json_object( - 'seq', NEW.seq, - 'id', NEW.id, - 'type', NEW.semantic_type, - 'priority', NEW.priority, - 'message', NEW.message, - 'data', json(NEW.payload_json), - 'actions', json(NEW.actions_json), - 'readAt', NEW.read_at, - 'archivedAt', NEW.archived_at - ), - NEW.created_at - ); -END; --- +goose StatementEnd - --- +goose StatementBegin -CREATE TRIGGER notifications_cdc_update -AFTER UPDATE ON notifications -WHEN OLD.read_at IS NOT NEW.read_at - OR OLD.archived_at IS NOT NEW.archived_at -BEGIN - INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) - VALUES ( - NEW.project_id, - NEW.session_id, - 'notification_updated', - json_object( - 'seq', NEW.seq, - 'id', NEW.id, - 'readAt', NEW.read_at, - 'archivedAt', NEW.archived_at - ), - NEW.updated_at - ); -END; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TRIGGER IF EXISTS notifications_cdc_update; -DROP TRIGGER IF EXISTS notifications_cdc_insert; -DROP TABLE IF EXISTS notifications; --- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/notification_store.go b/backend/internal/storage/sqlite/notification_store.go deleted file mode 100644 index 90b84331c7..0000000000 --- a/backend/internal/storage/sqlite/notification_store.go +++ /dev/null @@ -1,242 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// NotificationRow is the storage-facing notification row. It aliases the -// provider-neutral domain type so callers do not depend on sqlc structs. -type NotificationRow = domain.Notification - -// NotificationFilter constrains ListNotifications. A zero filter returns the -// newest notifications across projects. -type NotificationFilter struct { - ProjectID string - SessionID string - UnreadOnly bool - Limit int -} - -const defaultNotificationLimit = 100 - -// EnqueueNotification inserts a notification exactly once per dedupe key. The -// returned bool is true when a new row was created; false means the existing row -// for the same dedupe key was returned. -func (s *Store) EnqueueNotification(ctx context.Context, row NotificationRow) (NotificationRow, bool, error) { - row = normalizeNotification(row) - actionsJSON, err := json.Marshal(row.Actions) - if err != nil { - return NotificationRow{}, false, fmt.Errorf("marshal notification actions: %w", err) - } - - s.writeMu.Lock() - defer s.writeMu.Unlock() - - got, err := s.qw.InsertNotification(ctx, gen.InsertNotificationParams{ - ProjectID: string(row.ProjectID), - SessionID: string(row.SessionID), - Source: row.Source, - EventType: row.EventType, - SemanticType: row.SemanticType, - Priority: row.Priority, - Message: row.Message, - PayloadJson: string(row.Payload), - ActionsJson: string(actionsJSON), - DedupeKey: row.DedupeKey, - CauseKey: row.CauseKey, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - }) - if errors.Is(err, sql.ErrNoRows) { - existing, readErr := s.qw.GetNotificationByDedupeKey(ctx, row.DedupeKey) - if readErr != nil { - return NotificationRow{}, false, fmt.Errorf("get notification by dedupe %q: %w", row.DedupeKey, readErr) - } - mapped, mapErr := notificationFromGen(existing) - return mapped, false, mapErr - } - if err != nil { - return NotificationRow{}, false, fmt.Errorf("insert notification: %w", err) - } - mapped, err := notificationFromGen(got) - return mapped, true, err -} - -// GetNotification returns one notification by id, or ok=false if absent. -func (s *Store) GetNotification(ctx context.Context, id string) (NotificationRow, bool, error) { - row, err := s.qr.GetNotification(ctx, id) - if errors.Is(err, sql.ErrNoRows) { - return NotificationRow{}, false, nil - } - if err != nil { - return NotificationRow{}, false, fmt.Errorf("get notification %s: %w", id, err) - } - mapped, err := notificationFromGen(row) - return mapped, true, err -} - -// ListNotifications returns notifications in descending seq order. -func (s *Store) ListNotifications(ctx context.Context, filter NotificationFilter) ([]NotificationRow, error) { - limit := int64(filter.Limit) - if limit <= 0 { - limit = defaultNotificationLimit - } - - var ( - rows []gen.Notification - err error - ) - switch { - case filter.UnreadOnly: - rows, err = s.qr.ListUnreadNotifications(ctx, limit) - case filter.SessionID != "": - rows, err = s.qr.ListNotificationsBySession(ctx, gen.ListNotificationsBySessionParams{SessionID: filter.SessionID, Limit: limit}) - case filter.ProjectID != "": - rows, err = s.qr.ListNotificationsByProject(ctx, gen.ListNotificationsByProjectParams{ProjectID: filter.ProjectID, Limit: limit}) - default: - rows, err = s.qr.ListNotifications(ctx, limit) - } - if err != nil { - return nil, fmt.Errorf("list notifications: %w", err) - } - return notificationsFromGen(rows) -} - -// MarkNotificationRead marks an unread notification read. The returned bool is -// true only when the row changed; repeated calls return the existing row with -// changed=false and emit no CDC update. -func (s *Store) MarkNotificationRead(ctx context.Context, id string, at time.Time) (NotificationRow, bool, error) { - if at.IsZero() { - at = time.Now().UTC() - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - - row, err := s.qw.MarkNotificationRead(ctx, gen.MarkNotificationReadParams{ - ReadAt: sql.NullTime{Time: at, Valid: true}, - UpdatedAt: at, - ID: id, - }) - return s.changedNotificationResult(ctx, row, id, true, err) -} - -// MarkNotificationUnread clears read_at. Repeated calls are idempotent and emit -// no CDC update. -func (s *Store) MarkNotificationUnread(ctx context.Context, id string) (NotificationRow, bool, error) { - now := time.Now().UTC() - s.writeMu.Lock() - defer s.writeMu.Unlock() - - row, err := s.qw.MarkNotificationUnread(ctx, gen.MarkNotificationUnreadParams{UpdatedAt: now, ID: id}) - return s.changedNotificationResult(ctx, row, id, true, err) -} - -// ArchiveNotification marks a notification archived. Repeated calls are -// idempotent and emit no CDC update. -func (s *Store) ArchiveNotification(ctx context.Context, id string, at time.Time) (NotificationRow, bool, error) { - if at.IsZero() { - at = time.Now().UTC() - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - - row, err := s.qw.ArchiveNotification(ctx, gen.ArchiveNotificationParams{ - ArchivedAt: sql.NullTime{Time: at, Valid: true}, - UpdatedAt: at, - ID: id, - }) - return s.changedNotificationResult(ctx, row, id, true, err) -} - -func (s *Store) changedNotificationResult(ctx context.Context, row gen.Notification, id string, changed bool, err error) (NotificationRow, bool, error) { - if errors.Is(err, sql.ErrNoRows) { - existing, readErr := s.qw.GetNotification(ctx, id) - if errors.Is(readErr, sql.ErrNoRows) { - return NotificationRow{}, false, nil - } - if readErr != nil { - return NotificationRow{}, false, fmt.Errorf("get notification %s: %w", id, readErr) - } - mapped, mapErr := notificationFromGen(existing) - return mapped, false, mapErr - } - if err != nil { - return NotificationRow{}, false, err - } - mapped, mapErr := notificationFromGen(row) - return mapped, changed, mapErr -} - -func normalizeNotification(row NotificationRow) NotificationRow { - now := time.Now().UTC() - if row.Source == "" { - row.Source = "lifecycle" - } - if len(row.Payload) == 0 { - row.Payload = json.RawMessage(`{}`) - } - if row.Actions == nil { - row.Actions = []domain.NotificationAction{} - } - if row.CreatedAt.IsZero() { - row.CreatedAt = now - } - if row.UpdatedAt.IsZero() { - row.UpdatedAt = row.CreatedAt - } - return row -} - -func notificationsFromGen(rows []gen.Notification) ([]NotificationRow, error) { - out := make([]NotificationRow, 0, len(rows)) - for _, r := range rows { - row, err := notificationFromGen(r) - if err != nil { - return nil, err - } - out = append(out, row) - } - return out, nil -} - -func notificationFromGen(r gen.Notification) (NotificationRow, error) { - var actions []domain.NotificationAction - if r.ActionsJson == "" { - r.ActionsJson = "[]" - } - if err := json.Unmarshal([]byte(r.ActionsJson), &actions); err != nil { - return NotificationRow{}, fmt.Errorf("decode notification actions %s: %w", r.ID, err) - } - row := NotificationRow{ - Seq: r.Seq, - ID: domain.NotificationID(r.ID), - ProjectID: domain.ProjectID(r.ProjectID), - SessionID: domain.SessionID(r.SessionID), - Source: r.Source, - EventType: r.EventType, - SemanticType: r.SemanticType, - Priority: r.Priority, - Message: r.Message, - Payload: append(json.RawMessage(nil), []byte(r.PayloadJson)...), - Actions: actions, - DedupeKey: r.DedupeKey, - CauseKey: r.CauseKey, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - } - if r.ReadAt.Valid { - row.ReadAt = r.ReadAt.Time - } - if r.ArchivedAt.Valid { - row.ArchivedAt = r.ArchivedAt.Time - } - return row, nil -} diff --git a/backend/internal/storage/sqlite/notification_store_test.go b/backend/internal/storage/sqlite/notification_store_test.go deleted file mode 100644 index cd5c44a9e4..0000000000 --- a/backend/internal/storage/sqlite/notification_store_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package sqlite - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "sync" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -func TestNotificationInsertListGetAndDedupe(t *testing.T) { - s, rec := newNotificationTestSession(t) - ctx := context.Background() - - row, created, err := s.EnqueueNotification(ctx, sampleNotification(rec, "dedupe-1")) - if err != nil { - t.Fatal(err) - } - if !created || row.ID == "" || row.Seq == 0 { - t.Fatalf("enqueue created=%v row=%+v", created, row) - } - got, ok, err := s.GetNotification(ctx, string(row.ID)) - if err != nil || !ok { - t.Fatalf("get ok=%v err=%v", ok, err) - } - if got.DedupeKey != "dedupe-1" || got.Actions[0].ID != "open-session" { - t.Fatalf("get mismatch: %+v", got) - } - dup, created, err := s.EnqueueNotification(ctx, sampleNotification(rec, "dedupe-1")) - if err != nil { - t.Fatal(err) - } - if created || dup.ID != row.ID || dup.Seq != row.Seq { - t.Fatalf("duplicate should return existing row created=false: created=%v dup=%+v first=%+v", created, dup, row) - } - all, err := s.ListNotifications(ctx, NotificationFilter{Limit: 10}) - if err != nil || len(all) != 1 { - t.Fatalf("list all len=%d err=%v", len(all), err) - } - byProject, _ := s.ListNotifications(ctx, NotificationFilter{ProjectID: string(rec.ProjectID), Limit: 10}) - bySession, _ := s.ListNotifications(ctx, NotificationFilter{SessionID: string(rec.ID), Limit: 10}) - if len(byProject) != 1 || len(bySession) != 1 { - t.Fatalf("project/session lists = %d/%d", len(byProject), len(bySession)) - } -} - -func TestNotificationReadUnreadArchiveAndIdempotentCDC(t *testing.T) { - s, rec := newNotificationTestSession(t) - ctx := context.Background() - row, _, err := s.EnqueueNotification(ctx, sampleNotification(rec, "dedupe-read")) - if err != nil { - t.Fatal(err) - } - createdSeq, _ := s.MaxChangeLogSeq(ctx) - - readAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - read, changed, err := s.MarkNotificationRead(ctx, string(row.ID), readAt) - if err != nil || !changed { - t.Fatalf("mark read changed=%v err=%v", changed, err) - } - if read.ReadAt.IsZero() { - t.Fatal("read_at not set") - } - afterRead, _ := s.MaxChangeLogSeq(ctx) - if afterRead != createdSeq+1 { - t.Fatalf("read should emit one CDC event: before=%d after=%d", createdSeq, afterRead) - } - _, changed, err = s.MarkNotificationRead(ctx, string(row.ID), readAt.Add(time.Second)) - if err != nil || changed { - t.Fatalf("repeated mark read should be idempotent changed=false, got changed=%v err=%v", changed, err) - } - afterRepeat, _ := s.MaxChangeLogSeq(ctx) - if afterRepeat != afterRead { - t.Fatalf("repeated read emitted CDC: before=%d after=%d", afterRead, afterRepeat) - } - - unread, changed, err := s.MarkNotificationUnread(ctx, string(row.ID)) - if err != nil || !changed || !unread.ReadAt.IsZero() { - t.Fatalf("mark unread changed=%v err=%v row=%+v", changed, err, unread) - } - unreadList, err := s.ListNotifications(ctx, NotificationFilter{UnreadOnly: true, Limit: 10}) - if err != nil || len(unreadList) != 1 { - t.Fatalf("unread list len=%d err=%v", len(unreadList), err) - } - - archiveSeq, _ := s.MaxChangeLogSeq(ctx) - archived, changed, err := s.ArchiveNotification(ctx, string(row.ID), readAt.Add(2*time.Second)) - if err != nil || !changed || archived.ArchivedAt.IsZero() { - t.Fatalf("archive changed=%v err=%v row=%+v", changed, err, archived) - } - afterArchive, _ := s.MaxChangeLogSeq(ctx) - if afterArchive != archiveSeq+1 { - t.Fatalf("archive should emit one CDC event: before=%d after=%d", archiveSeq, afterArchive) - } - _, changed, err = s.ArchiveNotification(ctx, string(row.ID), readAt.Add(3*time.Second)) - if err != nil || changed { - t.Fatalf("repeated archive should be idempotent changed=false, got changed=%v err=%v", changed, err) - } - afterArchiveRepeat, _ := s.MaxChangeLogSeq(ctx) - if afterArchiveRepeat != afterArchive { - t.Fatalf("repeated archive emitted CDC: before=%d after=%d", afterArchive, afterArchiveRepeat) - } -} - -func TestNotificationJSONConstraintsRejectInvalidPayloadAndActions(t *testing.T) { - s, rec := newNotificationTestSession(t) - ctx := context.Background() - - badPayload := sampleNotification(rec, "bad-payload") - badPayload.Payload = json.RawMessage(`{"nope"`) - if _, _, err := s.EnqueueNotification(ctx, badPayload); err == nil { - t.Fatal("invalid payload JSON should be rejected") - } - - now := time.Now().UTC().Truncate(time.Second) - _, err := s.qw.InsertNotification(ctx, gen.InsertNotificationParams{ - ProjectID: string(rec.ProjectID), - SessionID: string(rec.ID), - Source: "lifecycle", - EventType: "reaction.agent-needs-input", - SemanticType: "session.needs_input", - Priority: "urgent", - Message: "bad actions", - PayloadJson: `{}`, - ActionsJson: `{not-json`, - DedupeKey: "bad-actions", - CauseKey: "agent-needs-input", - CreatedAt: now, - UpdatedAt: now, - }) - if err == nil { - t.Fatal("invalid actions JSON should be rejected") - } -} - -func TestNotificationCDCForCreateReadArchive(t *testing.T) { - s, rec := newNotificationTestSession(t) - ctx := context.Background() - startSeq, _ := s.MaxChangeLogSeq(ctx) - row, _, err := s.EnqueueNotification(ctx, sampleNotification(rec, "dedupe-cdc")) - if err != nil { - t.Fatal(err) - } - _, _, _ = s.MarkNotificationRead(ctx, string(row.ID), time.Now().UTC()) - _, _, _ = s.ArchiveNotification(ctx, string(row.ID), time.Now().UTC()) - - evs, err := s.ReadChangeLogAfter(ctx, startSeq, 10) - if err != nil { - t.Fatal(err) - } - var types []string - for _, e := range evs { - types = append(types, e.EventType) - if e.EventType == "notification_created" && !strings.Contains(e.Payload, `"data"`) { - t.Fatalf("notification_created payload missing data: %s", e.Payload) - } - } - want := []string{"notification_created", "notification_updated", "notification_updated"} - if fmt.Sprint(types) != fmt.Sprint(want) { - t.Fatalf("notification CDC types = %v, want %v", types, want) - } -} - -func TestConcurrentNotificationEnqueueSameDedupeCreatesOneRow(t *testing.T) { - s, rec := newNotificationTestSession(t) - ctx := context.Background() - const n = 20 - var wg sync.WaitGroup - ids := make(chan domain.NotificationID, n) - for i := 0; i < n; i++ { - wg.Add(1) - go func() { - defer wg.Done() - row, _, err := s.EnqueueNotification(ctx, sampleNotification(rec, "dedupe-concurrent")) - if err != nil { - t.Errorf("enqueue: %v", err) - return - } - ids <- row.ID - }() - } - wg.Wait() - close(ids) - var first domain.NotificationID - for id := range ids { - if first == "" { - first = id - } - if id != first { - t.Fatalf("all callers should see same id, got %q and %q", first, id) - } - } - rows, err := s.ListNotifications(ctx, NotificationFilter{Limit: 10}) - if err != nil || len(rows) != 1 { - t.Fatalf("list len=%d err=%v", len(rows), err) - } -} - -func newNotificationTestSession(t *testing.T) (*Store, domain.SessionRecord) { - t.Helper() - s := newTestStore(t) - seedProject(t, s, "ao") - rec, err := s.CreateSession(context.Background(), sampleRecord("ao")) - if err != nil { - t.Fatalf("create session: %v", err) - } - return s, rec -} - -func sampleNotification(rec domain.SessionRecord, dedupe string) NotificationRow { - now := time.Now().UTC().Truncate(time.Second) - return NotificationRow{ - ProjectID: rec.ProjectID, - SessionID: rec.ID, - Source: "lifecycle", - EventType: "reaction.agent-needs-input", - SemanticType: "session.needs_input", - Priority: "urgent", - Message: "Agent needs input to continue.", - Payload: json.RawMessage(`{"schemaVersion":3,"semanticType":"session.needs_input"}`), - Actions: []domain.NotificationAction{{ID: "open-session", Kind: "route", Label: "Open session", Route: "/projects/ao/sessions/ao-1"}}, - DedupeKey: dedupe, - CauseKey: "agent-needs-input", - CreatedAt: now, - UpdatedAt: now, - } -} diff --git a/backend/internal/storage/sqlite/pr_facts.go b/backend/internal/storage/sqlite/pr_facts.go deleted file mode 100644 index c0c3068b41..0000000000 --- a/backend/internal/storage/sqlite/pr_facts.go +++ /dev/null @@ -1,43 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// PRFactsForSession picks the PR that drives display/reaction status — the -// newest non-closed PR, else the newest PR — and folds in whether it has -// unresolved review comments. -func (s *Store) PRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, error) { - rows, err := s.ListPRsBySession(ctx, string(id)) - if err != nil { - return domain.PRFacts{}, err - } - if len(rows) == 0 { - return domain.PRFacts{}, nil - } - pick := rows[0] - for _, r := range rows { - if !r.Merged && !r.Closed { // newest non-closed (draft or open) - pick = r - break - } - } - facts := domain.PRFacts{ - URL: pick.URL, Number: pick.Number, Exists: true, - Draft: pick.Draft, Merged: pick.Merged, Closed: pick.Closed, - CI: pick.CI, Review: pick.Review, Mergeability: pick.Mergeability, - } - comments, err := s.ListPRComments(ctx, pick.URL) - if err != nil { - return domain.PRFacts{}, err - } - for _, c := range comments { - if !c.Resolved { - facts.ReviewComments = true - break - } - } - return facts, nil -} diff --git a/backend/internal/storage/sqlite/pr_store.go b/backend/internal/storage/sqlite/pr_store.go deleted file mode 100644 index 1d57b40d44..0000000000 --- a/backend/internal/storage/sqlite/pr_store.go +++ /dev/null @@ -1,246 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "errors" - "fmt" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// The pr / pr_checks / pr_comment rows are modelled by domain.PRRow / -// domain.PRCheckRow / domain.PRComment — flat tables, one shared type per table. -// This layer only maps those to/from the sqlc gen.* params: the bool PR state -// becomes the single pr.state column, empty enums default to their -// "nothing known yet" value (matching the CHECK constraints), and ints widen to -// int64. - -// Compile-time proof that *Store satisfies both ports it is wired into, so a -// drift between either interface and this implementation fails here at the point -// of definition rather than later at the call sites in lifecycle_wiring / tests. -var ( - _ ports.SessionStore = (*Store)(nil) - _ ports.PRWriter = (*Store)(nil) -) - -// UpsertPR inserts or replaces the scalar PR facts for a PR URL. -func (s *Store) UpsertPR(ctx context.Context, r domain.PRRow) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.qw.UpsertPR(ctx, genPRParams(r)) -} - -// WritePR persists a full PR observation — scalar facts, check runs, and the -// replacement comment set — in one write transaction, so the rows and the -// change_log events their triggers emit are committed all-or-nothing. The scalar -// PR upsert runs first so the checks'/comments' CDC triggers can resolve the -// session id from the pr row within the same transaction. -func (s *Store) WritePR(ctx context.Context, pr domain.PRRow, checks []domain.PRCheckRow, comments []domain.PRComment) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.inTx(ctx, "write pr observation", func(q *gen.Queries) error { - if err := q.UpsertPR(ctx, genPRParams(pr)); err != nil { - return err - } - for _, c := range checks { - if err := q.UpsertPRCheck(ctx, genCheckParams(c)); err != nil { - return err - } - } - if err := q.DeletePRComments(ctx, pr.URL); err != nil { - return err - } - for _, c := range comments { - if err := q.UpsertPRComment(ctx, genCommentParams(pr.URL, c)); err != nil { - return fmt.Errorf("comment %q: %w", c.ID, err) - } - } - return nil - }) -} - -// GetPR returns the PR facts for a URL, or ok=false if absent. -func (s *Store) GetPR(ctx context.Context, url string) (domain.PRRow, bool, error) { - p, err := s.qr.GetPR(ctx, url) - if errors.Is(err, sql.ErrNoRows) { - return domain.PRRow{}, false, nil - } - if err != nil { - return domain.PRRow{}, false, fmt.Errorf("get pr %s: %w", url, err) - } - return prRowFromGen(p), true, nil -} - -// ListPRsBySession returns every PR owned by a session, newest first. -func (s *Store) ListPRsBySession(ctx context.Context, sessionID string) ([]domain.PRRow, error) { - rows, err := s.qr.ListPRsBySession(ctx, sessionID) - if err != nil { - return nil, fmt.Errorf("list prs for %s: %w", sessionID, err) - } - out := make([]domain.PRRow, 0, len(rows)) - for _, p := range rows { - out = append(out, prRowFromGen(p)) - } - return out, nil -} - -// DeletePR removes a PR (cascades to its checks + comments). -func (s *Store) DeletePR(ctx context.Context, url string) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.qw.DeletePR(ctx, url) -} - -// RecordCheck upserts a CI check run. Re-polling the same (pr, name, commit) -// updates the same row; a new commit creates a new row (a fresh agent attempt). -func (s *Store) RecordCheck(ctx context.Context, r domain.PRCheckRow) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.qw.UpsertPRCheck(ctx, genCheckParams(r)) -} - -// RecentCheckStatuses returns the statuses of the last `limit` runs of a check, -// most-recent first. The CI-fix-loop brake reads this: "last 3 all failed?". -func (s *Store) RecentCheckStatuses(ctx context.Context, prURL, name string, limit int) ([]string, error) { - rows, err := s.qr.ListRecentChecks(ctx, gen.ListRecentChecksParams{ - PrUrl: prURL, Name: name, Limit: int64(limit), - }) - if err != nil { - return nil, fmt.Errorf("recent checks %s/%s: %w", prURL, name, err) - } - out := make([]string, 0, len(rows)) - for _, r := range rows { - out = append(out, r.Status) - } - return out, nil -} - -// ListChecks returns every recorded check run for a PR. -func (s *Store) ListChecks(ctx context.Context, prURL string) ([]domain.PRCheckRow, error) { - rows, err := s.qr.ListChecksByPR(ctx, prURL) - if err != nil { - return nil, fmt.Errorf("list checks %s: %w", prURL, err) - } - out := make([]domain.PRCheckRow, 0, len(rows)) - for _, c := range rows { - out = append(out, checkRowFromGen(c)) - } - return out, nil -} - -// ReplacePRComments atomically replaces the full comment set for a PR (each SCM -// fetch reports the current set, so a replace keeps it in sync). -func (s *Store) ReplacePRComments(ctx context.Context, prURL string, comments []domain.PRComment) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.inTx(ctx, "replace pr comments", func(q *gen.Queries) error { - if err := q.DeletePRComments(ctx, prURL); err != nil { - return err - } - for _, c := range comments { - if err := q.UpsertPRComment(ctx, genCommentParams(prURL, c)); err != nil { - return fmt.Errorf("comment %q: %w", c.ID, err) - } - } - return nil - }) -} - -// ListPRComments returns a PR's review comments, oldest first. -func (s *Store) ListPRComments(ctx context.Context, prURL string) ([]domain.PRComment, error) { - rows, err := s.qr.ListPRComments(ctx, prURL) - if err != nil { - return nil, fmt.Errorf("list pr comments %s: %w", prURL, err) - } - out := make([]domain.PRComment, 0, len(rows)) - for _, c := range rows { - out = append(out, commentFromGen(c)) - } - return out, nil -} - -// ---- domain <-> gen mapping ---- - -// prState collapses the PR's bools into the single pr.state column value. -func prState(r domain.PRRow) string { - switch { - case r.Merged: - return "merged" - case r.Closed: - return "closed" - case r.Draft: - return "draft" - default: - return "open" - } -} - -func orDefault(v, def string) string { - if v == "" { - return def - } - return v -} - -func genPRParams(r domain.PRRow) gen.UpsertPRParams { - return gen.UpsertPRParams{ - Url: r.URL, - SessionID: r.SessionID, - Number: int64(r.Number), - PrState: prState(r), - ReviewDecision: orDefault(string(r.Review), "none"), - CiState: orDefault(string(r.CI), "unknown"), - Mergeability: orDefault(string(r.Mergeability), "unknown"), - UpdatedAt: r.UpdatedAt, - } -} - -func prRowFromGen(p gen.Pr) domain.PRRow { - return domain.PRRow{ - URL: p.Url, - SessionID: p.SessionID, - Number: int(p.Number), - Draft: p.PrState == "draft", - Merged: p.PrState == "merged", - Closed: p.PrState == "closed", - CI: domain.CIState(p.CiState), - Review: domain.ReviewDecision(p.ReviewDecision), - Mergeability: domain.Mergeability(p.Mergeability), - UpdatedAt: p.UpdatedAt, - } -} - -func genCheckParams(c domain.PRCheckRow) gen.UpsertPRCheckParams { - status := c.Status - if status == "" { - status = "unknown" - } - return gen.UpsertPRCheckParams{ - PrUrl: c.PRURL, Name: c.Name, CommitHash: c.CommitHash, - Status: status, Url: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, - } -} - -func checkRowFromGen(c gen.PrCheck) domain.PRCheckRow { - return domain.PRCheckRow{ - PRURL: c.PrUrl, Name: c.Name, CommitHash: c.CommitHash, - Status: c.Status, URL: c.Url, LogTail: c.LogTail, CreatedAt: c.CreatedAt, - } -} - -func genCommentParams(prURL string, c domain.PRComment) gen.UpsertPRCommentParams { - return gen.UpsertPRCommentParams{ - PrUrl: prURL, CommentID: c.ID, Author: c.Author, File: c.File, - Line: int64(c.Line), Body: c.Body, Resolved: boolToInt(c.Resolved), CreatedAt: c.CreatedAt, - } -} - -func commentFromGen(c gen.PrComment) domain.PRComment { - return domain.PRComment{ - ID: c.CommentID, Author: c.Author, File: c.File, Line: int(c.Line), - Body: c.Body, Resolved: c.Resolved != 0, CreatedAt: c.CreatedAt, - } -} diff --git a/backend/internal/storage/sqlite/project_store.go b/backend/internal/storage/sqlite/project_store.go deleted file mode 100644 index d81943c3cb..0000000000 --- a/backend/internal/storage/sqlite/project_store.go +++ /dev/null @@ -1,93 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// ProjectRow is one registered repo (the projects table). id is a short slug -// (mer, ao). ArchivedAt zero means active. -type ProjectRow struct { - ID string - Path string - RepoOriginURL string - DisplayName string - RegisteredAt time.Time - ArchivedAt time.Time -} - -// UpsertProject inserts or updates a registered project. -func (s *Store) UpsertProject(ctx context.Context, r ProjectRow) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.qw.UpsertProject(ctx, gen.UpsertProjectParams{ - ID: r.ID, - Path: r.Path, - RepoOriginUrl: r.RepoOriginURL, - DisplayName: r.DisplayName, - RegisteredAt: r.RegisteredAt, - ArchivedAt: nullTime(r.ArchivedAt), - }) -} - -// GetProject returns a project by id (active or archived), or ok=false. -func (s *Store) GetProject(ctx context.Context, id string) (ProjectRow, bool, error) { - p, err := s.qr.GetProject(ctx, id) - if errors.Is(err, sql.ErrNoRows) { - return ProjectRow{}, false, nil - } - if err != nil { - return ProjectRow{}, false, fmt.Errorf("get project %s: %w", id, err) - } - return projectRowFromGen(p), true, nil -} - -// ListProjects returns active (non-archived) projects, ordered by id. -func (s *Store) ListProjects(ctx context.Context) ([]ProjectRow, error) { - rows, err := s.qr.ListProjects(ctx) - if err != nil { - return nil, fmt.Errorf("list projects: %w", err) - } - out := make([]ProjectRow, 0, len(rows)) - for _, p := range rows { - out = append(out, projectRowFromGen(p)) - } - return out, nil -} - -// ArchiveProject soft-deletes a project (the row stays so session.project_id -// still resolves). -func (s *Store) ArchiveProject(ctx context.Context, id string, at time.Time) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.qw.ArchiveProject(ctx, gen.ArchiveProjectParams{ - ArchivedAt: nullTime(at), - ID: id, - }) -} - -func projectRowFromGen(p gen.Project) ProjectRow { - r := ProjectRow{ - ID: p.ID, - Path: p.Path, - RepoOriginURL: p.RepoOriginUrl, - DisplayName: p.DisplayName, - RegisteredAt: p.RegisteredAt, - } - if p.ArchivedAt.Valid { - r.ArchivedAt = p.ArchivedAt.Time - } - return r -} - -func nullTime(t time.Time) sql.NullTime { - if t.IsZero() { - return sql.NullTime{} - } - return sql.NullTime{Time: t, Valid: true} -} diff --git a/backend/internal/storage/sqlite/queries/changelog.sql b/backend/internal/storage/sqlite/queries/changelog.sql index 0e11899c2a..9d41a3e3d0 100644 --- a/backend/internal/storage/sqlite/queries/changelog.sql +++ b/backend/internal/storage/sqlite/queries/changelog.sql @@ -2,9 +2,6 @@ SELECT seq, project_id, session_id, event_type, payload, created_at FROM change_log WHERE seq > ? ORDER BY seq LIMIT ?; --- name: ReadChangeLogAfterForProject :many -SELECT seq, project_id, session_id, event_type, payload, created_at -FROM change_log WHERE project_id = ? AND seq > ? ORDER BY seq LIMIT ?; -- name: MaxChangeLogSeq :one -SELECT COALESCE(MAX(seq), 0) AS seq FROM change_log; +SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) AS seq FROM change_log; diff --git a/backend/internal/storage/sqlite/queries/notifications.sql b/backend/internal/storage/sqlite/queries/notifications.sql deleted file mode 100644 index a896b43c91..0000000000 --- a/backend/internal/storage/sqlite/queries/notifications.sql +++ /dev/null @@ -1,70 +0,0 @@ --- name: InsertNotification :one -INSERT INTO notifications ( - project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (dedupe_key) DO NOTHING -RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; - --- name: GetNotification :one -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications WHERE id = ?; - --- name: GetNotificationByDedupeKey :one -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications WHERE dedupe_key = ?; - --- name: ListNotifications :many -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications -ORDER BY seq DESC -LIMIT ?; - --- name: ListNotificationsByProject :many -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications -WHERE project_id = ? -ORDER BY seq DESC -LIMIT ?; - --- name: ListNotificationsBySession :many -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications -WHERE session_id = ? -ORDER BY seq DESC -LIMIT ?; - --- name: ListUnreadNotifications :many -SELECT seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at -FROM notifications -WHERE read_at IS NULL AND archived_at IS NULL -ORDER BY seq DESC -LIMIT ?; - --- name: MarkNotificationRead :one -UPDATE notifications -SET read_at = ?, updated_at = ? -WHERE id = ? AND read_at IS NULL -RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; - --- name: MarkNotificationUnread :one -UPDATE notifications -SET read_at = NULL, updated_at = ? -WHERE id = ? AND read_at IS NOT NULL -RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; - --- name: ArchiveNotification :one -UPDATE notifications -SET archived_at = ?, updated_at = ? -WHERE id = ? AND archived_at IS NULL -RETURNING seq, id, project_id, session_id, source, event_type, semantic_type, priority, - message, payload_json, actions_json, dedupe_key, cause_key, read_at, archived_at, created_at, updated_at; diff --git a/backend/internal/storage/sqlite/queries/pr.sql b/backend/internal/storage/sqlite/queries/pr.sql index e6b41cf1af..508eddd436 100644 --- a/backend/internal/storage/sqlite/queries/pr.sql +++ b/backend/internal/storage/sqlite/queries/pr.sql @@ -2,7 +2,6 @@ INSERT INTO pr (url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (url) DO UPDATE SET - session_id = excluded.session_id, number = excluded.number, pr_state = excluded.pr_state, review_decision = excluded.review_decision, @@ -11,10 +10,34 @@ ON CONFLICT (url) DO UPDATE SET updated_at = excluded.updated_at; -- name: GetPR :one -SELECT * FROM pr WHERE url = ?; +SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at +FROM pr +WHERE url = ?; -- name: ListPRsBySession :many -SELECT * FROM pr WHERE session_id = ? ORDER BY updated_at DESC; +SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at +FROM pr +WHERE session_id = ? +ORDER BY updated_at DESC; --- name: DeletePR :exec -DELETE FROM pr WHERE url = ?; + +-- name: GetDisplayPRFactsBySession :one +SELECT + pr.url, + pr.number, + pr.pr_state, + pr.review_decision, + pr.ci_state, + pr.mergeability, + EXISTS ( + SELECT 1 + FROM pr_comment + WHERE pr_comment.pr_url = pr.url + AND pr_comment.resolved = 0 + ) AS review_comments +FROM pr +WHERE pr.session_id = ? +ORDER BY + CASE WHEN pr.pr_state NOT IN ('merged', 'closed') THEN 0 ELSE 1 END, + pr.updated_at DESC +LIMIT 1; diff --git a/backend/internal/storage/sqlite/queries/pr_checks.sql b/backend/internal/storage/sqlite/queries/pr_checks.sql index 2e3e3c1547..2e223729f7 100644 --- a/backend/internal/storage/sqlite/queries/pr_checks.sql +++ b/backend/internal/storage/sqlite/queries/pr_checks.sql @@ -6,10 +6,6 @@ ON CONFLICT (pr_url, name, commit_hash) DO UPDATE SET url = excluded.url, log_tail = excluded.log_tail; --- name: ListRecentChecks :many -SELECT status, commit_hash, created_at FROM pr_checks -WHERE pr_url = ? AND name = ? -ORDER BY created_at DESC LIMIT ?; - -- name: ListChecksByPR :many -SELECT * FROM pr_checks WHERE pr_url = ? ORDER BY name, created_at; +SELECT pr_url, name, commit_hash, status, url, log_tail, created_at +FROM pr_checks WHERE pr_url = ? ORDER BY name, created_at; diff --git a/backend/internal/storage/sqlite/queries/pr_comment.sql b/backend/internal/storage/sqlite/queries/pr_comment.sql index df4f99d01e..870a87d78a 100644 --- a/backend/internal/storage/sqlite/queries/pr_comment.sql +++ b/backend/internal/storage/sqlite/queries/pr_comment.sql @@ -1,12 +1,10 @@ --- name: UpsertPRComment :exec +-- name: InsertPRComment :exec INSERT INTO pr_comment (pr_url, comment_id, author, file, line, body, resolved, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (pr_url, comment_id) DO UPDATE SET - author = excluded.author, file = excluded.file, line = excluded.line, - body = excluded.body, resolved = excluded.resolved; +VALUES (?, ?, ?, ?, ?, ?, ?, ?); -- name: DeletePRComments :exec DELETE FROM pr_comment WHERE pr_url = ?; -- name: ListPRComments :many -SELECT * FROM pr_comment WHERE pr_url = ? ORDER BY created_at, comment_id; +SELECT pr_url, comment_id, author, file, line, body, resolved, created_at +FROM pr_comment WHERE pr_url = ? ORDER BY created_at, comment_id; diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql index 3dc28950b7..c5706035ef 100644 --- a/backend/internal/storage/sqlite/queries/projects.sql +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -15,5 +15,9 @@ FROM projects WHERE id = ?; SELECT id, path, repo_origin_url, display_name, registered_at, archived_at FROM projects WHERE archived_at IS NULL ORDER BY id; --- name: ArchiveProject :exec +-- name: FindProjectByPath :one +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +FROM projects WHERE path = ?; + +-- name: ArchiveProject :execrows UPDATE projects SET archived_at = ? WHERE id = ?; diff --git a/backend/internal/storage/sqlite/queries/sessions.sql b/backend/internal/storage/sqlite/queries/sessions.sql index 9b294de3c9..799718b8ae 100644 --- a/backend/internal/storage/sqlite/queries/sessions.sql +++ b/backend/internal/storage/sqlite/queries/sessions.sql @@ -4,31 +4,34 @@ SELECT COALESCE(MAX(num), 0) + 1 AS next FROM sessions WHERE project_id = ?; -- name: InsertSession :exec INSERT INTO sessions ( id, project_id, num, issue_id, kind, harness, - session_state, termination_reason, is_alive, - activity_state, activity_last_at, activity_source, - detecting_attempts, detecting_started_at, detecting_evidence_hash, - branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, + activity_state, activity_last_at, activity_source, is_terminated, + branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateSession :exec UPDATE sessions SET issue_id = ?, kind = ?, harness = ?, - session_state = ?, termination_reason = ?, is_alive = ?, - activity_state = ?, activity_last_at = ?, activity_source = ?, - detecting_attempts = ?, detecting_started_at = ?, detecting_evidence_hash = ?, - branch = ?, workspace_path = ?, runtime_handle_id = ?, runtime_name = ?, agent_session_id = ?, prompt = ?, + activity_state = ?, activity_last_at = ?, activity_source = ?, is_terminated = ?, + branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?, updated_at = ? WHERE id = ?; -- name: GetSession :one -SELECT * FROM sessions WHERE id = ?; +SELECT id, project_id, num, issue_id, kind, harness, + activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + runtime_handle_id, agent_session_id, prompt, created_at, updated_at +FROM sessions WHERE id = ?; -- name: ListSessionsByProject :many -SELECT * FROM sessions WHERE project_id = ? ORDER BY num; +SELECT id, project_id, num, issue_id, kind, harness, + activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + runtime_handle_id, agent_session_id, prompt, created_at, updated_at +FROM sessions WHERE project_id = ? ORDER BY num; -- name: ListAllSessions :many -SELECT * FROM sessions ORDER BY project_id, num; +SELECT id, project_id, num, issue_id, kind, harness, + activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + runtime_handle_id, agent_session_id, prompt, created_at, updated_at +FROM sessions ORDER BY project_id, num; --- name: DeleteSession :exec -DELETE FROM sessions WHERE id = ?; diff --git a/backend/internal/storage/sqlite/store.go b/backend/internal/storage/sqlite/store.go deleted file mode 100644 index 34d028da38..0000000000 --- a/backend/internal/storage/sqlite/store.go +++ /dev/null @@ -1,134 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "errors" - "fmt" - "sync" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// Store is the SQLite-backed persistence layer. It routes writes to a single -// writer connection (qw) and reads to a reader pool (qr) — see Open. writeMu -// guards the read-modify-write write methods (e.g. CreateSession's -// next-num-then-insert) so concurrent writes can't interleave them. -// -// CDC is captured by DB triggers (migration 0001), NOT by this layer: the store -// never writes change_log, it only reads it for the CDC poller. -type Store struct { - writeDB *sql.DB - readDB *sql.DB - qw *gen.Queries // bound to the single writer connection - qr *gen.Queries // bound to the reader pool - writeMu sync.Mutex -} - -// NewStore wraps an opened writer + reader *sql.DB (see Open) as a Store. -func NewStore(writeDB, readDB *sql.DB) *Store { - return &Store{ - writeDB: writeDB, - readDB: readDB, - qw: gen.New(writeDB), - qr: gen.New(readDB), - } -} - -// Close closes both pools. -func (s *Store) Close() error { - err := s.writeDB.Close() - if e := s.readDB.Close(); e != nil && err == nil { - err = e - } - return err -} - -// ---- sessions ---- - -// CreateSession assigns the per-project identity ("{project}-{num}") and inserts -// the record, returning it with ID populated. The next-num read and the insert -// run on the writer connection under writeMu, so two concurrent creates in the -// same project can't collide on num. -func (s *Store) CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { - s.writeMu.Lock() - defer s.writeMu.Unlock() - - num, err := s.qw.NextSessionNum(ctx, string(rec.ProjectID)) - if err != nil { - return domain.SessionRecord{}, fmt.Errorf("next session num for %s: %w", rec.ProjectID, err) - } - rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, num)) - if err := s.qw.InsertSession(ctx, recordToInsert(rec, num)); err != nil { - return domain.SessionRecord{}, fmt.Errorf("insert session %s: %w", rec.ID, err) - } - return rec, nil -} - -// UpdateSession writes the full mutable state of an existing session. The -// id/project/num/created_at are immutable and not touched here. -func (s *Store) UpdateSession(ctx context.Context, rec domain.SessionRecord) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.qw.UpdateSession(ctx, recordToUpdate(rec)) -} - -// GetSession returns the full record for a session, or ok=false if absent. -func (s *Store) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { - row, err := s.qr.GetSession(ctx, string(id)) - if errors.Is(err, sql.ErrNoRows) { - return domain.SessionRecord{}, false, nil - } - if err != nil { - return domain.SessionRecord{}, false, fmt.Errorf("get session %s: %w", id, err) - } - return rowToRecord(row), true, nil -} - -// ListSessions returns every session in a project, ordered by num. -func (s *Store) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { - rows, err := s.qr.ListSessionsByProject(ctx, string(project)) - if err != nil { - return nil, fmt.Errorf("list sessions for %s: %w", project, err) - } - return mapSessionRows(rows), nil -} - -// ListAllSessions returns every session across all projects. -func (s *Store) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) { - rows, err := s.qr.ListAllSessions(ctx) - if err != nil { - return nil, fmt.Errorf("list all sessions: %w", err) - } - return mapSessionRows(rows), nil -} - -// DeleteSession removes a session (cascades to its pr/checks/comments). -func (s *Store) DeleteSession(ctx context.Context, id domain.SessionID) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.qw.DeleteSession(ctx, string(id)) -} - -func mapSessionRows(rows []gen.Session) []domain.SessionRecord { - out := make([]domain.SessionRecord, 0, len(rows)) - for _, r := range rows { - out = append(out, rowToRecord(r)) - } - return out -} - -// inTx runs fn inside a single write transaction on the writer connection, -// rolling back on error. The caller must already hold writeMu. -func (s *Store) inTx(ctx context.Context, what string, fn func(*gen.Queries) error) error { - tx, err := s.writeDB.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin %s: %w", what, err) - } - defer func() { _ = tx.Rollback() }() - if err := fn(s.qw.WithTx(tx)); err != nil { - return fmt.Errorf("%s: %w", what, err) - } - return tx.Commit() -} diff --git a/backend/internal/storage/sqlite/store/changelog_store.go b/backend/internal/storage/sqlite/store/changelog_store.go new file mode 100644 index 0000000000..42b30a302c --- /dev/null +++ b/backend/internal/storage/sqlite/store/changelog_store.go @@ -0,0 +1,46 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// EventsAfter implements cdc.Source over the SQLite change_log table. +func (s *Store) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) { + rows, err := s.qr.ReadChangeLogAfter(ctx, gen.ReadChangeLogAfterParams{Seq: after, Limit: int64(limit)}) + if err != nil { + return nil, fmt.Errorf("read change_log after %d: %w", after, err) + } + events := make([]cdc.Event, 0, len(rows)) + for _, r := range rows { + events = append(events, changeLogEventFromGen(r)) + } + return events, nil +} + +// LatestSeq implements cdc.Source by returning the current change_log head. +func (s *Store) LatestSeq(ctx context.Context) (int64, error) { + seq, err := s.qr.MaxChangeLogSeq(ctx) + if err != nil { + return 0, fmt.Errorf("max change_log seq: %w", err) + } + return seq, nil +} + +func changeLogEventFromGen(r gen.ChangeLog) cdc.Event { + e := cdc.Event{ + Seq: r.Seq, + ProjectID: string(r.ProjectID), + Type: r.EventType, + Payload: json.RawMessage(r.Payload), + CreatedAt: r.CreatedAt, + } + if r.SessionID != nil { + e.SessionID = string(*r.SessionID) + } + return e +} diff --git a/backend/internal/storage/sqlite/pr_cdc_test.go b/backend/internal/storage/sqlite/store/pr_cdc_test.go similarity index 72% rename from backend/internal/storage/sqlite/pr_cdc_test.go rename to backend/internal/storage/sqlite/store/pr_cdc_test.go index 102e8b4f20..82f53b75e2 100644 --- a/backend/internal/storage/sqlite/pr_cdc_test.go +++ b/backend/internal/storage/sqlite/store/pr_cdc_test.go @@ -1,4 +1,4 @@ -package sqlite +package store_test import ( "context" @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) @@ -21,13 +22,9 @@ func TestPRChecksCDC_EmitsOnInsertAndStatusUpdate(t *testing.T) { t.Fatal(err) } url := "https://example/pr/1" - if err := s.UpsertPR(ctx, domain.PRRow{URL: url, SessionID: string(rec.ID), Number: 1}); err != nil { - t.Fatal(err) - } - now := time.Now() - mustCheck := func(status string) { - if err := s.RecordCheck(ctx, domain.PRCheckRow{PRURL: url, Name: "build", CommitHash: "c1", Status: status, CreatedAt: now}); err != nil { + mustCheck := func(status domain.PRCheckStatus) { + if err := s.WritePR(ctx, domain.PullRequest{URL: url, SessionID: rec.ID, Number: 1, UpdatedAt: now}, []domain.PullRequestCheck{{Name: "build", CommitHash: "c1", Status: status, CreatedAt: now}}, nil); err != nil { t.Fatal(err) } } @@ -35,20 +32,20 @@ func TestPRChecksCDC_EmitsOnInsertAndStatusUpdate(t *testing.T) { mustCheck("failed") // status change on same commit (update) -> event mustCheck("failed") // no-op re-poll (status unchanged) -> NO event - rows, err := s.ReadChangeLogAfter(ctx, 0, 100) + rows, err := s.EventsAfter(ctx, 0, 100) if err != nil { t.Fatal(err) } - var checkEvents []ChangeLogRow + var checkEvents []cdc.Event for _, r := range rows { - if r.EventType == "pr_check_recorded" { + if r.Type == "pr_check_recorded" { checkEvents = append(checkEvents, r) } } if len(checkEvents) != 2 { t.Fatalf("want 2 check CDC events (insert + status change, no-op suppressed), got %d", len(checkEvents)) } - if !strings.Contains(checkEvents[1].Payload, `"status":"failed"`) { + if !strings.Contains(string(checkEvents[1].Payload), `"status":"failed"`) { t.Fatalf("the update event should carry the new status, got %q", checkEvents[1].Payload) } } @@ -67,9 +64,9 @@ func TestWritePR_PersistsScalarsChecksAndComments(t *testing.T) { now := time.Now() err = s.WritePR(ctx, - domain.PRRow{URL: url, SessionID: string(rec.ID), Number: 7, CI: domain.CIFailing, UpdatedAt: now}, - []domain.PRCheckRow{{PRURL: url, Name: "build", CommitHash: "c1", Status: "failed", CreatedAt: now}}, - []domain.PRComment{{ID: "1", Author: "reviewer", Body: "use a const", CreatedAt: now}}, + domain.PullRequest{URL: url, SessionID: rec.ID, Number: 7, CI: domain.CIFailing, UpdatedAt: now}, + []domain.PullRequestCheck{{Name: "build", CommitHash: "c1", Status: "failed", CreatedAt: now}}, + []domain.PullRequestComment{{ID: "1", Author: "reviewer", Body: "use a const", CreatedAt: now}}, ) if err != nil { t.Fatal(err) diff --git a/backend/internal/storage/sqlite/store/pr_facts.go b/backend/internal/storage/sqlite/store/pr_facts.go new file mode 100644 index 0000000000..7bc9a84940 --- /dev/null +++ b/backend/internal/storage/sqlite/store/pr_facts.go @@ -0,0 +1,40 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// GetDisplayPRFactsForSession returns the PR snapshot that should represent a +// session in derived display status: active PRs first, otherwise the newest +// historical PR. ok=false means the session has no associated PRs. +func (s *Store) GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) { + r, err := s.qr.GetDisplayPRFactsBySession(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return domain.PRFacts{}, false, nil + } + if err != nil { + return domain.PRFacts{}, false, fmt.Errorf("display pr facts for %s: %w", id, err) + } + return prFactsFromGen(r), true, nil +} + +func prFactsFromGen(r gen.GetDisplayPRFactsBySessionRow) domain.PRFacts { + state := r.PRState + return domain.PRFacts{ + URL: r.URL, + Number: int(r.Number), + Draft: state == domain.PRStateDraft, + Merged: state == domain.PRStateMerged, + Closed: state == domain.PRStateClosed, + CI: r.CIState, + Review: r.ReviewDecision, + Mergeability: r.Mergeability, + ReviewComments: r.ReviewComments, + } +} diff --git a/backend/internal/storage/sqlite/store/pr_store.go b/backend/internal/storage/sqlite/store/pr_store.go new file mode 100644 index 0000000000..0f609f7bed --- /dev/null +++ b/backend/internal/storage/sqlite/store/pr_store.go @@ -0,0 +1,210 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// The pr / pr_checks / pr_comment rows are modelled by domain.PullRequest / +// domain.PullRequestCheck / domain.PullRequestComment — flat tables, one shared type per table. +// This layer only maps those to/from the sqlc gen.* params: the bool PR flags +// become the single pr.pr_state column, empty enums default to their +// "nothing known yet" value (matching the CHECK constraints), and ints widen to +// int64. + +// Compile-time proof that *Store satisfies both ports it is wired into, so a +// drift between either interface and this implementation fails here at the point +// of definition rather than later at the call sites in lifecycle_wiring / tests. +var ( + _ ports.PRWriter = (*Store)(nil) +) + +// WritePR persists a full PR observation — scalar facts, check runs, and the +// replacement comment set — in one write transaction, so the rows and the +// change_log events their triggers emit are committed all-or-nothing. The scalar +// PR upsert runs first so the checks'/comments' CDC triggers can resolve the +// session id from the pr row within the same transaction. +func (s *Store) WritePR(ctx context.Context, pr domain.PullRequest, checks []domain.PullRequestCheck, comments []domain.PullRequestComment) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.inTx(ctx, "write pr observation", func(q *gen.Queries) error { + existing, err := q.GetPR(ctx, pr.URL) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if err == nil && existing.SessionID != pr.SessionID { + return fmt.Errorf("pr %s already belongs to session %s", pr.URL, existing.SessionID) + } + if err := q.UpsertPR(ctx, genPRParams(pr)); err != nil { + return err + } + for _, c := range checks { + if err := q.UpsertPRCheck(ctx, genCheckParams(pr.URL, c)); err != nil { + return err + } + } + if err := q.DeletePRComments(ctx, pr.URL); err != nil { + return err + } + for _, c := range comments { + if err := q.InsertPRComment(ctx, genCommentParams(pr.URL, c)); err != nil { + return fmt.Errorf("comment %q: %w", c.ID, err) + } + } + return nil + }) +} + +// GetPR returns the PR facts for a URL, or ok=false if absent. +func (s *Store) GetPR(ctx context.Context, url string) (domain.PullRequest, bool, error) { + p, err := s.qr.GetPR(ctx, url) + if errors.Is(err, sql.ErrNoRows) { + return domain.PullRequest{}, false, nil + } + if err != nil { + return domain.PullRequest{}, false, fmt.Errorf("get pr %s: %w", url, err) + } + return prRowFromGen(p), true, nil +} + +// ListPRsBySession returns every PR owned by a session, newest first. +func (s *Store) ListPRsBySession(ctx context.Context, sessionID domain.SessionID) ([]domain.PullRequest, error) { + rows, err := s.qr.ListPRsBySession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("list prs for %s: %w", sessionID, err) + } + out := make([]domain.PullRequest, 0, len(rows)) + for _, p := range rows { + out = append(out, prRowFromGen(p)) + } + return out, nil +} + +// ListChecks returns every recorded check run for a PR. +func (s *Store) ListChecks(ctx context.Context, prURL string) ([]domain.PullRequestCheck, error) { + rows, err := s.qr.ListChecksByPR(ctx, prURL) + if err != nil { + return nil, fmt.Errorf("list checks %s: %w", prURL, err) + } + out := make([]domain.PullRequestCheck, 0, len(rows)) + for _, c := range rows { + out = append(out, checkRowFromGen(c)) + } + return out, nil +} + +// ListPRComments returns a PR's review comments, oldest first. +func (s *Store) ListPRComments(ctx context.Context, prURL string) ([]domain.PullRequestComment, error) { + rows, err := s.qr.ListPRComments(ctx, prURL) + if err != nil { + return nil, fmt.Errorf("list pr comments %s: %w", prURL, err) + } + out := make([]domain.PullRequestComment, 0, len(rows)) + for _, c := range rows { + out = append(out, commentFromGen(c)) + } + return out, nil +} + +// ---- domain <-> gen mapping ---- + +// prState collapses the PR's bools into the single pr.state column value. +func prState(r domain.PullRequest) domain.PRState { + switch { + case r.Merged: + return domain.PRStateMerged + case r.Closed: + return domain.PRStateClosed + case r.Draft: + return domain.PRStateDraft + default: + return domain.PRStateOpen + } +} + +func genPRParams(r domain.PullRequest) gen.UpsertPRParams { + return gen.UpsertPRParams{ + URL: r.URL, + SessionID: r.SessionID, + Number: int64(r.Number), + PRState: prState(r), + ReviewDecision: reviewOrDefault(r.Review), + CIState: ciOrDefault(r.CI), + Mergeability: mergeabilityOrDefault(r.Mergeability), + UpdatedAt: r.UpdatedAt, + } +} + +func reviewOrDefault(v domain.ReviewDecision) domain.ReviewDecision { + if v == "" { + return domain.ReviewNone + } + return v +} + +func ciOrDefault(v domain.CIState) domain.CIState { + if v == "" { + return domain.CIUnknown + } + return v +} + +func mergeabilityOrDefault(v domain.Mergeability) domain.Mergeability { + if v == "" { + return domain.MergeUnknown + } + return v +} + +func prRowFromGen(p gen.PR) domain.PullRequest { + return domain.PullRequest{ + URL: p.URL, + SessionID: p.SessionID, + Number: int(p.Number), + Draft: p.PRState == domain.PRStateDraft, + Merged: p.PRState == domain.PRStateMerged, + Closed: p.PRState == domain.PRStateClosed, + CI: p.CIState, + Review: p.ReviewDecision, + Mergeability: p.Mergeability, + UpdatedAt: p.UpdatedAt, + } +} + +func genCheckParams(prURL string, c domain.PullRequestCheck) gen.UpsertPRCheckParams { + status := c.Status + if status == "" { + status = domain.PRCheckUnknown + } + return gen.UpsertPRCheckParams{ + PRURL: prURL, Name: c.Name, CommitHash: c.CommitHash, + Status: status, URL: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, + } +} + +func checkRowFromGen(c gen.PRCheck) domain.PullRequestCheck { + return domain.PullRequestCheck{ + Name: c.Name, CommitHash: c.CommitHash, Status: c.Status, + URL: c.URL, LogTail: c.LogTail, CreatedAt: c.CreatedAt, + } +} + +func genCommentParams(prURL string, c domain.PullRequestComment) gen.InsertPRCommentParams { + return gen.InsertPRCommentParams{ + PRURL: prURL, CommentID: c.ID, Author: c.Author, File: c.File, + Line: int64(c.Line), Body: c.Body, Resolved: c.Resolved, CreatedAt: c.CreatedAt, + } +} + +func commentFromGen(c gen.PRComment) domain.PullRequestComment { + return domain.PullRequestComment{ + ID: c.CommentID, Author: c.Author, File: c.File, Line: int(c.Line), + Body: c.Body, Resolved: c.Resolved, CreatedAt: c.CreatedAt, + } +} diff --git a/backend/internal/storage/sqlite/store/project_store.go b/backend/internal/storage/sqlite/store/project_store.go new file mode 100644 index 0000000000..1d216d3e01 --- /dev/null +++ b/backend/internal/storage/sqlite/store/project_store.go @@ -0,0 +1,101 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/project" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +var _ project.Store = (*Store)(nil) + +// Upsert inserts or replaces a registered project row. +func (s *Store) Upsert(ctx context.Context, r project.Row) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.UpsertProject(ctx, gen.UpsertProjectParams{ + ID: domain.ProjectID(r.ID), + Path: r.Path, + RepoOriginURL: r.RepoOriginURL, + DisplayName: r.DisplayName, + RegisteredAt: r.RegisteredAt, + ArchivedAt: nullTime(r.ArchivedAt), + }) +} + +// Get returns a project by id, active or archived. +func (s *Store) Get(ctx context.Context, id string) (project.Row, bool, error) { + p, err := s.qr.GetProject(ctx, domain.ProjectID(id)) + if errors.Is(err, sql.ErrNoRows) { + return project.Row{}, false, nil + } + if err != nil { + return project.Row{}, false, fmt.Errorf("get project %s: %w", id, err) + } + return projectRowFromGen(p), true, nil +} + +// FindByPath returns a project registered at path, active or archived. +func (s *Store) FindByPath(ctx context.Context, path string) (project.Row, bool, error) { + p, err := s.qr.FindProjectByPath(ctx, path) + if errors.Is(err, sql.ErrNoRows) { + return project.Row{}, false, nil + } + if err != nil { + return project.Row{}, false, fmt.Errorf("find project by path %s: %w", path, err) + } + return projectRowFromGen(p), true, nil +} + +// List returns active projects ordered by id. +func (s *Store) List(ctx context.Context) ([]project.Row, error) { + rows, err := s.qr.ListProjects(ctx) + if err != nil { + return nil, fmt.Errorf("list projects: %w", err) + } + out := make([]project.Row, 0, len(rows)) + for _, p := range rows { + out = append(out, projectRowFromGen(p)) + } + return out, nil +} + +// Archive soft-deletes a project and reports whether a row was affected. +func (s *Store) Archive(ctx context.Context, id string, at time.Time) (bool, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + n, err := s.qw.ArchiveProject(ctx, gen.ArchiveProjectParams{ + ArchivedAt: nullTime(at), + ID: domain.ProjectID(id), + }) + if err != nil { + return false, err + } + return n > 0, nil +} + +func projectRowFromGen(p gen.Project) project.Row { + r := project.Row{ + ID: string(p.ID), + Path: p.Path, + RepoOriginURL: p.RepoOriginURL, + DisplayName: p.DisplayName, + RegisteredAt: p.RegisteredAt, + } + if p.ArchivedAt.Valid { + r.ArchivedAt = p.ArchivedAt.Time + } + return r +} + +func nullTime(t time.Time) sql.NullTime { + if t.IsZero() { + return sql.NullTime{} + } + return sql.NullTime{Time: t, Valid: true} +} diff --git a/backend/internal/storage/sqlite/store/session_store.go b/backend/internal/storage/sqlite/store/session_store.go new file mode 100644 index 0000000000..fefd7f3e47 --- /dev/null +++ b/backend/internal/storage/sqlite/store/session_store.go @@ -0,0 +1,163 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// ---- sessions ---- + +// CreateSession assigns the per-project identity ("{project}-{num}") and inserts +// the record, returning it with ID populated. The next-num read and the insert +// run on the writer connection under writeMu, so two concurrent creates in the +// same project can't collide on num. +func (s *Store) CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + + num, err := s.qw.NextSessionNum(ctx, rec.ProjectID) + if err != nil { + return domain.SessionRecord{}, fmt.Errorf("next session num for %s: %w", rec.ProjectID, err) + } + rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, num)) + if err := s.qw.InsertSession(ctx, recordToInsert(rec, num)); err != nil { + return domain.SessionRecord{}, fmt.Errorf("insert session %s: %w", rec.ID, err) + } + return rec, nil +} + +// UpdateSession writes the full mutable state of an existing session. The +// id/project/num/created_at are immutable and not touched here. +func (s *Store) UpdateSession(ctx context.Context, rec domain.SessionRecord) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.UpdateSession(ctx, recordToUpdate(rec)) +} + +// GetSession returns the full record for a session, or ok=false if absent. +func (s *Store) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { + row, err := s.qr.GetSession(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return domain.SessionRecord{}, false, nil + } + if err != nil { + return domain.SessionRecord{}, false, fmt.Errorf("get session %s: %w", id, err) + } + return rowToRecord(row), true, nil +} + +// ListSessions returns every session in a project, ordered by num. +func (s *Store) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { + rows, err := s.qr.ListSessionsByProject(ctx, project) + if err != nil { + return nil, fmt.Errorf("list sessions for %s: %w", project, err) + } + return mapSessionRows(rows), nil +} + +// ListAllSessions returns every session across all projects. +func (s *Store) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) { + rows, err := s.qr.ListAllSessions(ctx) + if err != nil { + return nil, fmt.Errorf("list all sessions: %w", err) + } + return mapSessionRows(rows), nil +} + +func mapSessionRows(rows []gen.Session) []domain.SessionRecord { + out := make([]domain.SessionRecord, 0, len(rows)) + for _, r := range rows { + out = append(out, rowToRecord(r)) + } + return out +} + +func rowToRecord(row gen.Session) domain.SessionRecord { + return domain.SessionRecord{ + ID: row.ID, + ProjectID: row.ProjectID, + IssueID: row.IssueID, + Kind: row.Kind, + Harness: row.Harness, + Activity: domain.ActivitySubstate{ + State: row.ActivityState, + LastActivityAt: row.ActivityLastAt, + Source: row.ActivitySource, + }, + IsTerminated: row.IsTerminated, + Metadata: domain.SessionMetadata{ + Branch: row.Branch, + WorkspacePath: row.WorkspacePath, + RuntimeHandleID: row.RuntimeHandleID, + AgentSessionID: row.AgentSessionID, + Prompt: row.Prompt, + }, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func recordToInsert(rec domain.SessionRecord, num int64) gen.InsertSessionParams { + activity := normalActivity(rec.Activity, rec.CreatedAt) + return gen.InsertSessionParams{ + ID: rec.ID, + ProjectID: rec.ProjectID, + Num: num, + IssueID: rec.IssueID, + Kind: rec.Kind, + Harness: rec.Harness, + ActivityState: activity.State, + ActivityLastAt: activity.LastActivityAt, + ActivitySource: activity.Source, + IsTerminated: rec.IsTerminated, + Branch: rec.Metadata.Branch, + WorkspacePath: rec.Metadata.WorkspacePath, + RuntimeHandleID: rec.Metadata.RuntimeHandleID, + AgentSessionID: rec.Metadata.AgentSessionID, + Prompt: rec.Metadata.Prompt, + CreatedAt: rec.CreatedAt, + UpdatedAt: rec.UpdatedAt, + } +} + +func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams { + activity := normalActivity(rec.Activity, rec.UpdatedAt) + return gen.UpdateSessionParams{ + ID: rec.ID, + IssueID: rec.IssueID, + Kind: rec.Kind, + Harness: rec.Harness, + ActivityState: activity.State, + ActivityLastAt: activity.LastActivityAt, + ActivitySource: activity.Source, + IsTerminated: rec.IsTerminated, + Branch: rec.Metadata.Branch, + WorkspacePath: rec.Metadata.WorkspacePath, + RuntimeHandleID: rec.Metadata.RuntimeHandleID, + AgentSessionID: rec.Metadata.AgentSessionID, + Prompt: rec.Metadata.Prompt, + UpdatedAt: rec.UpdatedAt, + } +} + +func normalActivity(a domain.ActivitySubstate, fallback time.Time) domain.ActivitySubstate { + if a.State == "" { + a.State = domain.ActivityIdle + } + if a.Source == "" { + a.Source = domain.SourceNone + } + if a.LastActivityAt.IsZero() { + a.LastActivityAt = fallback + } + if a.LastActivityAt.IsZero() { + a.LastActivityAt = time.Now().UTC() + } + return a +} diff --git a/backend/internal/storage/sqlite/store/store.go b/backend/internal/storage/sqlite/store/store.go new file mode 100644 index 0000000000..829e385ec9 --- /dev/null +++ b/backend/internal/storage/sqlite/store/store.go @@ -0,0 +1,60 @@ +// Package store contains SQLite-backed table stores built on sqlc-generated +// queries. +package store + +import ( + "context" + "database/sql" + "fmt" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// Store is the SQLite-backed persistence layer. It routes writes to a single +// writer connection (qw) and reads to a reader pool (qr) — see Open. writeMu +// guards the read-modify-write write methods (e.g. CreateSession's +// next-num-then-insert) so concurrent writes can't interleave them. +// +// CDC is captured by DB triggers (migration 0001), NOT by this layer: the store +// never writes change_log, it only reads it for the CDC poller. +type Store struct { + writeDB *sql.DB + readDB *sql.DB + qw *gen.Queries // bound to the single writer connection + qr *gen.Queries // bound to the reader pool + writeMu sync.Mutex +} + +// NewStore wraps an opened writer + reader *sql.DB (see Open) as a Store. +func NewStore(writeDB, readDB *sql.DB) *Store { + return &Store{ + writeDB: writeDB, + readDB: readDB, + qw: gen.New(writeDB), + qr: gen.New(readDB), + } +} + +// Close closes both pools. +func (s *Store) Close() error { + err := s.writeDB.Close() + if e := s.readDB.Close(); e != nil && err == nil { + err = e + } + return err +} + +// inTx runs fn inside a single write transaction on the writer connection, +// rolling back on error. The caller must already hold writeMu. +func (s *Store) inTx(ctx context.Context, what string, fn func(*gen.Queries) error) error { + tx, err := s.writeDB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin %s: %w", what, err) + } + defer func() { _ = tx.Rollback() }() + if err := fn(s.qw.WithTx(tx)); err != nil { + return fmt.Errorf("%s: %w", what, err) + } + return tx.Commit() +} diff --git a/backend/internal/storage/sqlite/store_test.go b/backend/internal/storage/sqlite/store/store_test.go similarity index 54% rename from backend/internal/storage/sqlite/store_test.go rename to backend/internal/storage/sqlite/store/store_test.go index 426a37d22c..669d15a91c 100644 --- a/backend/internal/storage/sqlite/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -1,18 +1,20 @@ -package sqlite +package store_test import ( "context" - "fmt" + "encoding/json" "sync" "testing" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/project" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) -func newTestStore(t *testing.T) *Store { +func newTestStore(t *testing.T) *sqlite.Store { t.Helper() - s, err := Open(t.TempDir()) + s, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatalf("open: %v", err) } @@ -20,9 +22,9 @@ func newTestStore(t *testing.T) *Store { return s } -func seedProject(t *testing.T, s *Store, id string) { +func seedProject(t *testing.T, s *sqlite.Store, id string) { t.Helper() - if err := s.UpsertProject(context.Background(), ProjectRow{ + if err := s.Upsert(context.Background(), project.Row{ ID: id, Path: "/tmp/" + id, RegisteredAt: time.Now().UTC().Truncate(time.Second), }); err != nil { t.Fatalf("seed project %s: %v", id, err) @@ -34,15 +36,8 @@ func sampleRecord(project string) domain.SessionRecord { return domain.SessionRecord{ ProjectID: domain.ProjectID(project), Kind: domain.KindWorker, - Lifecycle: domain.CanonicalSessionLifecycle{ - Version: domain.LifecycleVersion, - Harness: domain.HarnessClaudeCode, - IsAlive: true, - Session: domain.SessionSubstate{State: domain.SessionWorking}, - Activity: domain.ActivitySubstate{ - State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative, - }, - }, + Harness: domain.HarnessClaudeCode, + Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, Metadata: domain.SessionMetadata{Branch: "feat/x", WorkspacePath: "/ws"}, CreatedAt: now, UpdatedAt: now, @@ -54,24 +49,24 @@ func TestProjectCRUDAndArchive(t *testing.T) { ctx := context.Background() seedProject(t, s, "mer") - got, ok, err := s.GetProject(ctx, "mer") + got, ok, err := s.Get(ctx, "mer") if err != nil || !ok { t.Fatalf("get: ok=%v err=%v", ok, err) } if got.ID != "mer" || got.Path != "/tmp/mer" { t.Fatalf("project = %+v", got) } - if list, _ := s.ListProjects(ctx); len(list) != 1 { + if list, _ := s.List(ctx); len(list) != 1 { t.Fatalf("active list = %d, want 1", len(list)) } // archive hides from the active list but still resolves by id. - if err := s.ArchiveProject(ctx, "mer", time.Now().UTC()); err != nil { - t.Fatal(err) + if ok, err := s.Archive(ctx, "mer", time.Now().UTC()); err != nil || !ok { + t.Fatalf("archive: ok=%v err=%v", ok, err) } - if list, _ := s.ListProjects(ctx); len(list) != 0 { + if list, _ := s.List(ctx); len(list) != 0 { t.Fatalf("after archive, active list = %d, want 0", len(list)) } - if _, ok, _ := s.GetProject(ctx, "mer"); !ok { + if _, ok, _ := s.Get(ctx, "mer"); !ok { t.Fatal("archived project must still resolve by id") } } @@ -95,8 +90,8 @@ func TestSessionCreateAssignsPerProjectID(t *testing.T) { if err != nil || !ok { t.Fatalf("get: ok=%v err=%v", ok, err) } - if got.Lifecycle.Session.State != domain.SessionWorking || !got.Lifecycle.IsAlive || - got.Lifecycle.Harness != domain.HarnessClaudeCode || got.Metadata.Branch != "feat/x" { + if got.Activity.State != domain.ActivityActive || got.IsTerminated || + got.Harness != domain.HarnessClaudeCode || got.Metadata.Branch != "feat/x" { t.Fatalf("round-trip mismatch: %+v", got) } if list, _ := s.ListSessions(ctx, "mer"); len(list) != 2 { @@ -107,32 +102,28 @@ func TestSessionCreateAssignsPerProjectID(t *testing.T) { } } -func TestSessionUpdateAndDetecting(t *testing.T) { +func TestSessionUpdateActivityAndTermination(t *testing.T) { s := newTestStore(t) ctx := context.Background() seedProject(t, s, "mer") r, _ := s.CreateSession(ctx, sampleRecord("mer")) - r.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionDetecting} - r.Lifecycle.IsAlive = false - r.Lifecycle.Detecting = &domain.DetectingState{Attempts: 2, StartedAt: r.CreatedAt, EvidenceHash: "abc"} + r.Activity = domain.ActivitySubstate{State: domain.ActivityWaitingInput, LastActivityAt: r.CreatedAt, Source: domain.SourceHook} + r.IsTerminated = true if err := s.UpdateSession(ctx, r); err != nil { t.Fatal(err) } got, _, _ := s.GetSession(ctx, r.ID) - if got.Lifecycle.Session.State != domain.SessionDetecting || got.Lifecycle.IsAlive { - t.Fatalf("update not persisted: %+v", got.Lifecycle.Session) - } - if got.Lifecycle.Detecting == nil || got.Lifecycle.Detecting.Attempts != 2 || got.Lifecycle.Detecting.EvidenceHash != "abc" { - t.Fatalf("detecting not round-tripped: %+v", got.Lifecycle.Detecting) + if got.Activity.State != domain.ActivityWaitingInput || !got.IsTerminated { + t.Fatalf("update not persisted: %+v", got) } - // clearing detecting persists as nil. - got.Lifecycle.Detecting = nil - got.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionWorking} + + got.IsTerminated = false + got.Activity.State = domain.ActivityActive _ = s.UpdateSession(ctx, got) again, _, _ := s.GetSession(ctx, r.ID) - if again.Lifecycle.Detecting != nil { - t.Fatalf("detecting should clear to nil, got %+v", again.Lifecycle.Detecting) + if again.IsTerminated || again.Activity.State != domain.ActivityActive { + t.Fatalf("activity/termination should update, got %+v", again) } } @@ -143,57 +134,66 @@ func TestPRCRUD(t *testing.T) { r, _ := s.CreateSession(ctx, sampleRecord("mer")) now := time.Now().UTC().Truncate(time.Second) - pr := domain.PRRow{ - URL: "https://gh/pr/1", SessionID: string(r.ID), Number: 1, + pr := domain.PullRequest{ + URL: "https://gh/pr/1", SessionID: r.ID, Number: 1, Review: domain.ReviewRequired, CI: domain.CIFailing, Mergeability: domain.MergeBlocked, UpdatedAt: now, } - if err := s.UpsertPR(ctx, pr); err != nil { + if err := s.WritePR(ctx, pr, nil, nil); err != nil { t.Fatal(err) } got, ok, err := s.GetPR(ctx, pr.URL) if err != nil || !ok || got != pr { t.Fatalf("get pr: ok=%v err=%v got=%+v", ok, err, got) } - if list, _ := s.ListPRsBySession(ctx, string(r.ID)); len(list) != 1 { + if list, _ := s.ListPRsBySession(ctx, r.ID); len(list) != 1 { t.Fatalf("list prs = %d, want 1", len(list)) } - if err := s.DeletePR(ctx, pr.URL); err != nil { +} + +func TestWritePRRejectsSessionReassignment(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + first, _ := s.CreateSession(ctx, sampleRecord("mer")) + second, _ := s.CreateSession(ctx, sampleRecord("mer")) + now := time.Now().UTC().Truncate(time.Second) + + pr := domain.PullRequest{URL: "https://gh/pr/1", SessionID: first.ID, Number: 1, UpdatedAt: now} + if err := s.WritePR(ctx, pr, nil, nil); err != nil { t.Fatal(err) } - if _, ok, _ := s.GetPR(ctx, pr.URL); ok { - t.Fatal("pr should be gone") + pr.SessionID = second.ID + if err := s.WritePR(ctx, pr, nil, nil); err == nil { + t.Fatal("expected reassignment to fail") + } + got, ok, err := s.GetPR(ctx, pr.URL) + if err != nil || !ok { + t.Fatalf("get pr: ok=%v err=%v", ok, err) + } + if got.SessionID != first.ID { + t.Fatalf("pr moved to %s, want %s", got.SessionID, first.ID) } } -func TestPRChecksLoopBrakeQuery(t *testing.T) { +func TestDisplayPRFactsPrefersActivePR(t *testing.T) { s := newTestStore(t) ctx := context.Background() seedProject(t, s, "mer") r, _ := s.CreateSession(ctx, sampleRecord("mer")) now := time.Now().UTC().Truncate(time.Second) - _ = s.UpsertPR(ctx, domain.PRRow{URL: "pr1", SessionID: string(r.ID), UpdatedAt: now}) - // three consecutive failing runs of "build" (one per commit). - for i := 1; i <= 3; i++ { - if err := s.RecordCheck(ctx, domain.PRCheckRow{ - PRURL: "pr1", Name: "build", CommitHash: fmt.Sprintf("c%d", i), - Status: "failed", CreatedAt: now.Add(time.Duration(i) * time.Second), - }); err != nil { - t.Fatal(err) - } + if err := s.WritePR(ctx, domain.PullRequest{URL: "closed", SessionID: r.ID, Number: 1, Closed: true, UpdatedAt: now.Add(time.Minute)}, nil, nil); err != nil { + t.Fatal(err) } - last3, err := s.RecentCheckStatuses(ctx, "pr1", "build", 3) - if err != nil { + if err := s.WritePR(ctx, domain.PullRequest{URL: "open", SessionID: r.ID, Number: 2, CI: domain.CIFailing, UpdatedAt: now}, nil, nil); err != nil { t.Fatal(err) } - if len(last3) != 3 || last3[0] != "failed" || last3[1] != "failed" || last3[2] != "failed" { - t.Fatalf("recent statuses = %v, want 3x failed (loop brake would trip)", last3) + got, ok, err := s.GetDisplayPRFactsForSession(ctx, r.ID) + if err != nil || !ok { + t.Fatalf("display pr: ok=%v err=%v", ok, err) } - // a pass on a newer commit breaks the streak. - _ = s.RecordCheck(ctx, domain.PRCheckRow{PRURL: "pr1", Name: "build", CommitHash: "c4", Status: "passed", CreatedAt: now.Add(4 * time.Second)}) - last3, _ = s.RecentCheckStatuses(ctx, "pr1", "build", 3) - if last3[0] != "passed" { - t.Fatalf("most recent should be passed, got %v", last3) + if got.URL != "open" || got.CI != domain.CIFailing { + t.Fatalf("display pr = %+v", got) } } @@ -203,9 +203,7 @@ func TestPRCommentsReplace(t *testing.T) { seedProject(t, s, "mer") r, _ := s.CreateSession(ctx, sampleRecord("mer")) now := time.Now().UTC().Truncate(time.Second) - _ = s.UpsertPR(ctx, domain.PRRow{URL: "pr1", SessionID: string(r.ID), UpdatedAt: now}) - - _ = s.ReplacePRComments(ctx, "pr1", []domain.PRComment{ + _ = s.WritePR(ctx, domain.PullRequest{URL: "pr1", SessionID: r.ID, UpdatedAt: now}, nil, []domain.PullRequestComment{ {ID: "c1", Author: "a", File: "a.go", Line: 1, Body: "nit", CreatedAt: now}, {ID: "c2", Author: "b", File: "b.go", Line: 2, Body: "bug", Resolved: true, CreatedAt: now.Add(time.Second)}, }) @@ -213,7 +211,7 @@ func TestPRCommentsReplace(t *testing.T) { t.Fatalf("comments = %d, want 2", len(list)) } // replace with a smaller set drops the rest. - _ = s.ReplacePRComments(ctx, "pr1", []domain.PRComment{{ID: "c1", Body: "x", CreatedAt: now}}) + _ = s.WritePR(ctx, domain.PullRequest{URL: "pr1", SessionID: r.ID, UpdatedAt: now}, nil, []domain.PullRequestComment{{ID: "c1", Body: "x", CreatedAt: now}}) if list, _ := s.ListPRComments(ctx, "pr1"); len(list) != 1 { t.Fatalf("after replace, comments = %d, want 1", len(list)) } @@ -226,14 +224,14 @@ func TestCDCTriggersPopulateChangeLog(t *testing.T) { r, _ := s.CreateSession(ctx, sampleRecord("mer")) // a real state change logs; a metadata-only change does not (WHEN guard). - r.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionIdle} + r.Activity.State = domain.ActivityIdle _ = s.UpdateSession(ctx, r) r.Metadata.Prompt = "only metadata changed" _ = s.UpdateSession(ctx, r) // a PR insert logs too. - _ = s.UpsertPR(ctx, domain.PRRow{URL: "pr1", SessionID: string(r.ID), UpdatedAt: r.UpdatedAt}) + _ = s.WritePR(ctx, domain.PullRequest{URL: "pr1", SessionID: r.ID, UpdatedAt: r.UpdatedAt}, nil, nil) - evs, err := s.ReadChangeLogAfter(ctx, 0, 100) + evs, err := s.EventsAfter(ctx, 0, 100) if err != nil { t.Fatal(err) } @@ -242,13 +240,20 @@ func TestCDCTriggersPopulateChangeLog(t *testing.T) { if e.ProjectID != "mer" { t.Fatalf("event project = %s, want mer", e.ProjectID) } - types = append(types, e.EventType) + types = append(types, string(e.Type)) } want := []string{"session_created", "session_updated", "pr_created"} if len(types) != 3 || types[0] != want[0] || types[1] != want[1] || types[2] != want[2] { t.Fatalf("change_log event types = %v, want %v (metadata-only update suppressed)", types, want) } - maxSeq, _ := s.MaxChangeLogSeq(ctx) + var payload map[string]any + if err := json.Unmarshal([]byte(evs[0].Payload), &payload); err != nil { + t.Fatalf("session payload JSON: %v", err) + } + if _, ok := payload["isTerminated"].(bool); !ok { + t.Fatalf("isTerminated payload type = %T, want bool", payload["isTerminated"]) + } + maxSeq, _ := s.LatestSeq(ctx) if maxSeq != int64(len(evs)) { t.Fatalf("max seq = %d, want %d", maxSeq, len(evs)) } @@ -287,30 +292,3 @@ func TestConcurrentSessionCreateAssignsUniqueNums(t *testing.T) { t.Fatalf("created %d sessions, want %d", len(all), n) } } - -func TestTerminationReasonRoundTripAndCheck(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - seedProject(t, s, "mer") - r, _ := s.CreateSession(ctx, sampleRecord("mer")) - - // terminate with a valid reason -> round-trips. - r.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionTerminated} - r.Lifecycle.TerminationReason = domain.TermManuallyKilled - if err := s.UpdateSession(ctx, r); err != nil { - t.Fatal(err) - } - got, _, _ := s.GetSession(ctx, r.ID) - if got.Lifecycle.TerminationReason != domain.TermManuallyKilled { - t.Fatalf("termination_reason = %q, want manually_killed", got.Lifecycle.TerminationReason) - } - if domain.DeriveStatus(got.Lifecycle, domain.PRFacts{}) != domain.StatusKilled { - t.Fatal("terminated+manually_killed should derive to killed") - } - - // an off-enum reason is rejected by the CHECK constraint. - r.Lifecycle.TerminationReason = domain.TerminationReason("definitely_not_a_reason") - if err := s.UpdateSession(ctx, r); err == nil { - t.Fatal("expected CHECK constraint to reject an invalid termination_reason") - } -} diff --git a/backend/internal/terminal/doc.go b/backend/internal/terminal/doc.go index e9d3ebba26..44878ec340 100644 --- a/backend/internal/terminal/doc.go +++ b/backend/internal/terminal/doc.go @@ -1,9 +1,9 @@ // Package terminal is the live-terminal streaming feature: it attaches to a -// session's tmux pane over a PTY and multiplexes the byte stream to one or more +// session's Zellij pane over a PTY and multiplexes the byte stream to one or more // WebSocket clients, alongside a session-state channel fed by the CDC // broadcaster. // -// Boundaries (see docs/backend-code-structure.md): +// Boundaries (see docs/architecture.md): // // - This package owns the product workflow: PTY attach, output fan-out, a // bounded replay buffer, re-attach resilience, and the ch-tagged wire @@ -11,10 +11,10 @@ // not to any concrete WebSocket library. // - internal/httpd owns the HTTP/WebSocket upgrade and adapts the accepted // socket to wsConn; it does not contain stream logic. -// - The PTY itself is reached through PTYSource (satisfied by the tmux runtime +// - The PTY itself is reached through PTYSource (satisfied by the Zellij runtime // adapter's AttachCommand/IsAlive) and spawned through an injectable // spawnFunc, so the fan-out, buffering, and re-attach logic test without a -// real process, tmux, or network. +// real process, Zellij, or network. // // Raw PTY bytes never flow through the CDC change_log; only the session channel // is fed by cdc.Broadcaster. Terminal output is high-volume ephemeral data and diff --git a/backend/internal/terminal/fakes_test.go b/backend/internal/terminal/fakes_test.go index 939f6eccf1..247c33bd96 100644 --- a/backend/internal/terminal/fakes_test.go +++ b/backend/internal/terminal/fakes_test.go @@ -24,7 +24,7 @@ func (f *fakeSource) AttachCommand(ports.RuntimeHandle) ([]string, error) { return nil, f.attachErr } if f.argv == nil { - return []string{"tmux", "attach"}, nil + return []string{"zellij", "attach"}, nil } return f.argv, nil } @@ -42,7 +42,7 @@ func (f *fakeSource) setAlive(v bool) { } // fakePTY is a scripted ptyProcess: Read drains the out channel, Write records, -// Resize records, Close/Wait unblock on close. +// Resize records, and Close unblocks reads. type fakePTY struct { out chan []byte closed chan struct{} @@ -82,11 +82,6 @@ func (p *fakePTY) Resize(rows, cols uint16) error { return nil } -func (p *fakePTY) Wait() error { - <-p.closed - return nil -} - func (p *fakePTY) Close() error { p.once.Do(func() { close(p.closed) }) return nil diff --git a/backend/internal/terminal/logger_test.go b/backend/internal/terminal/logger_test.go new file mode 100644 index 0000000000..a13233415a --- /dev/null +++ b/backend/internal/terminal/logger_test.go @@ -0,0 +1,19 @@ +package terminal + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestNilLoggerFallsBackToDefault(t *testing.T) { + mgr := NewManager(&fakeSource{}, nil, nil, WithSpawn((&fakeSpawner{}).spawn)) + defer mgr.Close() + if mgr.log == nil { + t.Fatal("manager logger is nil") + } + s := newSession("t1", ports.RuntimeHandle{ID: "t1"}, &fakeSource{}, (&fakeSpawner{}).spawn, nil) + if s.log == nil { + t.Fatal("session logger is nil") + } +} diff --git a/backend/internal/terminal/manager.go b/backend/internal/terminal/manager.go index 895edb6f82..79ba6134cb 100644 --- a/backend/internal/terminal/manager.go +++ b/backend/internal/terminal/manager.go @@ -63,8 +63,11 @@ func WithSpawn(fn spawnFunc) Option { return func(m *Manager) { m.spawn = fn } } func WithHeartbeat(d time.Duration) Option { return func(m *Manager) { m.heartbeat = d } } // NewManager builds a Manager. src attaches PTYs; events feeds the session -// channel (may be nil to disable it); log is required. +// channel (may be nil to disable it). A nil logger falls back to slog.Default. func NewManager(src PTYSource, events EventSource, log *slog.Logger, opts ...Option) *Manager { + if log == nil { + log = slog.Default() + } ctx, cancel := context.WithCancel(context.Background()) m := &Manager{ src: src, @@ -105,7 +108,7 @@ func (m *Manager) Close() { } // openSession returns the live session for id, starting it on first open. The id -// is the runtime handle id (tmux target). +// is the runtime handle id (Zellij handle). func (m *Manager) openSession(id string) (*session, error) { m.mu.Lock() defer m.mu.Unlock() @@ -156,7 +159,7 @@ func (m *Manager) Serve(ctx context.Context, conn wsConn) { if ctx.Err() != nil { return } - c.handle(ctx, msg) + c.handle(msg) } } @@ -173,12 +176,12 @@ type connState struct { closed bool } -func (c *connState) handle(ctx context.Context, msg clientMsg) { +func (c *connState) handle(msg clientMsg) { switch msg.Ch { case chTerminal: - c.handleTerminal(ctx, msg) + c.handleTerminal(msg) case chSubscribe: - c.handleSubscribe() + c.handleSubscribe(msg) case chSystem: if msg.Type == msgPing { c.enqueue(serverMsg{Ch: chSystem, Type: msgPong}) @@ -186,10 +189,10 @@ func (c *connState) handle(ctx context.Context, msg clientMsg) { } } -func (c *connState) handleTerminal(ctx context.Context, msg clientMsg) { +func (c *connState) handleTerminal(msg clientMsg) { switch msg.Type { case msgOpen: - c.openTerminal(ctx, msg.ID) + c.openTerminal(msg.ID) case msgData: raw, err := base64.StdEncoding.DecodeString(msg.Data) if err != nil { @@ -207,7 +210,7 @@ func (c *connState) handleTerminal(ctx context.Context, msg clientMsg) { } } -func (c *connState) openTerminal(_ context.Context, id string) { +func (c *connState) openTerminal(id string) { if id == "" { c.enqueue(serverMsg{Ch: chTerminal, Type: msgError, Error: "missing terminal id"}) return @@ -300,8 +303,8 @@ func (c *connState) lookup(id string) *session { return s } -func (c *connState) handleSubscribe() { - if c.mgr.events == nil { +func (c *connState) handleSubscribe(msg clientMsg) { + if msg.Type != msgSubscribe || c.mgr.events == nil { return } c.mu.Lock() diff --git a/backend/internal/terminal/protocol.go b/backend/internal/terminal/protocol.go index 31a47999a9..163ca3bac9 100644 --- a/backend/internal/terminal/protocol.go +++ b/backend/internal/terminal/protocol.go @@ -4,9 +4,9 @@ package terminal // ("ch"), mirroring the legacy Node mux server so the existing xterm client can // connect unchanged. One socket carries every logical stream: // -// ch "terminal" — per-pane byte stream, keyed by an opaque client-chosen id +// ch "terminal" — per-pane byte stream, keyed by an opaque runtime handle id // ch "subscribe" — the client opts into the session-state channel -// ch "sessions" — server-pushed session-state notifications (CDC-fed) +// ch "sessions" — server-pushed session-state messages (CDC-fed) // ch "system" — liveness; ws-level ping/pong also runs underneath // // Terminal payloads are base64 in the Data field: PTY output is arbitrary bytes diff --git a/backend/internal/terminal/pty_unix.go b/backend/internal/terminal/pty_unix.go index e5ca6f34ba..a250a0379d 100644 --- a/backend/internal/terminal/pty_unix.go +++ b/backend/internal/terminal/pty_unix.go @@ -41,9 +41,7 @@ func (p *creackPTY) Resize(rows, cols uint16) error { return pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols}) } -func (p *creackPTY) Wait() error { return p.cmd.Wait() } - -// Close stops the attach process and releases the PTY. tmux attach exits cleanly +// Close stops the attach process and releases the PTY. Zellij attach exits cleanly // when the master closes, but kill the process to be sure it does not linger. // // It is idempotent: both the session run loop (after copyOut returns) and diff --git a/backend/internal/terminal/pty_windows.go b/backend/internal/terminal/pty_windows.go index c93465aa01..f88ef557ba 100644 --- a/backend/internal/terminal/pty_windows.go +++ b/backend/internal/terminal/pty_windows.go @@ -7,9 +7,8 @@ import ( "errors" ) -// defaultSpawn is not yet implemented on Windows: the POSIX PTY path uses -// creack/pty. A ConPTY-backed attach (mirroring the legacy named-pipe relay) is -// a follow-up. The rest of the package compiles and tests on Windows with an +// defaultSpawn is not implemented on Windows: the POSIX PTY path uses +// creack/pty. The rest of the package compiles and tests on Windows with an // injected spawner. func defaultSpawn(_ context.Context, _ []string) (ptyProcess, error) { return nil, errors.New("terminal: PTY streaming is not supported on Windows yet") diff --git a/backend/internal/terminal/ring.go b/backend/internal/terminal/ring.go index ed55ca6591..8ed303cdf9 100644 --- a/backend/internal/terminal/ring.go +++ b/backend/internal/terminal/ring.go @@ -1,17 +1,13 @@ package terminal -import "sync" - // defaultRingMax caps per-terminal replay history. A late subscriber gets at // most this many bytes of recent output so it can paint a usable screen without // the whole session backlog. Matches the legacy 50KB ring. const defaultRingMax = 50 * 1024 // ringBuffer is a byte ring holding the most recent output of one terminal. It -// keeps a contiguous tail capped at max bytes; snapshot returns a copy for -// replay-on-subscribe. +// is owned by session and accessed under session.mu. type ringBuffer struct { - mu sync.Mutex buf []byte max int } @@ -26,8 +22,6 @@ func newRingBuffer(maxBytes int) *ringBuffer { // append adds p and drops the oldest bytes beyond max. A single write larger // than max is truncated to its last max bytes. func (r *ringBuffer) append(p []byte) { - r.mu.Lock() - defer r.mu.Unlock() if len(p) >= r.max { r.buf = append(r.buf[:0], p[len(p)-r.max:]...) return @@ -40,8 +34,6 @@ func (r *ringBuffer) append(p []byte) { // snapshot returns a copy of the current contents (oldest first). func (r *ringBuffer) snapshot() []byte { - r.mu.Lock() - defer r.mu.Unlock() out := make([]byte, len(r.buf)) copy(out, r.buf) return out diff --git a/backend/internal/terminal/session.go b/backend/internal/terminal/session.go index 77fb514747..02d99cbdf6 100644 --- a/backend/internal/terminal/session.go +++ b/backend/internal/terminal/session.go @@ -13,7 +13,7 @@ import ( // PTYSource is what a terminal needs from the runtime: the argv that attaches a // PTY to a session's pane, and a liveness check used to decide whether a dropped -// PTY should be re-attached or treated as a clean exit. The tmux runtime adapter +// PTY should be re-attached or treated as a clean exit. The Zellij runtime adapter // satisfies this via AttachCommand/IsAlive; the interface lives here, next to its // only consumer, so terminal does not depend on a concrete adapter. type PTYSource interface { @@ -28,14 +28,12 @@ type PTYSource interface { type ptyProcess interface { io.ReadWriteCloser Resize(rows, cols uint16) error - // Wait blocks until the attach process exits. - Wait() error } // spawnFunc starts a PTY for argv. ctx cancellation must terminate the process. type spawnFunc func(ctx context.Context, argv []string) (ptyProcess, error) -// reattach policy: a PTY that drops is re-attached while the underlying tmux +// reattach policy: a PTY that drops is re-attached while the underlying Zellij // session is still alive, up to maxReattach consecutive failures. An attach that // survived longer than reattachResetGrace before dropping resets the counter, so // a long-lived pane that blips recovers but a tight crash-loop gives up. @@ -44,9 +42,9 @@ const ( defaultReattachResetTime = 5 * time.Second ) -// subscriber receives one terminal's output frames. It must not block; the -// session calls it while holding no lock, but a slow consumer stalls fan-out, so -// the WS layer funnels these onto its own buffered writer. +// subscriber receives one terminal's output frames. It must not block: session +// fan-out calls subscribers while serializing replay/delivery under its mutex, +// so the WS layer funnels frames onto its own buffered writer. type subscriber func(data []byte) // session is one attached terminal pane, fanned out to N subscribers. It owns a @@ -75,6 +73,9 @@ type session struct { } func newSession(id string, handle ports.RuntimeHandle, src PTYSource, spawn spawnFunc, log *slog.Logger) *session { + if log == nil { + log = slog.Default() + } return &session{ id: id, handle: handle, @@ -152,7 +153,7 @@ func (s *session) copyOut(p ptyProcess) { } // shouldReattach decides whether a dropped/failed PTY warrants another attempt: -// only while not closed/cancelled, the tmux session still exists, and we are +// only while not closed/cancelled, the Zellij session still exists, and we are // under the consecutive-failure cap. A backoff sleep separates attempts. func (s *session) shouldReattach(ctx context.Context, failures int) bool { if s.isClosed() || ctx.Err() != nil || failures > s.maxReattach { diff --git a/backend/internal/terminal/session_integration_test.go b/backend/internal/terminal/session_integration_test.go index 9041d96389..1c9fceafdb 100644 --- a/backend/internal/terminal/session_integration_test.go +++ b/backend/internal/terminal/session_integration_test.go @@ -1,31 +1,45 @@ +//go:build !windows + package terminal import ( "context" + "os" "os/exec" + "path/filepath" "strings" "testing" "time" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// TestSessionStreamsRealTmuxPane attaches a real PTY to a real tmux session and +// TestSessionStreamsRealZellijPane attaches a real PTY to a real Zellij session and // asserts output streams back, then that killing the pane stops the session -// without a re-attach storm. Skipped when tmux is unavailable. -func TestSessionStreamsRealTmuxPane(t *testing.T) { - tmuxBin, err := exec.LookPath("tmux") +// without a re-attach storm. Skipped when Zellij is unavailable. +func TestSessionStreamsRealZellijPane(t *testing.T) { + zellijBin, err := exec.LookPath("zellij") if err != nil { - t.Skip("tmux unavailable") + t.Skip("zellij unavailable") } name := "ao-term-it-" + strings.ReplaceAll(t.Name(), "/", "-") - mustRun(t, tmuxBin, "new-session", "-d", "-s", name, "/bin/sh") - t.Cleanup(func() { _ = exec.Command(tmuxBin, "kill-session", "-t", "="+name).Run() }) - - rt := tmux.New(tmux.Options{Binary: tmuxBin}) - handle := ports.RuntimeHandle{ID: name} + socketDir := filepath.Join(os.TempDir(), name+"-socket") + if err := os.MkdirAll(socketDir, 0o755); err != nil { + t.Fatalf("mkdir socket dir: %v", err) + } + rt := zellij.New(zellij.Options{Binary: zellijBin, SocketDir: socketDir, ConfigDir: t.TempDir(), Timeout: 5 * time.Second}) + handle, err := rt.Create(context.Background(), ports.RuntimeConfig{ + SessionID: domain.SessionID(name), + WorkspacePath: t.TempDir(), + LaunchCommand: "printf AO_READY\n", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) }) s := newSession(name, handle, rt, defaultSpawn, testLogger()) ctx, cancel := context.WithCancel(context.Background()) @@ -39,14 +53,9 @@ func TestSessionStreamsRealTmuxPane(t *testing.T) { eventually(t, 3*time.Second, func() bool { return s.write([]byte("echo AO_MARKER_42\n")) == nil }) eventually(t, 5*time.Second, func() bool { return strings.Contains(got.string(), "AO_MARKER_42") }) - // Kill the pane: the session must observe it as gone and not re-attach. - mustRun(t, tmuxBin, "kill-session", "-t", "="+name) - eventually(t, 5*time.Second, func() bool { return s.isExited() }) -} - -func mustRun(t *testing.T, name string, args ...string) { - t.Helper() - if out, err := exec.Command(name, args...).CombinedOutput(); err != nil { - t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out) + // Kill the session: the terminal session must observe it as gone and not re-attach. + if err := rt.Destroy(context.Background(), handle); err != nil { + t.Fatalf("Destroy: %v", err) } + eventually(t, 5*time.Second, func() bool { return s.isExited() }) } diff --git a/backend/internal/terminal/session_test.go b/backend/internal/terminal/session_test.go index f7b9ddeadc..5483117ac6 100644 --- a/backend/internal/terminal/session_test.go +++ b/backend/internal/terminal/session_test.go @@ -45,13 +45,19 @@ func TestSessionReplaysRingBufferOnSubscribe(t *testing.T) { go s.run(ctx) pty.push([]byte("scrollback")) - eventually(t, time.Second, func() bool { return len(s.ring.snapshot()) == len("scrollback") }) + eventually(t, time.Second, func() bool { return ringLen(s) == len("scrollback") }) var late safeBytes s.subscribe(late.add, nil) eventually(t, time.Second, func() bool { return late.string() == "scrollback" }) } +func ringLen(s *session) int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.ring.snapshot()) +} + func TestSessionWriteAndResizeReachPTY(t *testing.T) { src := &fakeSource{} pty := newFakePTY() @@ -75,7 +81,7 @@ func TestSessionWriteAndResizeReachPTY(t *testing.T) { } func TestSessionSkipsReattachOnCleanExit(t *testing.T) { - src := &fakeSource{alive: false} // tmux session gone -> no re-attach + src := &fakeSource{alive: false} // Zellij session gone -> no re-attach pty := newFakePTY() sp := &fakeSpawner{ptys: []*fakePTY{pty}} s := newTestSession(src, sp.spawn) @@ -91,7 +97,7 @@ func TestSessionSkipsReattachOnCleanExit(t *testing.T) { select { case <-exited: case <-time.After(time.Second): - t.Fatal("expected exit notification after clean pane exit") + t.Fatal("expected exit callback after clean pane exit") } if got := sp.calls(); got != 1 { t.Fatalf("expected exactly one attach, got %d", got) diff --git a/backend/sqlc.yaml b/backend/sqlc.yaml index 9659bf779a..3614c4253b 100644 --- a/backend/sqlc.yaml +++ b/backend/sqlc.yaml @@ -9,5 +9,82 @@ sql: out: "internal/storage/sqlite/gen" emit_json_tags: false emit_prepared_queries: false - emit_interface: true + emit_interface: false emit_empty_slices: true + initialisms: + - id + - url + - pr + - ci + overrides: + - column: "change_log.project_id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "ProjectID" + - column: "change_log.session_id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "SessionID" + pointer: true + - column: "change_log.event_type" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + type: "EventType" + - column: "pr.session_id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "SessionID" + - column: "pr.pr_state" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "PRState" + - column: "pr.review_decision" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "ReviewDecision" + - column: "pr.ci_state" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "CIState" + - column: "pr.mergeability" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "Mergeability" + - column: "pr_checks.status" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "PRCheckStatus" + - column: "pr_comment.resolved" + go_type: "bool" + - column: "projects.id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "ProjectID" + - column: "sessions.id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "SessionID" + - column: "sessions.project_id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "ProjectID" + - column: "sessions.issue_id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "IssueID" + - column: "sessions.kind" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "SessionKind" + - column: "sessions.harness" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "AgentHarness" + - column: "sessions.activity_state" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "ActivityState" + - column: "sessions.activity_source" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "ActivitySource" diff --git a/docs/README.md b/docs/README.md index 220dec402e..ad4c1453e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,35 +1,17 @@ -# agent-orchestrator (rewrite) — docs +# agent-orchestrator rewrite docs -The agent-orchestrator is being rebuilt as a long-running **Go backend daemon** -(`backend/`) plus an **Electron + TypeScript frontend** (`frontend/`). The -backend supervises a fleet of coding-agent sessions and keeps one true status -per session. +The agent-orchestrator is being rebuilt as a long-running Go backend daemon +(`backend/`) plus an Electron + TypeScript frontend (`frontend/`). The backend +supervises coding-agent sessions and exposes daemon control, project/session +state, terminal streaming, and CDC/event infrastructure. -This folder documents the **Lifecycle Manager (LCM) + Session Manager (SM) -lane** — the deterministic core of the backend that is now implemented (behind -fakes) on the `feat/lcm-sm-contracts` integration branch. +Start with [architecture.md](architecture.md) for the current backend model and +[cli/README.md](cli/README.md) for the CLI surface. -## Start here +## Mental model -| Doc | What it covers | -|-----|----------------| -| [architecture.md](architecture.md) | How the lane works: the OBSERVE→DECIDE→ACT loop, the canonical state model, the package layout, every component, and the load-bearing invariants. Read this first. | -| [status.md](status.md) | What's done (PR by PR), what's left, the integration to-dos, the open cross-lane contract questions, and how to build/test. | -| [cli/README.md](cli/README.md) | CLI foundation decisions: Cobra, reference projects, old CLI inventory, and the first command surface. | +Persist durable facts, derive display status: -## The one-paragraph mental model - -The backend is a **stateless supervisor over external ground truth**: git/GitHub -own PR/CI/review truth, the agent's own files own its activity, and the backend -owns no agent state. Its whole job is, per session: **OBSERVE** raw facts → -**DECIDE** one canonical status via pure, deterministic functions → **ACT** -(persist + fire reactions). The LCM is that reducer; the SM is the -explicit-mutation plumbing (spawn/kill/restore/cleanup) that feeds it. - -## Where this lane fits - -Other lanes (built by other people, in parallel) provide the real adapters this -lane depends on through narrow interfaces: the **persistence layer + CDC**, the -**SCM poller**, the **runtime/agent/workspace plugins**, the **backend API + -OpenAPI**, and the **frontend store**. See [status.md](status.md#integration) -for the hand-off points. +- session table: `activity_state`, `is_terminated`, identity, metadata +- PR tables: PR/CI/review facts +- derived read model: `domain.DeriveStatus(session, prFacts)` diff --git a/docs/architecture.md b/docs/architecture.md index 9673142c75..fe2159bd18 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,187 +1,95 @@ -# LCM + Session Manager — architecture +# Agent Orchestrator backend architecture -This is the deterministic core of the backend daemon. It supervises agent -sessions and keeps exactly one true status per session. +The backend is a long-running Go daemon that supervises coding-agent sessions. +The current model is intentionally small: session rows persist only durable facts, +and display status is derived at read time. -## 1. Mental model: OBSERVE → DECIDE → ACT - -The backend owns no agent state. git/GitHub own PR/CI/review truth; the agent's -own files own its activity. The job, per session, is one loop: +## Mental model ``` -OBSERVE → DECIDE → ACT -(impure, external) (pure, total) (impure) -raw facts one canonical status persist + react +OBSERVE external facts → UPDATE durable facts → DERIVE display status / ACT ``` -In the rewrite the **OBSERVE** step lives *outside* the LCM (separate owners), -and the LCM is a **synchronous reducer** invoked with facts: +The durable session facts are: + +- `activity_state` — what the agent last reported or what the runtime observer + can safely conclude (`active`, `ready`, `idle`, `waiting_input`, `blocked`, + `exited`). +- `is_terminated` — whether the session should be treated as over. +- PR facts in the `pr`, `pr_checks`, and `pr_comment` tables. + +The UI status is not stored. `domain.DeriveStatus` computes it from the session +record plus PR facts. + +## Package layout ``` -SCM poller ─ ApplySCMObservation ──┐ -reaper ─ ApplyRuntimeObservation┤ -activity hooks ─ ApplyActivitySignal ───┼─▶ LCM: load canonical -Session Mgr ─ OnSpawnCompleted ──────┘ → pure DECIDE - ─ OnKillRequested → diff → persist (merge-patch) -reaper tick ─ TickEscalations → if transition: react (ACT) +backend/internal/domain shared vocabulary and display-status derivation +backend/internal/ports inbound/outbound interfaces +backend/internal/session explicit mutations: spawn, kill, restore, send, cleanup +backend/internal/lifecycle runtime/activity/spawn/termination session fact reducer +backend/internal/pr PR observation ingestion +backend/internal/storage SQLite persistence and DB-triggered CDC +backend/internal/cdc change-log poller and broadcaster +backend/internal/httpd daemon HTTP surface +backend/internal/terminal WebSocket terminal multiplexer +backend/internal/adapters Zellij/git-worktree/GitHub adapters ``` -The LCM **never polls**. The reaper (a timer, owned elsewhere) drives liveness -sampling and duration-based escalation by calling in. +## Status derivation -## 2. Canonical state model — the crown jewel +`session.Manager` selects the display PR from all PR snapshots for a session, then +`domain.DeriveStatus(session, prFacts)` applies this rough precedence: -The **only** thing persisted per session is `CanonicalSessionLifecycle` -(`backend/internal/domain/lifecycle.go`). The single-word display status is -**derived on read and never stored** — this is the most important invariant; it -prevents canonical truth and display from drifting. +1. `is_terminated` → `terminated`, except merged PRs display `merged`. +2. `activity_state=waiting_input` → `needs_input`. +3. `activity_state=blocked` → `stuck`. +4. Open PR facts drive PR pipeline statuses: `ci_failed`, `draft`, + `changes_requested`, `mergeable`, `approved`, `review_pending`, `pr_open`. +5. `activity_state=active` → `working`. +6. Everything else → `idle`. -``` -CanonicalSessionLifecycle - Version schema version of the record shape - Revision monotonic write counter (optimistic-concurrency token) - Session (state, reason) working/idle/needs_input/stuck/detecting/done/terminated - PR (state, reason) none/open/merged/closed - Runtime (state, reason) unknown/alive/exited/missing/probe_failed - Activity last-known agent activity (+ timestamp, source) ← decider input - Detecting anti-flap quarantine memory (nil unless quarantined) ← decider input -``` +## Lifecycle manager -`DeriveLegacyStatus` (`domain/status.go`) is the **sole producer** of the -display `SessionStatus`. Precedence: terminal/hard session states map directly -(they outrank PR facts) → a merged PR wins → an open PR maps by reason → else the -soft session state. So an idle worker with a CI-failing open PR displays -`ci_failed`, but a `needs_input` session shows `needs_input` regardless of the PR. +`lifecycle.Manager` is the write path for session lifecycle facts and lifecycle-owned agent nudges: -`Session` (`domain/session.go`) is the read-model: a `SessionRecord` -(persistence shape, identity + lifecycle + metadata) plus the derived `Status`. -The **Session Manager is the single producer of `Status`** — it attaches it on -read; the store and API never recompute or persist it. +- runtime observations can mark a session terminated only when runtime and + process are both clearly dead and recent activity does not contradict that; + failed/unknown probes do not persist a special state. +- activity signals update `activity_state`; `exited` also marks the session + terminated. +- PR observations do not write PR rows here, but after the PR service persists + them lifecycle sends actionable agent nudges for CI failures, review feedback, + and merge conflicts. -## 3. Package layout (`backend/internal/`) +## PR manager -``` -domain/ the vocabulary (imports only the std lib → no cycles) - lifecycle.go CanonicalSessionLifecycle + all sub-states/enums - status.go SessionStatus + DeriveLegacyStatus (sole display producer) - session.go SessionRecord (persisted) + Session (read-model) + id types - decide/ the PURE core — total, deterministic, zero I/O - types.go LifecycleDecision + Probe/OpenPR/Detecting inputs + tuning consts - decide.go the deciders + the anti-flap quarantine + HashEvidence -ports/ the boundaries (interfaces + DTOs) - inbound.go LifecycleManager, SessionManager (we implement) - outbound.go LifecycleStore, Notifier, AgentMessenger, Runtime/Agent/Workspace - facts.go SCMFacts, RuntimeFacts, ActivitySignal, SpawnOutcome, KillReason -lifecycle/ the LCM implementation (DECIDE + ACT) - manager.go the Apply* pipeline, per-session lock, patch diffing - decide_bridge.go fact→decide-input translation + the composition rules - reactions.go the reaction table + escalation engine + TickEscalations -session/ the SM implementation (explicit mutations) - manager.go Spawn/Kill/Restore/Cleanup/List/Get/Send + rollback -``` +`pr.Manager` records SCM observations into the PR/check/comment tables, then +forwards the observation to lifecycle for agent nudges. A merged PR marks the +owning session terminated through the lifecycle manager; other PR facts are +consumed at read time for display status. -`domain` + `ports` are the committed, stabilized **integration boundary**. -Everything else implements behind it. +## Session manager -## 4. The pure DECIDE core (`domain/decide`) +`session.Manager` performs explicit user mutations: -Total, deterministic, side-effect-free functions — the highest-value test -surface (table-tested to 100%). Key ones: +- `Spawn` creates a row, creates workspace/runtime resources, and reports the + handles to the lifecycle manager. +- `Kill` marks the row terminated, then tears down runtime/workspace resources. +- `Restore` relaunches a terminated session and clears `is_terminated` via the + spawn-completed path. +- `List`/`Get` attach the derived display status. -- `ResolveProbeDecision` — runtime/process liveness. An explicit kill - short-circuits to terminal; a **failed probe is never read as death** (routes - to `detecting`), as does any probe disagreement; only runtime-dead + - process-dead + no-recent-activity reaches `killed`. -- `ResolveOpenPRDecision` — the PR ladder: `ci_failing` → `changes_requested` → - `mergeable` → `approved` → `review_pending` → idle-beyond → else `pr_open`. -- `ResolveTerminalPRStateDecision` — merged → `merged` (park idle awaiting a - human decision); closed → `idle`. -- `CreateDetectingDecision` — the **anti-flap quarantine**. Counts attempts and - hashes the *timestamp-stripped* evidence; escalates to `stuck` only after 3 - consecutive unchanged-evidence ticks **or** 5 minutes since first entering - detecting (`StartedAt` is preserved across the whole episode). Changing - evidence resets the counter. +## Persistence and CDC -## 5. The LCM (`lifecycle`) +SQLite is the durable store. User-visible table changes are captured by database +triggers into `change_log`; the Go store does not manually emit CDC events. A +poller tails `change_log` and publishes live events to in-process subscribers. -Implements `ports.LifecycleManager`. Every `Apply*`/`On*` entrypoint runs the -same pipeline (`manager.go`): +## Load-bearing rules -``` -withLock(session): ← per-session serialization - load canonical → decideFn (build sparse patch) → if changed: persist → load after -return transition (before, after) -``` -then, **after the lock releases**, `react()` fires the mapped reaction. - -- **Per-session serialization** — `keyedMutex` hands out one lock per session id - (parallel across sessions, serial within one). Entries are reference-counted - and evicted when the last holder releases, so the map stays bounded. -- **Composition rules** (`decide_bridge.go`) — two observers must not fight over - the session axis. Liveness (runtime probes) owns the runtime + death/detecting - axis; activity owns working/idle/waiting. `isLivenessOwned` decides when a - healthy probe may *recover* a state (e.g. `detecting → working`) vs. when it - must not clobber an activity-owned `needs_input`/`blocked`. A high-confidence - activity signal may resolve a `detecting` session; an open PR writes only the - PR axis and lets `DeriveLegacyStatus` surface it. -- **Detecting-memory lifecycle** — a decision with `Detecting == nil` clears the - persisted quarantine memory (`LifecyclePatch.ClearDetecting`) so a stale prior - can't leak into a later episode. -- **ACT — reactions + escalation** (`reactions.go`) — on a genuine status - transition, `react()` maps it to a reaction (`send-to-agent` / `notify`; - `auto-merge` exists but is off by default) and dispatches it. A - per-`(session,reaction)` escalation tracker counts attempts; it escalates - (notifies a human and silences further auto-dispatch) when a numeric cap or a - duration is exceeded. The `ci-failed` budget is persistent across CI - oscillation within an open PR and re-arms on genuine recovery. `TickEscalations` - (called by the reaper) fires the duration-based escalations the synchronous - LCM can't wake itself for; it notifies outside the lock. - -## 6. The Session Manager (`session`) - -Implements `ports.SessionManager` — the explicit-mutation plumbing. It never -derives/observes lifecycle state; it routes outcomes to the LCM. - -- **Spawn** — `Workspace.Create` → build prompt → `Runtime.Create` (env - `AO_SESSION_ID`/`AO_PROJECT_ID`/`AO_ISSUE_ID`) → **seed** the initial record - (`not_started`/`spawn_requested`) via the store → `LCM.OnSpawnCompleted`. - Eager rollback unwinds prior steps on failure; an `OnSpawnCompleted` failure - routes the seeded orphan to terminal-errored (the store has no delete; a later - `Cleanup` reclaims it). -- **Kill** — `LCM.OnKillRequested` → `Runtime.Destroy` → `Workspace.Destroy`, - honoring the **worktree-remove safety**: after `git worktree prune`, a still- - registered path is never `rm -rf`'d (it may hold the agent's uncommitted work) - — the refusal is surfaced, not forced. -- **Restore** — reopen via `PatchLifecycle` (not re-seed): session → - `not_started`, PR → `cleared_on_restore`; relaunch with the agent's resume - command; runtime is rolled back on a post-create failure. -- **List/Get** — read records and attach the derived `Status`. **Send** — via - `AgentMessenger`. **Cleanup** — tear down terminal/stale sessions, skipping - paths with uncommitted work. - -## 7. Load-bearing invariants - -1. **Persist canonical; derive display.** Never store the display status. -2. **One authority for death.** Only the DECIDE pipeline (via `detecting`) writes - inferred terminal states; the SM's explicit-kill path goes through - `OnKillRequested`. Everything else that notices a dead runtime persists - `detecting`, never `terminated`. -3. **Failed probe ≠ dead.** Timed-out/errored probes route to `detecting`. -4. **Evidence-hash debounce** prevents flapping signals from terminating live - work; the 5-minute cap is a whole-episode wall-clock safety net. -5. **PR facts dominate** the soft session states once a PR exists. -6. **Merge-patch persistence** — writes touch only changed keys; the store is the - single disk writer (atomic write + lock + CDC). -7. **Sticky activity states** (`waiting_input`/`blocked`) do not decay by clock. -8. **Worktree-remove safety** on teardown. - -## 8. Concurrency & testing - -- Within a session, the per-session lock serializes the load→decide→persist - read-modify-write. `react()` runs *outside* the lock (so a busy-waiting - send-to-agent never holds the session mutex) — see `status.md` for the - integration-time follow-up this implies. -- Tests use **in-memory fakes** for every outbound port, so the LCM and SM are - fully testable with no real adapters. The SM tests drive the **real** - `lifecycle.Manager` for spawn/kill round-trips, so the SM↔LCM contract is - genuinely exercised. The `decide` package is table-tested in isolation. +- Do not store display status. +- Keep session status facts small: `activity_state`, `is_terminated`, and PR + facts are the durable inputs. +- Do not treat failed probes as death. +- Do not force-delete registered dirty worktrees. diff --git a/docs/cli/README.md b/docs/cli/README.md index d78539a035..f4af7107a4 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -1,32 +1,41 @@ -# AO CLI Foundation +# AO CLI -This page is the running decision log for the Agent Orchestrator CLI. Keep new -CLI decisions here as the command surface grows. +The `ao` CLI is a thin Go/Cobra client for the local Agent Orchestrator daemon. +It starts, discovers, inspects, and stops the daemon through the loopback HTTP +surface and the `running.json` handshake. It must not open SQLite directly or +call runtime, workspace, tracker, or agent adapters in-process. -## Current State +## Current commands -This branch implements the daemon-control foundation. AO now has a Go/Cobra -`ao` binary that can start, inspect, diagnose, and stop the local backend daemon -end to end. +| Command | Purpose | +|---|---| +| `ao start` | Start the daemon in the background and wait for `/readyz`. | +| `ao status` | Report daemon state from `running.json`, process liveness, `/healthz`, and `/readyz`. | +| `ao status --json` | Emit the same daemon state as machine-readable JSON. | +| `ao stop` | Gracefully stop the daemon via loopback `POST /shutdown` after verifying daemon identity. | +| `ao doctor` | Check config, data directory, DB-file presence, daemon state, `git`, and optional `zellij`. | +| `ao doctor --json` | Emit doctor checks as JSON. | +| `ao completion ` | Generate completions for `bash`, `zsh`, `fish`, or `powershell`. | +| `ao version` / `ao --version` | Print build metadata. | +| `ao daemon` | Hidden internal daemon entrypoint used by `ao start`. | + +`go run .` in `backend/` remains a compatibility wrapper around the daemon. + +## Configuration -What works now: +The CLI and daemon share the same environment-driven config: + +| Var | Default | Purpose | +|---|---|---| +| `AO_PORT` | `3001` | Loopback daemon port. | +| `AO_RUN_FILE` | `/agent-orchestrator/running.json` | PID/port handshake. | +| `AO_DATA_DIR` | `/agent-orchestrator/data` | SQLite data directory. | +| `AO_REQUEST_TIMEOUT` | `60s` | REST request timeout. | +| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown cap. | -- `ao start` starts the daemon in the background and waits for `/readyz`. -- `ao status` and `ao status --json` report stopped, stale, unhealthy, - not-ready, or ready daemon state. -- `ao stop` gracefully stops the daemon via the loopback `POST /shutdown` - endpoint, only after verifying the daemon's identity from `running.json`. -- `ao daemon` is the hidden internal daemon entrypoint used by `ao start`. -- `ao doctor` (and `ao doctor --json`) checks config, data dir, the database - file's presence, daemon state, and local tool availability for `git`, `tmux`, - and `zellij`. It never opens or migrates the store — the daemon is the sole - writer/migrator, so doctor only reports whether the database exists yet. -- `ao completion` generates shell completions for `bash`, `zsh`, `fish`, and - `powershell`. -- `ao version` and `ao --version` print build metadata. -- `go run .` still works as a compatibility wrapper around `internal/daemon.Run`. +The daemon always binds `127.0.0.1`. -Manual smoke test: +## Manual smoke test ```bash cd backend @@ -43,353 +52,18 @@ export AO_PORT=3037 /tmp/ao status --json /tmp/ao stop /tmp/ao status --json +rm -rf "$tmp" ``` -What is intentionally not implemented yet: - -- `ao project ...` -- `ao spawn` -- `ao session ...` -- `ao send` -- `ao events ...` - -Next steps: - -1. Wire the existing project manager/controller shell into the daemon with a - durable SQLite-backed project store. -2. Implement `ao project list/add/show/remove` against `/api/v1/projects`. -3. Wire production Session Manager dependencies: project-backed repo resolver, - tmux/zellij runtime registry, first agent adapter, and AgentMessenger. -4. Add `/api/v1/sessions`, then implement `ao spawn`, `ao session ...`, and - `ao send`. -5. Add `/events` SSE and durable event-list reads, then implement - `ao events tail/list`. - -## Decision - -AO will use a single Go CLI binary built with -[Cobra](https://github.com/spf13/cobra). - -The CLI is a thin client for the Go daemon. It should not call SQLite, runtime -adapters, agent adapters, workspace adapters, or SCM integrations directly. It -should start, discover, inspect, and command the daemon through the loopback API -and the existing `running.json` handshake. - -Initial rules: - -- The binary name is `ao`. -- `ao daemon` is the hidden/internal entrypoint for the long-running daemon. -- User-facing commands call the daemon over loopback after reading - `running.json`. -- Commands that mutate core AO state go through HTTP API routes, not direct - stores. -- Commands support predictable text output first and `--json` where automation - is likely. -- Do not introduce Viper in the foundation. Start with explicit flags and a - small config/client layer, then add config loading once the shape is real. - -## References - -These projects inform the direction, but AO should keep its own command surface -smaller at first. - -| Project | CLI stack | What to take | -|---|---|---| -| [Gastown](https://github.com/gastownhall/gastown) | Go + Cobra, with Charmbracelet packages for richer terminal UI | Simple `cmd//main.go` delegating to internal command construction. Useful confirmation that Cobra is the right default for this size of Go CLI. | -| [GitHub CLI](https://github.com/cli/cli) | Go + Cobra | Command factories, explicit IO streams, JSON output, and testable command construction. | -| [Docker CLI](https://github.com/docker/cli) | Go + Cobra | Daemon/client split, command groups, signal handling, and plugin-aware CLI layout. | -| [kubectl](https://github.com/kubernetes/kubectl) | Go + Cobra | Large command tree patterns and IO abstractions. It is a useful ceiling, not a shape to copy now. | -| [Tailscale CLI](https://github.com/tailscale/tailscale) | Go + ffcli | Useful daemon-backed product model: a CLI talks to a local daemon. Do not copy the framework choice. | - -The old AO TypeScript CLI is a product/workflow reference only. We should not -port its implementation because it mixes CLI, storage, runtime, and project -logic in-process. The rewrite needs the CLI to sit outside the core daemon. - -## Current Legacy CLI Inventory - -Inventory source: installed `ao` binary at version `0.9.2`, plus the old -`packages/cli/src/program.ts` and `packages/cli/src/commands/*.ts` files. - -Count: - -- 25 public top-level commands, excluding Commander-generated `help`. -- 26 visible top-level commands if generated `help` is counted. -- 64 explicit public command nodes when nested subcommands are counted. -- 1 hidden internal command: `completion __complete`. -- No aliases are registered in the old Commander source. - -Top-level commands: - -| Command | Legacy purpose | Foundation decision | -|---|---|---| -| `start` | Start orchestrator agent and dashboard | Keep, but redefine as daemon start. | -| `stop` | Stop orchestrator agent and dashboard | Keep, daemon stop. | -| `status` | Show all sessions and project/session health | Keep, daemon and session status. | -| `spawn` | Spawn a single agent session | Keep after session API exists. | -| `batch-spawn` | Spawn many sessions | Defer. | -| `session` | Manage sessions | Keep a smaller subset after session API exists. | -| `send` | Send a message to a session | Keep after messaging API exists. | -| `acknowledge` | Agent self-reporting hook | Defer or replace with internal API. | -| `report` | Agent workflow transition hook | Defer or replace with internal API. | -| `review-check` | Trigger agents from review comments | Defer. | -| `review` | Manage AO-local reviewer runs | Defer. | -| `dashboard` | Start web dashboard | Defer to Electron/frontend lane. | -| `open` | Open terminal/dashboard | Defer. | -| `verify` | Verify issue after staging check | Defer. | -| `doctor` | Run install/env/runtime checks | Keep. | -| `update` | Upgrade AO | Defer to packaging/release lane. | -| `setup` | Configure integrations | Defer. | -| `plugin` | Plugin marketplace/install flow | Defer. | -| `notify` | Notification test commands | Defer. | -| `project` | Manage registered projects | Keep after project API exists. | -| `migrate-storage` | Legacy storage migration | Drop for rewrite unless a real migration appears. | -| `completion` | Generate shell completions | Keep. | -| `events` | Query activity event log | Keep a small `tail`/`list` surface after event API exists. | -| `config` | Read/write old global config | Defer. Avoid until config shape is stable. | -| `config-help` | Print old config schema | Drop. | - -Nested legacy commands: - -| Parent | Subcommands | -|---|---| -| `session` | `ls`, `attach`, `kill`, `cleanup`, `claim-pr`, `restore`, `remap` | -| `review` | `run`, `execute`, `send`, `list` | -| `setup` | `dashboard`, `desktop`, `webhook`, `slack`, `discord`, `composio`, `composio-slack`, `composio-discord`, `composio-discord-bot`, `composio-mail`, `openclaw` | -| `plugin` | `list`, `search`, `create`, `install`, `update`, `uninstall` | -| `project` | `ls`, `add`, `rm`, `set-default` | -| `events` | `list`, `search`, `stats` | -| `config` | `set`, `get` | -| `notify` | `test` | -| `completion` | `zsh`, hidden `__complete` | - -## Initial Command Surface - -The first CLI should make AO installable, startable, inspectable, and stoppable -before trying to recreate the old product surface. - -### Foundation Commands - -These are the first commands to implement. - -| Command | Purpose | Notes | -|---|---|---| -| `ao start` | Start the daemon, wait for `/readyz`, and print PID/port. | Reads the same config env as the daemon. Should be idempotent when an existing healthy daemon is already running. | -| `ao stop` | Stop the running daemon. | Reads `running.json`, sends graceful termination, waits for run-file removal, and reports stale/dead daemon state clearly. | -| `ao status` | Show daemon status and, once APIs exist, project/session summary. | First version can show run-file, process liveness, `/healthz`, `/readyz`, uptime, and port. Add `--json`; add `--watch` once useful. | -| `ao daemon` | Hidden internal daemon entrypoint. | This replaces the current direct `go run .` daemon entrypoint once `main.go` is extracted into `internal/daemon`. | -| `ao doctor` | Diagnose the local environment. | Start with daemon/run-file/port checks, required binaries, config dir/data dir permissions, and runtime availability. | -| `ao completion` | Generate shell completions. | Cobra can support `bash`, `zsh`, `fish`, and `powershell`. | -| `ao version` | Print CLI and build metadata. | Implement as both `ao version` and Cobra's `--version` flag. | - -This gives a useful first release even before project/session mutation routes are -complete. - -### First Core Application Commands - -These are the next commands once daemon HTTP routes expose the needed managers. - -| Command | Purpose | Depends on | -|---|---|---| -| `ao project list` | List registered projects. | Project API. Alias `ls` is acceptable for old muscle memory. | -| `ao project add ` | Register a project. | Project API and project identity rules. | -| `ao project show ` | Inspect project config and health. | Project API. | -| `ao project remove ` | Archive/remove a project. | Project API. Alias `rm` is acceptable. | -| `ao spawn [issue]` | Spawn one coding-agent session. | Session Manager HTTP route, tracker lookup, workspace/runtime/agent adapters. | -| `ao session list` | List sessions across projects or one project. | Session API. Alias `ls` is acceptable. | -| `ao session show ` | Show one session with lifecycle, PR, CI, runtime, and paths. | Session API. | -| `ao session attach ` | Attach to the runtime terminal. | Runtime API or direct terminal attach contract exposed by daemon. | -| `ao session kill ` | Kill a session and clean up safely. | Session Manager `Kill`. | -| `ao session restore ` | Restore a terminated/crashed session. | Session Manager `Restore`. | -| `ao send [message...]` | Send instructions to a running session. | AgentMessenger route. | -| `ao events tail` | Follow daemon activity events. | SSE/CDC API. | -| `ao events list` | List recent activity events. | Event read API. | - -This is the smallest surface that covers the core product loop: - -1. Register a repo. -2. Start AO. -3. Spawn work. -4. Inspect work. -5. Intervene in work. -6. Stop AO. - -## Explicit Deferrals - -Do not include these in the CLI foundation: - -- `batch-spawn`: valuable, but it multiplies error handling before single-spawn - semantics are stable. -- `dashboard` and `open`: frontend/Electron should own the primary dashboard - launch path first. -- `review`, `review-check`, and `verify`: useful workflow automation, but not - required to run core AO. -- `setup`, `plugin`, and `notify`: integration/plugin surface should come after - the daemon API and config model settle. -- `update`: belongs with distribution and release packaging. -- `config` and `config-help`: wait for a stable Go config model. Avoid copying - the old TypeScript global config behavior. -- `migrate-storage`: old storage migration is not part of the rewrite unless a - concrete migration requirement appears. -- `acknowledge` and `report`: these are agent self-reporting hooks. Prefer a - daemon/internal protocol before exposing them as durable user CLI commands. - -## Implementation Plan - -1. Add Cobra to `backend/go.mod`. -2. Move current daemon startup from `backend/main.go` into - `backend/internal/daemon.Run(ctx, opts)`. -3. Add `backend/cmd/ao/main.go` as the only user binary entrypoint. -4. Add `backend/internal/cli` for command construction, IO streams, process - launching, run-file discovery, loopback HTTP client, and output formatting. -5. Implement `ao daemon` first so the current daemon behavior is preserved. -6. Implement `ao start`, `ao stop`, and `ao status` around `running.json` and - `/healthz`/`/readyz`. -7. Add `ao doctor`, `ao completion`, and `ao version`. -8. Add command tests using Cobra command construction with fake IO, fake process - runner, and fake daemon client. Keep daemon integration tests in the daemon - packages. - -Suggested package layout: - -```text -backend/ - cmd/ - ao/ - main.go - internal/ - cli/ - root.go - start.go - stop.go - status.go - doctor.go - completion.go - version.go - client.go - output.go - process.go - daemon/ - daemon.go -``` - -Acceptance criteria for the foundation: - -- `go run ./cmd/ao daemon` behaves like today's `go run .`. -- `go run ./cmd/ao start` starts the daemon and waits until `/readyz` returns - ready. -- `go run ./cmd/ao status --json` works when the daemon is running, stopped, and - stale. -- `go run ./cmd/ao stop` gracefully stops the daemon and removes `running.json`. -- `go test ./...`, `go vet ./...`, and `go test -race ./...` pass. - -## Implementation Readiness - -This section records what the CLI can connect to in the current codebase and -what still needs to be built. Inventory date: 2026-05-31 after merging -`origin/main` at `438b830`. - -### Implemented Foundation - -The daemon-control foundation now exists in `backend/cmd/ao` and -`backend/internal/cli`. - -Implemented commands: - -- `ao daemon` hidden/internal daemon entrypoint. -- `ao start` starts the daemon, waits for `/readyz`, and supports `--json`, - `--timeout`, and `--log-file`. -- `ao stop` stops the daemon from `running.json`, removes stale run-files, and - supports `--json` and `--timeout`. -- `ao status` reports stopped/stale/unhealthy/not-ready/ready states and - supports `--json`. -- `ao doctor` checks config, data dir, database-file presence, daemon state, and - local tool availability for `git`, `tmux`, and `zellij`; supports `--json`. It - does not open or migrate the store (the daemon owns that). -- `ao completion` generates `bash`, `zsh`, `fish`, and `powershell` - completions. -- `ao version` prints build metadata. - -The old `backend/main.go` remains as a compatibility wrapper around -`internal/daemon.Run`, so `go run .` still starts the daemon while scripts move -to `go run ./cmd/ao ...`. - -### Already Implemented and Directly Usable by the CLI - -These pieces are available now and are enough to build the daemon-management -part of the CLI. - -| Area | Existing code | CLI use | -|---|---|---| -| Daemon config | `backend/internal/config` loads `AO_PORT`, `AO_REQUEST_TIMEOUT`, `AO_SHUTDOWN_TIMEOUT`, `AO_RUN_FILE`, and `AO_DATA_DIR`. Host is fixed to `127.0.0.1`. | `ao start`, `ao daemon`, `ao status`, and `ao doctor` can share the same config resolution. | -| HTTP server lifecycle | `backend/internal/httpd.Server` binds loopback, writes `running.json`, serves until context cancellation, then removes `running.json`. | `ao daemon` can preserve today's daemon behavior after extraction into `internal/daemon`. | -| Health probes | `GET /healthz` and `GET /readyz`. | `ao start` can wait for readiness; `ao status` and `ao doctor` can check daemon health. | -| Run-file handshake | `backend/internal/runfile` reads, writes, removes, and stale-checks `running.json`. | `ao status` can discover PID/port; `ao stop` can find the process; `ao start` can detect an already-running daemon. | -| Durable store | `backend/internal/storage/sqlite` opens SQLite, runs goose migrations, uses WAL, stores projects/sessions/PR/check/comment rows, and reads `change_log`. | Not directly called by user CLI commands, but confirms the daemon has a durable backend once APIs expose it. | -| CDC substrate | `backend/internal/cdc` poller and broadcaster exist; daemon starts the poller with `startCDC`. | Future `ao events tail` can build on this once an SSE/API transport exists. | -| Lifecycle manager | `backend/internal/lifecycle` is implemented and currently wired in daemon startup. | Session/status APIs can use it; CLI must wait for HTTP routes rather than calling it directly. | -| Reaper timer | `backend/internal/observe/reaper` exists and is wired. | Runtime liveness will be available once runtime registry wiring exists. | - -### Implemented Internally but Not Reachable by CLI Yet - -These are real backend components, but the CLI cannot responsibly use them until -they are wired into the daemon and exposed through HTTP. - -| Area | Existing code | Missing before CLI can use it | -|---|---|---| -| Project API pieces | `internal/project` has manager/controller DTOs, `/api/v1/projects` routes exist, and `sqlite.Store` has project CRUD. | Durable project-store adapter/wiring in the daemon and CLI commands. The daemon currently constructs the router with nil API deps, so project routes are not product-usable from `ao` yet. | -| Session Manager | `backend/internal/session.Manager` implements `Spawn`, `Kill`, `Restore`, `List`, `Get`, `Send`, and `Cleanup`. | Production daemon wiring with real runtime, agent, workspace, messenger, and HTTP routes. | -| Runtime adapters | tmux and zellij adapters implement `ports.Runtime` and also have attach/send/output helpers. | Runtime registry wiring in daemon, attach/send abstractions in ports/API, and selection config. | -| Workspace adapter | git worktree adapter implements create/destroy/restore/list with safety checks. | Repo resolver backed by registered projects and daemon wiring into Session Manager. | -| GitHub issue tracker | `backend/internal/adapters/tracker/github` implements read-only issue `Get`, `List`, and `Preflight`. | Tracker registry/config, spawn prompt hydration, and project tracker metadata. | -| PR facts storage | SQLite PR/check/comment writes and CDC triggers exist. | SCM/PR observer that fetches GitHub PR/CI/review facts and calls `LCM.ApplyPRObservation`. | -| Session read model | `SessionManager.List/Get` derive display status from canonical lifecycle + PR facts. | HTTP response DTOs and API routes for CLI/frontend reads. | - -### Still Missing - -These are the main gaps before the full initial command set is real. - -| Gap | Blocks | -|---|---| -| Product API client package with run-file discovery. | `project`, `spawn`, `session`, `send`, `events list`, richer `status`. | -| Shutdown mechanism choice: PID signal now, optional `POST /api/v1/daemon/shutdown` later. | `ao stop` polish and cross-platform behavior. | -| Session/send API route surface under `/api/v1`. | `spawn`, `session`, `send`, richer `status`. | -| Project API daemon wiring. | `ao project list/add/show/remove`. | -| SSE route for live CDC events plus durable catch-up reads. | `ao events tail`, frontend live updates. | -| Agent adapters for supported harnesses (`codex`, `claude-code`, etc.). | `ao spawn`, `ao session restore`. | -| AgentMessenger implementation over tmux/zellij. | `ao send`, LCM auto-nudge reactions. | -| Runtime registry wired with tmux/zellij. | Reaper liveness, `session attach`, spawn/kill/restore runtime work. | -| Notifier implementation/multiplexer. | Human notifications and LCM escalation side effects. | -| Activity hooks or agent self-report protocol. | Accurate working/idle/needs-input status beyond runtime/PR facts. | -| Project/tracker config model. | `project add/show`, tracker-backed `spawn`, `doctor` config checks. | -| OpenAPI/DTO/error contract. | Stable CLI/frontend API clients and tests. | - -### Command Readiness Matrix +## Product commands not present yet -| Command | Can implement now? | Existing support | Remaining work | -|---|---:|---|---| -| `ao daemon` | Implemented | Current daemon startup is extracted to `internal/daemon.Run`. | None for foundation. | -| `ao start` | Implemented | Config, run-file stale check, HTTP readiness probes. | Later: package-manager/service integration if needed. | -| `ao stop` | Implemented | Run-file discovery gives PID/port; server exits cleanly on SIGINT/SIGTERM. | Optional later shutdown HTTP route. | -| `ao status` | Partially implemented | Run-file, process liveness via PID, `/healthz`, `/readyz`. | Rich project/session summary waits for `/api/v1/projects` and `/api/v1/sessions`. | -| `ao doctor` | Partially implemented | Config resolution, run-file, database-file presence (no open/migrate), runtime binary checks. | Deeper adapter preflights need daemon wiring/config and should be queried from the daemon, not run in-process. | -| `ao completion` | Implemented | Cobra generators. | None for foundation. | -| `ao version` | Implemented | Build metadata can be injected with `-ldflags`. | Release tooling needs to set metadata. | -| `ao project list/add/show/remove` | Not yet | Project manager/controller route shell and SQLite project CRUD exist. | Durable project-store adapter, daemon API wiring, and CLI HTTP client. CLI must not write SQLite directly. | -| `ao spawn` | Not yet | Session Manager exists; runtime/workspace/tracker pieces partly exist. | Agent adapters, registry/config wiring, project lookup, tracker hydration, HTTP route. | -| `ao session list/show` | Not yet | Store and Session Manager read model exist. | HTTP routes and response DTOs. | -| `ao session attach` | Not yet | tmux/zellij have attach command helpers. | Runtime attach port/API and terminal-launch policy. | -| `ao session kill/restore` | Not yet | Session Manager implements both. | Production wiring and HTTP routes. | -| `ao send` | Not yet | Session Manager has `Send`; tmux/zellij have send helpers. | AgentMessenger implementation, port/API wiring, busy/idle delivery policy. | -| `ao events tail/list` | Not yet | Durable `change_log`, CDC poller, in-process broadcaster. | SSE route and durable event-list route. | +The backend has project, session, lifecycle, terminal, and CDC building blocks, +but the public CLI currently exposes only daemon-control commands. Add product +commands only when a daemon HTTP route owns the corresponding mutation/read: -### Recommended Build Order +- `ao project ...` should call project HTTP routes. +- `ao spawn`, `ao session ...`, and `ao send` should call session/messaging HTTP routes. +- `ao events ...` should call CDC/event HTTP routes. -1. Build CLI foundation around the daemon only: `daemon`, `start`, `stop`, - `status`, `doctor`, `completion`, `version`. -2. Wire the existing project manager/controller shell into the daemon with a - durable SQLite-backed store, then implement `project list/add/show/remove`. -3. Wire production Session Manager dependencies: project-backed repo resolver, - tmux/zellij runtime registry, first agent adapter, and AgentMessenger. -4. Add `/api/v1/sessions` and implement `spawn`, `session list/show/kill/restore`, - and `send`. -5. Add `/events` SSE plus event-list reads, then implement `events tail/list`. +Do not port old in-process TypeScript CLI behavior that mixed command handling +with storage and runtime implementation details. diff --git a/docs/status.md b/docs/status.md index 9bb79cdb19..6ca5bc2746 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,98 +1,29 @@ -# LCM + Session Manager — status & roadmap +# agent-orchestrator status -Where the lane stands, what's left, and where to plug in. +Current main contains the Go backend daemon, Cobra CLI foundation, SQLite store, +CDC poller/broadcaster, lifecycle/session managers, terminal mux, project API +controller/manager work, runtime/workspace/tracker adapters, and CDC-backed event rows. -## Branch model +## Build & test -`feat/lcm-sm-contracts` is the **lane integration branch**: each sub-PR below -branched off it and merged **into** it. The whole lane lands on `main` as one -unit once it's ready. Sub-PRs were reviewed against the integration branch; -the eventual lane→main merge is a single cumulative review. - -## Done — implementation complete (behind fakes) - -| Area | What landed | PR | -|------|-------------|----| -| Skeleton | `backend/` (Go) + `frontend/` (Electron/TS) | #1 (on `main`) | -| Contracts + CI | `domain/` + `ports/`; Go + gitleaks workflows | #2 | -| Pure DECIDE core | the deciders + anti-flap quarantine + exhaustive truth-table tests | #4 | -| LCM — pipeline | `Apply*` pipeline, per-session serialization, store integration, composition rules, detecting-memory lifecycle | #5 | -| LCM — reactions | reaction table + escalation engine + real `TickEscalations` | #6 | -| Session Manager | spawn / kill / restore / cleanup / list, eager rollback, worktree-remove safety | #7 | - -`gofmt` / `go build` / `go vet` / `go test -race` all green across `domain`, -`domain/decide`, `lifecycle`, and `session`. The `decide` core is at 100% -statement coverage; the impl packages cover the load-bearing logic including the -error/rollback paths. - -### Build & test - -``` -cd backend -gofmt -l . # must print nothing -go build ./... -go vet ./... -go test -race ./... -go test -cover ./... +```bash +npm run lint ``` -## Not done — the integration phase - -Everything above runs against **in-memory fakes**. Making it a live system means -swapping fakes for real adapters (built by other lanes) behind the existing -ports, and resolving the carried-forward items below. - -### Carried-forward items (must be addressed as real adapters land) - -- **`react()` out-of-lock dispatch.** Reactions fire after the per-session lock - releases (deliberate, so a busy-waiting send-to-agent doesn't hold the mutex). - Under a live daemon with concurrent observers this can dispatch on a stale - snapshot / out of order. Give `react()` a per-session ordering (a small react - queue) or re-check the triggering state before dispatching. Documented in - `lifecycle/reactions.go`. -- **`ExpectedRevision` optimistic-concurrency is unused.** The in-process - per-session mutex covers a single daemon. Multi-writer or CDC-driven setups - must use the `LifecyclePatch.ExpectedRevision` CAS the contract already exposes. -- **Store `Seed` + `Get` need a real implementation.** The Session Manager added - two record-with-identity methods to `LifecycleStore`; the real persistence - layer must implement them (create-with-identity that rejects an existing id; - full-record read by id). Documented in `ports/outbound.go`. - -### Real adapters needed (other lanes) - -| Port | Real adapter | Owning lane | -|------|--------------|-------------| -| `LifecycleStore` | persistence layer (flat-file/KV + atomic write + lock + CDC) | persistence | -| `SCMFacts` producer | SCM poller (batch PR/CI/review enrichment) | SCM | -| `Runtime` / `Agent` / `Workspace` | tmux runtime, claude-code/codex agent, git-worktree workspace | coding-agents | -| `Notifier` | desktop/Slack notifier | notifications | -| `AgentMessenger` | tmux inject with busy-detect + delivery verify | coding-agents | -| `SessionManager` consumer | backend API (routes/controllers) + OpenAPI | API | - -### Open cross-lane contract questions - -- **SCM facts** — does `SCMFacts` match what the poller can cheaply produce - (batch enrichment, CI log tail as a pointer)? -- **Persistence** — is `LifecycleStore` + `LifecyclePatch` the right boundary? - Per-session lock vs. the `ExpectedRevision` CAS? -- **API** — is the `SessionManager` interface + the `Session` read-model - OpenAPI-friendly? - -### Land the lane → `main` +## Current shape -A final cumulative review of `feat/lcm-sm-contracts` vs. `main`, then merge the -complete lane in one unit. +- CLI: `ao start`, `status`, `stop`, `doctor`, `completion`, `version`, and the + hidden daemon entrypoint. +- Session facts: `activity_state` and `is_terminated`; display status is derived + from those plus PR facts. +- SQLite: migrations create projects, sessions, PR/check/comment, and `change_log` tables. +- CDC: DB triggers append to `change_log`; the poller broadcasts live events. +- Session Manager: spawn/kill/restore/list/get/send/cleanup over runtime, + workspace, agent, store, messenger, and lifecycle ports. It is package-level + code today; daemon HTTP routes for session commands are not wired yet. -## Where to plug in (for someone picking this up) +## Next integration work -- **Implementing a real adapter?** Write it to satisfy the matching interface in - `ports/`, then construct the `lifecycle.Manager` / `session.Manager` with it in - place of the fake. Nothing in `domain`/`lifecycle`/`session` should need to - change. -- **Changing decision behavior?** It lives in `domain/decide` (pure) — add a - truth-table case first; nothing there does I/O. -- **Adding a reaction?** Extend the table in `lifecycle/reactions.go` and map the - triggering status in `reactionEventFor`. -- **Don't** persist the display status, conclude death outside the probe - pipeline, or `rm -rf` a still-registered worktree — see the invariants in - [architecture.md](architecture.md#7-load-bearing-invariants). +- Wire production agent adapters. +- Finish project/session HTTP routes and CLI product commands. +- Add SSE/event read endpoints over the CDC log. diff --git a/package.json b/package.json new file mode 100644 index 0000000000..4010149de2 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "agent-orchestrator", + "private": true, + "scripts": { + "lint": "cd backend && go test ./... && go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run --path-mode=abs", + "frontend:typecheck": "npm --prefix frontend run typecheck", + "sqlc": "cd backend && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate" + } +} diff --git a/test/cli/Dockerfile b/test/cli/Dockerfile index fb5d85b2a7..6ed08cc6fc 100644 --- a/test/cli/Dockerfile +++ b/test/cli/Dockerfile @@ -24,11 +24,11 @@ RUN cd backend && CGO_ENABLED=0 go build -trimpath -o /out/ao ./cmd/ao # ---- stage 2: a clean machine with NO Go toolchain, just like an end user ---- FROM debian:bookworm-slim AS run -# Runtime deps a fresh user would need: git is required by `ao doctor`; tmux is -# the optional runtime it probes for; curl drives the HTTP-level guard checks; -# ca-certificates for good measure. +# Runtime deps a fresh user would need: git is required by `ao doctor`; curl +# drives the HTTP-level guard checks; ca-certificates for good measure. Zellij is +# optional for this smoke test, so doctor reports a WARN if it is absent. RUN apt-get update \ - && apt-get install -y --no-install-recommends git tmux curl ca-certificates \ + && apt-get install -y --no-install-recommends git curl ca-certificates \ && rm -rf /var/lib/apt/lists/* # "Install" the CLI the way a user would: drop the binary on PATH. From c8f605053995386f3ae569dbb9ecb9e51dc49902 Mon Sep 17 00:00:00 2001 From: prateek Date: Mon, 1 Jun 2026 09:26:18 +0530 Subject: [PATCH 098/250] refactor: remove activity source tracking (#62) (#66) Co-authored-by: itrytoohard --- backend/internal/cdc/cdc_test.go | 2 +- backend/internal/daemon/wiring_test.go | 4 +- backend/internal/domain/activity.go | 50 +++---------------- backend/internal/domain/session.go | 20 ++++---- backend/internal/domain/status.go | 6 +-- backend/internal/domain/status_test.go | 3 +- backend/internal/lifecycle/manager.go | 15 +++--- backend/internal/lifecycle/manager_test.go | 26 +--------- backend/internal/lifecycle/reactions.go | 2 +- backend/internal/lifecycle/runtime.go | 4 +- .../internal/observe/reaper/reaper_test.go | 2 +- .../internal/ports/runtime_observations.go | 1 - backend/internal/session/manager.go | 2 +- backend/internal/session/manager_test.go | 10 ++-- backend/internal/storage/sqlite/gen/models.go | 1 - .../storage/sqlite/gen/sessions.sql.go | 19 +++---- .../0002_remove_activity_source.sql | 11 ++++ .../storage/sqlite/queries/sessions.sql | 12 ++--- .../storage/sqlite/store/session_store.go | 10 +--- .../storage/sqlite/store/store_test.go | 4 +- backend/sqlc.yaml | 4 -- docs/architecture.md | 10 ++-- 22 files changed, 70 insertions(+), 148 deletions(-) create mode 100644 backend/internal/storage/sqlite/migrations/0002_remove_activity_source.sql diff --git a/backend/internal/cdc/cdc_test.go b/backend/internal/cdc/cdc_test.go index 14ad640ceb..6196120fdd 100644 --- a/backend/internal/cdc/cdc_test.go +++ b/backend/internal/cdc/cdc_test.go @@ -32,7 +32,7 @@ func seedSession(t *testing.T, s *sqlite.Store) domain.SessionRecord { } r, err := s.CreateSession(ctx, domain.SessionRecord{ ProjectID: "mer", Kind: domain.KindWorker, - Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, + Activity: domain.Activity{State: domain.ActivityActive, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now, }) if err != nil { diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index d743fcee9b..6d6dae0426 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -42,14 +42,14 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { } rec, err := store.CreateSession(ctx, domain.SessionRecord{ ProjectID: "mer", Kind: domain.KindWorker, - Activity: domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: time.Now(), Source: domain.SourceNone}, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}, }) if err != nil { t.Fatal(err) } // A real transition through the engine, which writes the row and fires the // activity_state/is_terminated CDC trigger. - if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{Valid: true, State: domain.ActivityActive, Timestamp: time.Now(), Source: domain.SourceNative}); err != nil { + if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{Valid: true, State: domain.ActivityActive, Timestamp: time.Now()}); err != nil { t.Fatal(err) } diff --git a/backend/internal/domain/activity.go b/backend/internal/domain/activity.go index c725a38cdf..468ee9e472 100644 --- a/backend/internal/domain/activity.go +++ b/backend/internal/domain/activity.go @@ -5,59 +5,23 @@ import "time" // ActivityState is how busy the agent is, derived from its output/JSONL. type ActivityState string -// Activity states. WaitingInput and Blocked are sticky (see IsSticky). +// Activity states. WaitingInput is sticky (see IsSticky). const ( ActivityActive ActivityState = "active" ActivityIdle ActivityState = "idle" ActivityWaitingInput ActivityState = "waiting_input" - ActivityBlocked ActivityState = "blocked" ActivityExited ActivityState = "exited" ) // IsSticky reports whether an activity state must NOT be aged/demoted by the // passage of time (a paused agent is still paused until a new signal says so). func (a ActivityState) IsSticky() bool { - return a == ActivityWaitingInput || a == ActivityBlocked + return a == ActivityWaitingInput } -// ActivitySource records where an activity reading came from, so a weaker -// source can't override a stronger one. -type ActivitySource string - -// Activity signal sources, strongest first. -const ( - SourceNative ActivitySource = "native" - SourceTerminal ActivitySource = "terminal" - SourceHook ActivitySource = "hook" - SourceRuntime ActivitySource = "runtime" - SourceNone ActivitySource = "none" -) - -// CanOverride reports whether a reading from source a may replace a current -// reading from source current. Unknown sources are treated as weakest. -func (a ActivitySource) CanOverride(current ActivitySource) bool { - return activitySourceRank(a) <= activitySourceRank(current) -} - -func activitySourceRank(s ActivitySource) int { - switch s { - case SourceNative: - return 0 - case SourceTerminal: - return 1 - case SourceHook: - return 2 - case SourceRuntime: - return 3 - default: - return 4 - } -} - -// ActivitySubstate is the persisted activity reading: the state, when it was -// last observed, and which source reported it. -type ActivitySubstate struct { - State ActivityState `json:"state"` - LastActivityAt time.Time `json:"lastActivityAt"` - Source ActivitySource `json:"source"` +// Activity captures the persisted activity reading: the state and when it was +// last observed. +type Activity struct { + State ActivityState `json:"state"` + LastActivityAt time.Time `json:"lastActivityAt"` } diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index 76e799fb29..2354072377 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -36,16 +36,16 @@ type SessionMetadata struct { // facts: identity, agent harness, activity_state, is_terminated, and operational // metadata. The user-facing Status is derived from these facts plus PR facts. type SessionRecord struct { - ID SessionID `json:"id"` - ProjectID ProjectID `json:"projectId"` - IssueID IssueID `json:"issueId,omitempty"` - Kind SessionKind `json:"kind"` - Harness AgentHarness `json:"harness,omitempty"` - Activity ActivitySubstate `json:"activity"` - IsTerminated bool `json:"isTerminated"` - Metadata SessionMetadata `json:"-"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID SessionID `json:"id"` + ProjectID ProjectID `json:"projectId"` + IssueID IssueID `json:"issueId,omitempty"` + Kind SessionKind `json:"kind"` + Harness AgentHarness `json:"harness,omitempty"` + Activity Activity `json:"activity"` + IsTerminated bool `json:"isTerminated"` + Metadata SessionMetadata `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // Session is the read-model returned across the API boundary: a SessionRecord diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index d02ddcb37e..ad88468555 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -16,7 +16,6 @@ const ( StatusMergeable SessionStatus = "mergeable" StatusMerged SessionStatus = "merged" StatusNeedsInput SessionStatus = "needs_input" - StatusStuck SessionStatus = "stuck" StatusIdle SessionStatus = "idle" StatusTerminated SessionStatus = "terminated" ) @@ -32,11 +31,8 @@ func DeriveStatus(rec SessionRecord, pr *PRFacts) SessionStatus { return StatusTerminated } - switch rec.Activity.State { - case ActivityWaitingInput: + if rec.Activity.State == ActivityWaitingInput { return StatusNeedsInput - case ActivityBlocked: - return StatusStuck } if pr != nil { diff --git a/backend/internal/domain/status_test.go b/backend/internal/domain/status_test.go index 7bd02dbf3b..075098687c 100644 --- a/backend/internal/domain/status_test.go +++ b/backend/internal/domain/status_test.go @@ -3,7 +3,7 @@ package domain import "testing" func rec(activity ActivityState, terminated bool) SessionRecord { - return SessionRecord{Activity: ActivitySubstate{State: activity}, IsTerminated: terminated} + return SessionRecord{Activity: Activity{State: activity}, IsTerminated: terminated} } func pr(facts PRFacts) *PRFacts { return &facts } @@ -18,7 +18,6 @@ func TestDeriveStatusFromSessionFactsAndPR(t *testing.T) { {"terminated", rec(ActivityExited, true), nil, StatusTerminated}, {"merged-pr", rec(ActivityIdle, true), pr(PRFacts{Merged: true}), StatusMerged}, {"needs-input", rec(ActivityWaitingInput, false), pr(PRFacts{CI: CIFailing}), StatusNeedsInput}, - {"blocked", rec(ActivityBlocked, false), pr(PRFacts{CI: CIFailing}), StatusStuck}, {"ci-failed", rec(ActivityIdle, false), pr(PRFacts{CI: CIFailing}), StatusCIFailed}, {"draft", rec(ActivityIdle, false), pr(PRFacts{Draft: true}), StatusDraft}, {"changes-requested", rec(ActivityIdle, false), pr(PRFacts{Review: ReviewChangesRequest}), StatusChangesRequested}, diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 03eee005d9..f76620fb36 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -65,7 +65,7 @@ func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.Session } next := cur next.IsTerminated = true - next.Activity = domain.ActivitySubstate{State: domain.ActivityExited, LastActivityAt: timeOr(f.ObservedAt, now), Source: domain.SourceRuntime} + next.Activity = domain.Activity{State: domain.ActivityExited, LastActivityAt: timeOr(f.ObservedAt, now)} return next, true }) } @@ -79,11 +79,8 @@ func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, if cur.IsTerminated { return cur, false } - if !s.Source.CanOverride(cur.Activity.Source) { - return cur, false - } next := cur - act := domain.ActivitySubstate{State: s.State, LastActivityAt: timeOr(s.Timestamp, now), Source: s.Source} + act := domain.Activity{State: s.State, LastActivityAt: timeOr(s.Timestamp, now)} if sameActivity(cur.Activity, act) { return cur, false } @@ -108,7 +105,7 @@ func (m *Manager) MarkSpawned(ctx context.Context, id domain.SessionID, metadata } now := m.clock() rec.IsTerminated = false - rec.Activity = domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: now, Source: domain.SourceRuntime} + rec.Activity = domain.Activity{State: domain.ActivityIdle, LastActivityAt: now} rec.Metadata = mergeMetadata(rec.Metadata, metadata) rec.UpdatedAt = now return m.store.UpdateSession(ctx, rec) @@ -121,13 +118,13 @@ func (m *Manager) MarkTerminated(ctx context.Context, id domain.SessionID) error return cur, false } cur.IsTerminated = true - cur.Activity = domain.ActivitySubstate{State: domain.ActivityExited, LastActivityAt: now, Source: domain.SourceRuntime} + cur.Activity = domain.Activity{State: domain.ActivityExited, LastActivityAt: now} return cur, true }) } -func sameActivity(a, b domain.ActivitySubstate) bool { - return a.State == b.State && a.Source == b.Source && a.LastActivityAt.Equal(b.LastActivityAt) +func sameActivity(a, b domain.Activity) bool { + return a.State == b.State && a.LastActivityAt.Equal(b.LastActivityAt) } func mergeMetadata(base, in domain.SessionMetadata) domain.SessionMetadata { diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index 19f3616c5f..3a0750850b 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -51,7 +51,7 @@ func newManager() (*Manager, *fakeStore, *fakeMessenger) { } func working(id domain.SessionID) domain.SessionRecord { - return domain.SessionRecord{ID: id, ProjectID: "mer", Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: time.Now(), Source: domain.SourceNative}} + return domain.SessionRecord{ID: id, ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive, LastActivityAt: time.Now()}} } func TestRuntimeObservation_InferredDeathSetsTerminated(t *testing.T) { @@ -92,30 +92,6 @@ func TestActivity_InvalidIsIgnored(t *testing.T) { } } -func TestActivity_WeakerSourceDoesNotOverrideStronger(t *testing.T) { - m, st, _ := newManager() - st.sessions["mer-1"] = working("mer-1") - before := st.sessions["mer-1"] - if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityIdle, Source: domain.SourceRuntime}); err != nil { - t.Fatal(err) - } - if st.sessions["mer-1"] != before { - t.Fatalf("weaker runtime signal should not override native activity, got %+v", st.sessions["mer-1"]) - } -} - -func TestActivity_StrongerSourceOverridesWeaker(t *testing.T) { - m, st, _ := newManager() - st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: time.Now(), Source: domain.SourceRuntime}} - if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityActive, Source: domain.SourceNative}); err != nil { - t.Fatal(err) - } - got := st.sessions["mer-1"].Activity - if got.State != domain.ActivityActive || got.Source != domain.SourceNative { - t.Fatalf("stronger native signal should override runtime, got %+v", got) - } -} - func TestMarkTerminated(t *testing.T) { m, st, _ := newManager() st.sessions["mer-1"] = working("mer-1") diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index 3f056c55cc..024bda21b6 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -39,7 +39,7 @@ func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o if err != nil || !ok { return err } - if rec.IsTerminated || rec.Activity.State == domain.ActivityBlocked || rec.Activity.State == domain.ActivityWaitingInput { + if rec.IsTerminated || rec.Activity.State == domain.ActivityWaitingInput { return nil } if o.CI == domain.CIFailing { diff --git a/backend/internal/lifecycle/runtime.go b/backend/internal/lifecycle/runtime.go index 58de7f5657..842f3ab47b 100644 --- a/backend/internal/lifecycle/runtime.go +++ b/backend/internal/lifecycle/runtime.go @@ -9,7 +9,7 @@ import ( const defaultRecentActivityWindow = 60 * time.Second -func hasRecentActivity(a domain.ActivitySubstate, now time.Time, window time.Duration) bool { +func hasRecentActivity(a domain.Activity, now time.Time, window time.Duration) bool { switch { case a.State == domain.ActivityExited: return false @@ -22,7 +22,7 @@ func hasRecentActivity(a domain.ActivitySubstate, now time.Time, window time.Dur } } -func runtimeClearlyDead(f ports.RuntimeFacts, activity domain.ActivitySubstate, now time.Time, window time.Duration) bool { +func runtimeClearlyDead(f ports.RuntimeFacts, activity domain.Activity, now time.Time, window time.Duration) bool { observedAt := timeOr(f.ObservedAt, now) return f.Probe == ports.ProbeDead && !hasRecentActivity(activity, observedAt, window) } diff --git a/backend/internal/observe/reaper/reaper_test.go b/backend/internal/observe/reaper/reaper_test.go index a2c8457837..f016e9e337 100644 --- a/backend/internal/observe/reaper/reaper_test.go +++ b/backend/internal/observe/reaper/reaper_test.go @@ -43,7 +43,7 @@ func (r fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) func probableSession(id domain.SessionID) domain.SessionRecord { return domain.SessionRecord{ ID: id, - Activity: domain.ActivitySubstate{State: domain.ActivityActive}, + Activity: domain.Activity{State: domain.ActivityActive}, Metadata: domain.SessionMetadata{RuntimeHandleID: "h1"}, } } diff --git a/backend/internal/ports/runtime_observations.go b/backend/internal/ports/runtime_observations.go index f81ffe67f1..fe548969a7 100644 --- a/backend/internal/ports/runtime_observations.go +++ b/backend/internal/ports/runtime_observations.go @@ -30,5 +30,4 @@ type ActivitySignal struct { Valid bool State domain.ActivityState Timestamp time.Time - Source domain.ActivitySource } diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index 82576dad41..ca4d0fa68e 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -288,7 +288,7 @@ func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { CreatedAt: now, UpdatedAt: now, Harness: cfg.Harness, - Activity: domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: now, Source: domain.SourceNone}, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, } } diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index 228fac89c0..f682a51af0 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -68,7 +68,7 @@ func (l *fakeLCM) MarkSpawned(_ context.Context, id domain.SessionID, metadata d l.completed++ rec := l.store.sessions[id] rec.IsTerminated = false - rec.Activity = domain.ActivitySubstate{State: domain.ActivityIdle, LastActivityAt: time.Now(), Source: domain.SourceRuntime} + rec.Activity = domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()} rec.Metadata = metadata l.store.sessions[id] = rec return nil @@ -76,7 +76,7 @@ func (l *fakeLCM) MarkSpawned(_ context.Context, id domain.SessionID, metadata d func (l *fakeLCM) MarkTerminated(_ context.Context, id domain.SessionID) error { rec := l.store.sessions[id] rec.IsTerminated = true - rec.Activity = domain.ActivitySubstate{State: domain.ActivityExited, LastActivityAt: time.Now(), Source: domain.SourceRuntime} + rec.Activity = domain.Activity{State: domain.ActivityExited, LastActivityAt: time.Now()} l.store.sessions[id] = rec return nil } @@ -134,10 +134,10 @@ func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) { return m, st, rt, ws } func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) { - st.sessions[id] = domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: meta, IsTerminated: true, Activity: domain.ActivitySubstate{State: domain.ActivityExited}} + st.sessions[id] = domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: meta, IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}} } func mkLive(id domain.SessionID) domain.SessionRecord { - return domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: domain.SessionMetadata{WorkspacePath: "/ws/" + string(id), RuntimeHandleID: "h1"}, Activity: domain.ActivitySubstate{State: domain.ActivityActive}} + return domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: domain.SessionMetadata{WorkspacePath: "/ws/" + string(id), RuntimeHandleID: "h1"}, Activity: domain.Activity{State: domain.ActivityActive}} } func TestSpawn_AssignsIDAndGoesIdle(t *testing.T) { @@ -185,7 +185,7 @@ func TestKill_TearsDownRuntimeAndWorkspace(t *testing.T) { } func TestKill_RefusesIncompleteHandle(t *testing.T) { m, st, _, _ := newManager() - st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.ActivitySubstate{State: domain.ActivityActive}} + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}} if _, err := m.Kill(ctx, "mer-1"); !errors.Is(err, ErrIncompleteHandle) { t.Fatalf("want ErrIncompleteHandle, got %v", err) } diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 720343e00e..e65add746a 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -71,7 +71,6 @@ type Session struct { Harness domain.AgentHarness ActivityState domain.ActivityState ActivityLastAt time.Time - ActivitySource domain.ActivitySource IsTerminated bool Branch string WorkspacePath string diff --git a/backend/internal/storage/sqlite/gen/sessions.sql.go b/backend/internal/storage/sqlite/gen/sessions.sql.go index 2acc891882..fc1fa82bb5 100644 --- a/backend/internal/storage/sqlite/gen/sessions.sql.go +++ b/backend/internal/storage/sqlite/gen/sessions.sql.go @@ -14,7 +14,7 @@ import ( const getSession = `-- name: GetSession :one SELECT id, project_id, num, issue_id, kind, harness, - activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at FROM sessions WHERE id = ? ` @@ -31,7 +31,6 @@ func (q *Queries) GetSession(ctx context.Context, id domain.SessionID) (Session, &i.Harness, &i.ActivityState, &i.ActivityLastAt, - &i.ActivitySource, &i.IsTerminated, &i.Branch, &i.WorkspacePath, @@ -47,10 +46,10 @@ func (q *Queries) GetSession(ctx context.Context, id domain.SessionID) (Session, const insertSession = `-- name: InsertSession :exec INSERT INTO sessions ( id, project_id, num, issue_id, kind, harness, - activity_state, activity_last_at, activity_source, is_terminated, + activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertSessionParams struct { @@ -62,7 +61,6 @@ type InsertSessionParams struct { Harness domain.AgentHarness ActivityState domain.ActivityState ActivityLastAt time.Time - ActivitySource domain.ActivitySource IsTerminated bool Branch string WorkspacePath string @@ -83,7 +81,6 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er arg.Harness, arg.ActivityState, arg.ActivityLastAt, - arg.ActivitySource, arg.IsTerminated, arg.Branch, arg.WorkspacePath, @@ -98,7 +95,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er const listAllSessions = `-- name: ListAllSessions :many SELECT id, project_id, num, issue_id, kind, harness, - activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at FROM sessions ORDER BY project_id, num ` @@ -121,7 +118,6 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { &i.Harness, &i.ActivityState, &i.ActivityLastAt, - &i.ActivitySource, &i.IsTerminated, &i.Branch, &i.WorkspacePath, @@ -146,7 +142,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { const listSessionsByProject = `-- name: ListSessionsByProject :many SELECT id, project_id, num, issue_id, kind, harness, - activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at FROM sessions WHERE project_id = ? ORDER BY num ` @@ -169,7 +165,6 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID domain.Pr &i.Harness, &i.ActivityState, &i.ActivityLastAt, - &i.ActivitySource, &i.IsTerminated, &i.Branch, &i.WorkspacePath, @@ -206,7 +201,7 @@ func (q *Queries) NextSessionNum(ctx context.Context, projectID domain.ProjectID const updateSession = `-- name: UpdateSession :exec UPDATE sessions SET issue_id = ?, kind = ?, harness = ?, - activity_state = ?, activity_last_at = ?, activity_source = ?, is_terminated = ?, + activity_state = ?, activity_last_at = ?, is_terminated = ?, branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?, updated_at = ? WHERE id = ? @@ -218,7 +213,6 @@ type UpdateSessionParams struct { Harness domain.AgentHarness ActivityState domain.ActivityState ActivityLastAt time.Time - ActivitySource domain.ActivitySource IsTerminated bool Branch string WorkspacePath string @@ -236,7 +230,6 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) er arg.Harness, arg.ActivityState, arg.ActivityLastAt, - arg.ActivitySource, arg.IsTerminated, arg.Branch, arg.WorkspacePath, diff --git a/backend/internal/storage/sqlite/migrations/0002_remove_activity_source.sql b/backend/internal/storage/sqlite/migrations/0002_remove_activity_source.sql new file mode 100644 index 0000000000..885f24e0d7 --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0002_remove_activity_source.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +UPDATE sessions SET activity_state = 'waiting_input' WHERE activity_state = 'blocked'; +ALTER TABLE sessions DROP COLUMN activity_source; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE sessions ADD COLUMN activity_source TEXT NOT NULL DEFAULT 'none' + CHECK (activity_source IN ('native', 'terminal', 'hook', 'runtime', 'none')); +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/sessions.sql b/backend/internal/storage/sqlite/queries/sessions.sql index 799718b8ae..cec6ad3668 100644 --- a/backend/internal/storage/sqlite/queries/sessions.sql +++ b/backend/internal/storage/sqlite/queries/sessions.sql @@ -4,34 +4,34 @@ SELECT COALESCE(MAX(num), 0) + 1 AS next FROM sessions WHERE project_id = ?; -- name: InsertSession :exec INSERT INTO sessions ( id, project_id, num, issue_id, kind, harness, - activity_state, activity_last_at, activity_source, is_terminated, + activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateSession :exec UPDATE sessions SET issue_id = ?, kind = ?, harness = ?, - activity_state = ?, activity_last_at = ?, activity_source = ?, is_terminated = ?, + activity_state = ?, activity_last_at = ?, is_terminated = ?, branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?, updated_at = ? WHERE id = ?; -- name: GetSession :one SELECT id, project_id, num, issue_id, kind, harness, - activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at FROM sessions WHERE id = ?; -- name: ListSessionsByProject :many SELECT id, project_id, num, issue_id, kind, harness, - activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at FROM sessions WHERE project_id = ? ORDER BY num; -- name: ListAllSessions :many SELECT id, project_id, num, issue_id, kind, harness, - activity_state, activity_last_at, activity_source, is_terminated, branch, workspace_path, + activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at FROM sessions ORDER BY project_id, num; diff --git a/backend/internal/storage/sqlite/store/session_store.go b/backend/internal/storage/sqlite/store/session_store.go index fefd7f3e47..7c8596ffd4 100644 --- a/backend/internal/storage/sqlite/store/session_store.go +++ b/backend/internal/storage/sqlite/store/session_store.go @@ -85,10 +85,9 @@ func rowToRecord(row gen.Session) domain.SessionRecord { IssueID: row.IssueID, Kind: row.Kind, Harness: row.Harness, - Activity: domain.ActivitySubstate{ + Activity: domain.Activity{ State: row.ActivityState, LastActivityAt: row.ActivityLastAt, - Source: row.ActivitySource, }, IsTerminated: row.IsTerminated, Metadata: domain.SessionMetadata{ @@ -114,7 +113,6 @@ func recordToInsert(rec domain.SessionRecord, num int64) gen.InsertSessionParams Harness: rec.Harness, ActivityState: activity.State, ActivityLastAt: activity.LastActivityAt, - ActivitySource: activity.Source, IsTerminated: rec.IsTerminated, Branch: rec.Metadata.Branch, WorkspacePath: rec.Metadata.WorkspacePath, @@ -135,7 +133,6 @@ func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams { Harness: rec.Harness, ActivityState: activity.State, ActivityLastAt: activity.LastActivityAt, - ActivitySource: activity.Source, IsTerminated: rec.IsTerminated, Branch: rec.Metadata.Branch, WorkspacePath: rec.Metadata.WorkspacePath, @@ -146,13 +143,10 @@ func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams { } } -func normalActivity(a domain.ActivitySubstate, fallback time.Time) domain.ActivitySubstate { +func normalActivity(a domain.Activity, fallback time.Time) domain.Activity { if a.State == "" { a.State = domain.ActivityIdle } - if a.Source == "" { - a.Source = domain.SourceNone - } if a.LastActivityAt.IsZero() { a.LastActivityAt = fallback } diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 669d15a91c..9befe1c2c8 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -37,7 +37,7 @@ func sampleRecord(project string) domain.SessionRecord { ProjectID: domain.ProjectID(project), Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, - Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, + Activity: domain.Activity{State: domain.ActivityActive, LastActivityAt: now}, Metadata: domain.SessionMetadata{Branch: "feat/x", WorkspacePath: "/ws"}, CreatedAt: now, UpdatedAt: now, @@ -108,7 +108,7 @@ func TestSessionUpdateActivityAndTermination(t *testing.T) { seedProject(t, s, "mer") r, _ := s.CreateSession(ctx, sampleRecord("mer")) - r.Activity = domain.ActivitySubstate{State: domain.ActivityWaitingInput, LastActivityAt: r.CreatedAt, Source: domain.SourceHook} + r.Activity = domain.Activity{State: domain.ActivityWaitingInput, LastActivityAt: r.CreatedAt} r.IsTerminated = true if err := s.UpdateSession(ctx, r); err != nil { t.Fatal(err) diff --git a/backend/sqlc.yaml b/backend/sqlc.yaml index 3614c4253b..81707b2096 100644 --- a/backend/sqlc.yaml +++ b/backend/sqlc.yaml @@ -84,7 +84,3 @@ sql: go_type: import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" type: "ActivityState" - - column: "sessions.activity_source" - go_type: - import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" - type: "ActivitySource" diff --git a/docs/architecture.md b/docs/architecture.md index fe2159bd18..8e768c55f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,8 +13,7 @@ OBSERVE external facts → UPDATE durable facts → DERIVE display status / ACT The durable session facts are: - `activity_state` — what the agent last reported or what the runtime observer - can safely conclude (`active`, `ready`, `idle`, `waiting_input`, `blocked`, - `exited`). + can safely conclude (`active`, `idle`, `waiting_input`, `exited`). - `is_terminated` — whether the session should be treated as over. - PR facts in the `pr`, `pr_checks`, and `pr_comment` tables. @@ -43,11 +42,10 @@ backend/internal/adapters Zellij/git-worktree/GitHub adapters 1. `is_terminated` → `terminated`, except merged PRs display `merged`. 2. `activity_state=waiting_input` → `needs_input`. -3. `activity_state=blocked` → `stuck`. -4. Open PR facts drive PR pipeline statuses: `ci_failed`, `draft`, +3. Open PR facts drive PR pipeline statuses: `ci_failed`, `draft`, `changes_requested`, `mergeable`, `approved`, `review_pending`, `pr_open`. -5. `activity_state=active` → `working`. -6. Everything else → `idle`. +4. `activity_state=active` → `working`. +5. Everything else → `idle`. ## Lifecycle manager From f9b08aada4ed6334d270b172142cb163be5b7265 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Mon, 1 Jun 2026 21:44:56 +0530 Subject: [PATCH 099/250] =?UTF-8?q?feat(scm):=20GitHub=20provider=20adapte?= =?UTF-8?q?r=20=E2=80=94=20Observe(prURL)=20=E2=86=92=20PRObservation=20(#?= =?UTF-8?q?69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(scm): GitHub provider adapter — Observe(prURL) → PRObservation A fresh GitHub SCM provider adapter under backend/internal/adapters/scm/github/ exposing one method: (*Provider).Observe(ctx, prURL) (ports.PRObservation, error) It performs a REST GET on /repos/{o}/{r}/pulls/{n} for the authoritative draft/merged/closed/head-SHA, one GraphQL query for the reviewDecision + mergeStateStatus + statusCheckRollup + unresolved review threads, and (only for failure-class CheckRuns) a REST GET on /actions/jobs/{job_id}/logs to splice the last 20 lines of the failed job into the observation. The package is the observation primitive; the polling loop, cadence selection, daemon wiring, persistence and webhook receiver are all intentionally out of scope (separate PRs / lanes). Closes #27 — this supersedes PR #28's attempt, which targeted types (domain.SCMProvider / SCMSnapshot / ports.SCMObserveRequest) that the PR #62 simplification refactor has since removed. The GraphQL queries and mergeability composition logic are credited to @whoisasx from PR #28's provider.go; the package was re-implemented against the current ports.PRObservation seam (post-#62) rather than rebased. Bot-author detection uses ONLY GitHub's typed signal (__typename "Bot" / User.Type "Bot"). The strings.Contains(login, "bot") fallback from PR #28 was intentionally dropped — aa-18's review flagged it as a false-positive magnet for logins like "robothon" / "lambot123". 46 table-driven tests against httptest.NewServer cover happy path, draft, merged, closed (not merged), CI passing/failing/pending, StatusContext legacy, log-tail extraction (and the best-effort log-fetch failure case), mergeability mergeable/conflicting/blocked (including ci-failing → blocked even when GitHub still says CLEAN — the load-bearing aa-18 contract)/unstable/unknown, review approved/changes-requested/required/none, bot-author filtering (including the robothon false-positive guard), unresolved-only threads, all-bots → empty Comments, ETag-304 cache hit, primary + secondary rate-limit (with errors.As → *RateLimitError), 401 → ErrAuthFailed, malformed JSON → Fetched:false, network error → Fetched:false, Authorization Bearer header injection, StaticTokenSource blank/whitespace rejection, GHTokenSource memoize + invalidate. Verification: - go build ./... clean - go vet ./... clean - gofmt -l backend/internal/adapters/scm/ clean - golangci-lint run ./... (v2.12, repo .golangci.yml) 0 issues - go test -race ./internal/adapters/scm/github/... 46/46 PASS References: - aa-18 review of PR #28: ~/.ao/agent-reports/aa-18.md - aa-26 tracker adapter (sibling Go-adapter pattern): #36 / agent-reports/aa-26.md Co-Authored-By: Claude Opus 4.7 * fix(scm): address greptile review on #69 Four fixes from the greptile review of PR #69: 1. CI rollup pagination (P1) — when GraphQL reports pageInfo.hasNextPage=true for the statusCheckRollup contexts, a visible "all passing" set could be hiding a failing context on the next page. ciSummaryFromGraphQL now degrades Passing / Pending / Unknown to CIUnknown in that case; a known CIFailing on the visible page is still safe and is NOT degraded. Also bumped the per-page limit from 50 to 100 (GraphQL's documented max for the contexts connection). Two new tests pin both branches. 2. Empty GraphQL inline fragment (P2) — dropped `... on User { }` from the reviewThreads author selection. The empty selection set was technically invalid GraphQL and a future API tightening could reject the query. __typename already tells us whether the actor is a Bot, so the fragment carried no information. 3. rest.MergeStateStatus dead-code (P2) — the field decoded from the non-existent REST `merge_state_status` was always empty, making the firstNonEmpty fallback dead code. Removed the field and switched the tiebreaker to rest.MergeableState (the actual REST field, upper- cased so the same switch covers both GraphQL and REST shapes). 4. Wrong Accept header on /actions/jobs/{id}/logs (P2) — GitHub's REST API validates the Accept header before issuing the 302 to the log blob; sending text/plain risks a 406. Switched to the canonical application/vnd.github+json; the redirected blob serves text/plain regardless. Verification: - go build ./... clean - go vet ./... clean - golangci-lint run ./... 0 issues - go test -race ./internal/adapters/scm/github/... 48 / 48 PASS Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- backend/internal/adapters/scm/github/auth.go | 139 ++ .../internal/adapters/scm/github/client.go | 436 +++++++ backend/internal/adapters/scm/github/doc.go | 121 ++ .../internal/adapters/scm/github/provider.go | 698 ++++++++++ .../adapters/scm/github/provider_test.go | 1122 +++++++++++++++++ 5 files changed, 2516 insertions(+) create mode 100644 backend/internal/adapters/scm/github/auth.go create mode 100644 backend/internal/adapters/scm/github/client.go create mode 100644 backend/internal/adapters/scm/github/doc.go create mode 100644 backend/internal/adapters/scm/github/provider.go create mode 100644 backend/internal/adapters/scm/github/provider_test.go diff --git a/backend/internal/adapters/scm/github/auth.go b/backend/internal/adapters/scm/github/auth.go new file mode 100644 index 0000000000..3349d7c335 --- /dev/null +++ b/backend/internal/adapters/scm/github/auth.go @@ -0,0 +1,139 @@ +package github + +import ( + "context" + "errors" + "os" + "os/exec" + "strings" + "sync" + "time" +) + +// TokenSource yields a GitHub bearer token on demand. Production wires this +// to EnvTokenSource or GHTokenSource; tests inject StaticTokenSource. +type TokenSource interface { + Token(ctx context.Context) (string, error) +} + +// tokenInvalidator is the optional capability of dropping a cached token so +// the next call re-fetches it. The Client invokes this whenever GitHub +// responds with an auth-class failure: the next request will pick up a +// rotated token without restarting the daemon. +type tokenInvalidator interface { + InvalidateToken() +} + +// ErrNoToken is returned when no token source could yield a non-empty token. +var ErrNoToken = errors.New("github scm: no token configured") + +// StaticTokenSource is a literal token, typically used in tests. +type StaticTokenSource string + +// Token returns the literal token, or ErrNoToken if it is blank. +func (s StaticTokenSource) Token(context.Context) (string, error) { + t := strings.TrimSpace(string(s)) + if t == "" { + return "", ErrNoToken + } + return t, nil +} + +// EnvTokenSource reads the first non-empty value from the listed env vars, +// falling back to GITHUB_TOKEN. Order matters: a project-scoped variable +// (AO_GITHUB_TOKEN) should win over the global default. +type EnvTokenSource struct { + EnvVars []string +} + +// Token returns the first non-empty env-var value found, or ErrNoToken. +func (s EnvTokenSource) Token(context.Context) (string, error) { + for _, name := range s.EnvVars { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return v, nil + } + } + if v := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); v != "" { + return v, nil + } + return "", ErrNoToken +} + +const defaultGHTokenCacheTTL = 5 * time.Minute + +// GHTokenSource shells out to `gh auth token` when env vars are not +// configured. It memoizes the result for TokenTTL so we don't fork-exec on +// every request, but the Client invalidates the cache on auth failures so a +// rotated token is picked up on the next call. Tests inject GH so the gh +// binary is never required. +type GHTokenSource struct { + // GH is the shell-out hook. Production leaves this nil and falls back + // to `exec.CommandContext("gh", "auth", "token")`; tests inject a + // fake to avoid touching the real binary. + GH func(ctx context.Context) (string, error) + // TokenTTL is how long a successful read is memoized. Zero means use + // defaultGHTokenCacheTTL. + TokenTTL time.Duration + // Clock allows tests to drive expiration. Zero means time.Now. + Clock func() time.Time + + mu sync.Mutex + token string + expiresAt time.Time +} + +// Token returns the cached token if still fresh, otherwise re-runs gh. +func (s *GHTokenSource) Token(ctx context.Context) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + if s.token != "" && now.Before(s.expiresAt) { + return s.token, nil + } + run := s.GH + if run == nil { + run = ghAuthToken + } + out, err := run(ctx) + if err != nil { + return "", err + } + token := strings.TrimSpace(out) + if token == "" { + return "", ErrNoToken + } + s.token = token + s.expiresAt = now.Add(s.ttl()) + return token, nil +} + +// InvalidateToken drops the memoized token so the next Token call shells +// out again. The Client calls this on 401/403-auth responses. +func (s *GHTokenSource) InvalidateToken() { + s.mu.Lock() + defer s.mu.Unlock() + s.token = "" + s.expiresAt = time.Time{} +} + +func (s *GHTokenSource) now() time.Time { + if s.Clock != nil { + return s.Clock() + } + return time.Now() +} + +func (s *GHTokenSource) ttl() time.Duration { + if s.TokenTTL > 0 { + return s.TokenTTL + } + return defaultGHTokenCacheTTL +} + +func ghAuthToken(ctx context.Context) (string, error) { + out, err := exec.CommandContext(ctx, "gh", "auth", "token").Output() + if err != nil { + return "", err + } + return string(out), nil +} diff --git a/backend/internal/adapters/scm/github/client.go b/backend/internal/adapters/scm/github/client.go new file mode 100644 index 0000000000..13897672f3 --- /dev/null +++ b/backend/internal/adapters/scm/github/client.go @@ -0,0 +1,436 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const ( + defaultRESTBaseURL = "https://api.github.com" + defaultGraphQLURL = "https://api.github.com/graphql" + defaultUserAgent = "ao-agent-orchestrator/scm-github" +) + +// Sentinel errors. Provider-level callers should match on these via +// errors.Is; the orchestrator's lifecycle code is intentionally insulated +// from raw HTTP status codes. +var ( + ErrNotFound = errors.New("github scm: not found") + ErrAuthFailed = errors.New("github scm: authentication failed") + ErrRateLimited = errors.New("github scm: rate limited") +) + +// RateLimitError carries the structured backoff hints from a rate-limit +// response. Callers that want to back off intelligently can extract +// ResetAt / RetryAfter via errors.As; callers that only need the category +// can use errors.Is(err, ErrRateLimited). +type RateLimitError struct { + ResetAt time.Time + RetryAfter time.Duration + Message string +} + +// Error formats the rate-limit error for logs. +func (e *RateLimitError) Error() string { + if e == nil { + return ErrRateLimited.Error() + } + if e.Message != "" { + return "github scm: rate limited: " + e.Message + } + return ErrRateLimited.Error() +} + +// Is lets errors.Is match a *RateLimitError against ErrRateLimited. +func (e *RateLimitError) Is(target error) bool { return target == ErrRateLimited } + +// ClientOptions configures a Client. Production code sets Token alone; +// tests inject HTTPClient and the URL fields to point at an httptest fake. +type ClientOptions struct { + HTTPClient *http.Client + Token TokenSource + RESTBase string + GraphQLURL string + UserAgent string +} + +// Client is the HTTP wrapper. It owns: +// - bearer-token injection (with cache invalidation on auth failures), +// - ETag cache for REST GETs (so the second observation of the same PR +// burns a free 304 instead of a fresh payload), and +// - sentinel-error classification so callers don't switch on status codes. +type Client struct { + http *http.Client + tokens TokenSource + restBase string + graphqlURL string + userAgent string + + mu sync.Mutex + etagOut map[string]string // key (method+path+query) -> last-seen ETag + bodyOut map[string][]byte // key -> last-seen body for 304 replay + cacheLRU []string // insertion-order keys for FIFO eviction +} + +// cacheMaxEntries caps the number of distinct (method,path,query) tuples +// the in-memory ETag cache will track. A single Provider observes one PR +// at a time today, but the follow-up poller will reuse one Provider for +// the whole daemon — without a cap, long-running daemons would grow this +// map forever. +const cacheMaxEntries = 512 + +// NewClient returns a Client. It is intentionally tolerant of nil +// dependencies: production passes a TokenSource; tests sometimes leave it +// nil and supply Bearer-less fakes. +func NewClient(opts ClientOptions) *Client { + c := &Client{ + http: opts.HTTPClient, + tokens: opts.Token, + restBase: opts.RESTBase, + graphqlURL: opts.GraphQLURL, + userAgent: opts.UserAgent, + etagOut: map[string]string{}, + bodyOut: map[string][]byte{}, + } + if c.http == nil { + c.http = &http.Client{Timeout: 30 * time.Second} + } + if c.restBase == "" { + c.restBase = defaultRESTBaseURL + } + if c.graphqlURL == "" { + c.graphqlURL = defaultGraphQLURL + } + if c.userAgent == "" { + c.userAgent = defaultUserAgent + } + return c +} + +// RESTResponse is what doREST returns to the Provider. NotModified=true +// means the cached body is being served; the byte slice is unchanged from +// the previous fresh fetch. +type RESTResponse struct { + StatusCode int + NotModified bool + ETag string + Body []byte +} + +// doREST performs one REST request with ETag-aware caching. The cache is +// scoped to the (method, path, query) tuple so repeated PR observations +// against the same endpoint replay from the cache while observations of a +// different PR don't share state. Only GET requests participate in the +// cache — mutating methods would mis-replay 304s as the previous payload. +func (c *Client) doREST(ctx context.Context, method, path string, q url.Values, body any) (RESTResponse, error) { + cacheable := method == http.MethodGet + cacheKey := method + " " + path + "?" + q.Encode() + var prevETag string + var prevBody []byte + if cacheable { + c.mu.Lock() + prevETag = c.etagOut[cacheKey] + prevBody = c.bodyOut[cacheKey] + c.mu.Unlock() + } + + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return RESTResponse{}, fmt.Errorf("github scm: encode %s %s body: %w", method, path, err) + } + rdr = bytes.NewReader(b) + } + + u, err := c.restURL(path, q) + if err != nil { + return RESTResponse{}, fmt.Errorf("github scm: build %s URL: %w", path, err) + } + req, err := http.NewRequestWithContext(ctx, method, u, rdr) + if err != nil { + return RESTResponse{}, fmt.Errorf("github scm: build %s %s request: %w", method, path, err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", c.userAgent) + if prevETag != "" { + req.Header.Set("If-None-Match", prevETag) + } + if err := c.authorize(ctx, req); err != nil { + return RESTResponse{}, err + } + + resp, err := c.http.Do(req) + if err != nil { + return RESTResponse{}, fmt.Errorf("github scm: %s %s: %w", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + + if cacheable && resp.StatusCode == http.StatusNotModified { + // Replay the cached body. Update the ETag if GitHub returned a + // fresher one — some endpoints rotate ETags on weak revalidation. + newETag := resp.Header.Get("ETag") + if newETag != "" && newETag != prevETag { + c.mu.Lock() + c.etagOut[cacheKey] = newETag + c.mu.Unlock() + } + return RESTResponse{StatusCode: resp.StatusCode, NotModified: true, ETag: newETag, Body: prevBody}, nil + } + + b, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return RESTResponse{}, fmt.Errorf("github scm: read %s body: %w", path, readErr) + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + etag := resp.Header.Get("ETag") + if cacheable && etag != "" { + // Defensive copy: GitHub's HTTP body is owned by net/http's + // buffer pool. Holding the raw slice in our cache would let a + // later caller mutate or alias the same backing array. + c.storeCacheEntry(cacheKey, etag, append([]byte(nil), b...)) + } + return RESTResponse{StatusCode: resp.StatusCode, ETag: etag, Body: b}, nil + } + + err = classifyError(resp, b) + if errors.Is(err, ErrAuthFailed) { + c.invalidateToken() + } + return RESTResponse{StatusCode: resp.StatusCode, Body: b}, err +} + +// doGraphQL posts one GraphQL request and returns the decoded data map +// (the "data" field). Top-level GraphQL errors are surfaced as Go errors +// classified by the same sentinels as REST. +func (c *Client) doGraphQL(ctx context.Context, query string, variables map[string]any) (map[string]any, error) { + payload := map[string]any{"query": query} + if variables != nil { + payload["variables"] = variables + } + b, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("github scm: encode graphql body: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL, bytes.NewReader(b)) + if err != nil { + return nil, fmt.Errorf("github scm: build graphql request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.userAgent) + if err := c.authorize(ctx, req); err != nil { + return nil, err + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("github scm: POST graphql: %w", err) + } + defer func() { _ = resp.Body.Close() }() + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, fmt.Errorf("github scm: read graphql body: %w", readErr) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + err := classifyError(resp, respBody) + if errors.Is(err, ErrAuthFailed) { + c.invalidateToken() + } + return nil, err + } + var decoded struct { + Data map[string]any `json:"data"` + Errors []struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"errors"` + } + if err := json.Unmarshal(respBody, &decoded); err != nil { + return nil, fmt.Errorf("github scm: decode graphql response: %w", err) + } + if len(decoded.Errors) > 0 { + msg := decoded.Errors[0].Message + low := strings.ToLower(msg) + switch { + case strings.Contains(low, "rate limit") || strings.Contains(low, "abuse"): + return decoded.Data, &RateLimitError{Message: msg} + case strings.Contains(low, "bad credentials") || strings.Contains(low, "credentials"): + c.invalidateToken() + return decoded.Data, fmt.Errorf("%w: %s", ErrAuthFailed, msg) + case strings.Contains(low, "could not resolve") || strings.Contains(low, "not found"): + return decoded.Data, fmt.Errorf("%w: %s", ErrNotFound, msg) + default: + return decoded.Data, fmt.Errorf("github scm: graphql error: %s", msg) + } + } + return decoded.Data, nil +} + +// fetchPlainText is a small REST helper used for the job-log endpoint, +// which returns text/plain rather than JSON. It does NOT participate in +// the ETag cache (logs are append-only and tiny enough that re-fetch is +// cheap; caching would just inflate memory for no win). +func (c *Client) fetchPlainText(ctx context.Context, path string) ([]byte, error) { + u, err := c.restURL(path, nil) + if err != nil { + return nil, fmt.Errorf("github scm: build %s URL: %w", path, err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, http.NoBody) + if err != nil { + return nil, fmt.Errorf("github scm: build %s request: %w", path, err) + } + // The /actions/jobs/{id}/logs endpoint validates the Accept header + // before issuing its 302 to the log blob; sending text/plain here + // gets a 406. The canonical Accept for the GitHub REST API is the + // vnd.github+json media type — the redirected blob serves the + // actual text/plain regardless of what we asked for. + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", c.userAgent) + if err := c.authorize(ctx, req); err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("github scm: GET %s: %w", path, err) + } + defer func() { _ = resp.Body.Close() }() + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, fmt.Errorf("github scm: read %s body: %w", path, readErr) + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return body, nil + } + return nil, classifyError(resp, body) +} + +// storeCacheEntry records one (ETag, body) pair under cacheKey and evicts +// the oldest entry once cacheMaxEntries is exceeded. FIFO is intentional: +// the access pattern is "one PR per poll cycle"; an LRU would just add +// bookkeeping without changing eviction order in practice. +func (c *Client) storeCacheEntry(cacheKey, etag string, body []byte) { + c.mu.Lock() + defer c.mu.Unlock() + if _, exists := c.etagOut[cacheKey]; !exists { + c.cacheLRU = append(c.cacheLRU, cacheKey) + } + c.etagOut[cacheKey] = etag + c.bodyOut[cacheKey] = body + for len(c.cacheLRU) > cacheMaxEntries { + evict := c.cacheLRU[0] + c.cacheLRU = c.cacheLRU[1:] + delete(c.etagOut, evict) + delete(c.bodyOut, evict) + } +} + +func (c *Client) authorize(ctx context.Context, req *http.Request) error { + if c.tokens == nil { + return nil + } + token, err := c.tokens.Token(ctx) + if err != nil { + return fmt.Errorf("%w: %w", ErrAuthFailed, err) + } + req.Header.Set("Authorization", "Bearer "+token) + return nil +} + +func (c *Client) invalidateToken() { + if inv, ok := c.tokens.(tokenInvalidator); ok { + inv.InvalidateToken() + } +} + +func (c *Client) restURL(path string, q url.Values) (string, error) { + base, err := url.Parse(c.restBase) + if err != nil { + return "", err + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + base.Path = strings.TrimSuffix(base.Path, "/") + path + if q != nil { + base.RawQuery = q.Encode() + } + return base.String(), nil +} + +func classifyError(resp *http.Response, body []byte) error { + msg := githubMessage(body) + switch resp.StatusCode { + case http.StatusNotFound: + return fmt.Errorf("%w: %s", ErrNotFound, msg) + case http.StatusTooManyRequests: + return rateLimited(resp, msg) + case http.StatusUnauthorized: + return fmt.Errorf("%w: %s", ErrAuthFailed, msg) + case http.StatusForbidden: + // GitHub returns 403 for primary rate-limit exhaustion, for + // secondary/abuse limits, and for genuine auth/permission failures. + // Disambiguate by signal: primary limit sets X-RateLimit-Remaining=0; + // secondary/abuse sets Retry-After (often without the Remaining + // header); either case mentions "rate limit" / "abuse" in the body. + // Everything else is an auth/permission failure. + if isRateLimited(resp, msg) { + return rateLimited(resp, msg) + } + return fmt.Errorf("%w: %s", ErrAuthFailed, msg) + } + return fmt.Errorf("github scm: %d %s", resp.StatusCode, msg) +} + +func isRateLimited(resp *http.Response, msg string) bool { + if rem := resp.Header.Get("X-RateLimit-Remaining"); rem != "" { + if n, err := strconv.Atoi(rem); err == nil && n == 0 { + return true + } + } + if resp.Header.Get("Retry-After") != "" { + return true + } + low := strings.ToLower(msg) + return strings.Contains(low, "rate limit") || strings.Contains(low, "abuse detection") || strings.Contains(low, "secondary rate") +} + +func rateLimited(resp *http.Response, msg string) error { + e := &RateLimitError{Message: msg} + if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" { + if sec, err := strconv.ParseInt(reset, 10, 64); err == nil && sec > 0 { + e.ResetAt = time.Unix(sec, 0) + } + } + if ra := resp.Header.Get("Retry-After"); ra != "" { + if sec, err := strconv.Atoi(ra); err == nil && sec >= 0 { + e.RetryAfter = time.Duration(sec) * time.Second + } + } + return e +} + +func githubMessage(body []byte) string { + var p struct { + Message string `json:"message"` + } + if json.Unmarshal(body, &p) == nil && p.Message != "" { + return p.Message + } + return strings.TrimSpace(string(body)) +} diff --git a/backend/internal/adapters/scm/github/doc.go b/backend/internal/adapters/scm/github/doc.go new file mode 100644 index 0000000000..8dee9a3467 --- /dev/null +++ b/backend/internal/adapters/scm/github/doc.go @@ -0,0 +1,121 @@ +// Package github observes GitHub pull requests for the PR Manager. +// +// The exported surface is one function: +// +// (*Provider).Observe(ctx, prURL) (ports.PRObservation, error) +// +// It performs a REST GET on /repos/{o}/{r}/pulls/{n} for the authoritative +// state booleans (draft / merged / closed / head SHA), one GraphQL query +// for the reviewDecision + mergeStateStatus + statusCheckRollup + review +// threads, and (only for CheckRuns that concluded failure-class) a REST +// GET on /repos/{o}/{r}/actions/jobs/{job_id}/logs to splice the last 20 +// lines of the failed job into the observation. +// +// The poller / cadence loop is intentionally NOT in this package — it is +// a follow-up PR. This adapter is the observation primitive that loop +// will call. +// +// # State mapping +// +// Each ports.PRObservation field is derived as follows: +// +// - Fetched: false if any required REST/GraphQL call fails; true +// only once all the calls have succeeded. Log-tail +// fetch failures are best-effort: the LogTail is +// stamped with a "" sentinel +// and the observation still surfaces as Fetched=true. +// +// - URL, Number: the URL the caller passed (validated) plus the +// number from REST pulls/{n}. +// +// - Draft: REST pulls/{n}.draft. +// +// - Merged: REST pulls/{n}.merged OR a non-null merged_at. +// +// - Closed: REST pulls/{n}.state == "closed" AND NOT Merged. +// (Closed and Merged are mutually exclusive.) +// +// - CI: derived from the latest commit's statusCheckRollup contexts +// (CheckRun + StatusContext). Failed if ANY context concluded in a +// failure class (failure / cancelled / timed_out / action_required / +// error). Pending if any context is still running / queued. +// Passing if all non-skipped contexts concluded SUCCESS / NEUTRAL. +// Unknown otherwise. Empty rollup falls back to the rollup-level +// "state" field. +// +// - Review: from GraphQL pullRequest.reviewDecision: +// +// | GraphQL | domain.ReviewDecision | +// |------------------------|-------------------------| +// | APPROVED | ReviewApproved | +// | CHANGES_REQUESTED | ReviewChangesRequest | +// | REVIEW_REQUIRED | ReviewRequired | +// | null / unknown | ReviewNone | +// +// - Mergeability: composed in priority order; the first rule that +// matches wins. The primary signal is the GraphQL pullRequest +// payload; the REST pulls/{n} response is consulted only as a +// tiebreaker when GraphQL is empty or has not yet been computed. +// Rules: +// (1) mergeStateStatus == DIRTY -> MergeConflicting +// (2) mergeStateStatus == BLOCKED -> MergeBlocked +// (3) mergeStateStatus == UNSTABLE -> MergeUnstable +// (4) GraphQL mergeable == CONFLICTING -> MergeConflicting +// (5) reviewDecision == changes_requested -> MergeBlocked +// (6) CI == failing -> MergeBlocked +// (7) REST mergeable_state pin — a TIE-BREAKER, not a terminal +// step: "dirty"->MergeConflicting, "blocked"->MergeBlocked, +// "unstable"->MergeUnstable, "clean"->MergeMergeable ONLY if +// GraphQL says MERGEABLE or REST mergeable bool is true +// (otherwise stays unknown — REST lags GraphQL). +// (8) mergeable == MERGEABLE AND mergeStateStatus == CLEAN +// -> MergeMergeable +// (9) otherwise -> MergeUnknown +// +// - Checks[]: one entry per rollup context. For CheckRun rows we use +// name + conclusion + detailsUrl + the head SHA as the CommitHash; +// for StatusContext rows we use context + state + targetUrl. LogTail +// is populated ONLY for failure-class CheckRun entries, by fetching +// /actions/jobs/{job_id}/logs and tailing to the last 20 lines. +// +// - Comments[]: one entry per unresolved review-thread comment. +// Resolved threads are skipped client-side (Resolved on the +// observation is therefore always false). Bot authors are detected +// via GitHub's __typename == "Bot" or User.Type == "Bot" and +// dropped — the legacy strings.Contains(login, "bot") fallback was +// intentionally NOT carried forward (it false-positives on logins +// like "robothon" / "lambot123"; aa-18's review of PR #28 flagged +// this). +// +// # Errors +// +// The Client classifies HTTP failures into three sentinels: +// +// - ErrNotFound — 404 (PR doesn't exist or token can't see it) +// - ErrAuthFailed — 401, or 403 without rate-limit signals +// - ErrRateLimited — 403 with X-RateLimit-Remaining=0, 403 with the +// secondary "abuse detection" body, or 429 +// (also returns *RateLimitError with ResetAt / +// RetryAfter — match via errors.As) +// +// All other transport failures (decode errors, network failures, GraphQL +// "errors" array) bubble up as wrapped errors with Fetched=false on the +// observation, so the PR Manager keeps the prior row rather than +// fabricating a closed/merged transition from a failed observation. +// +// # Caching +// +// The Client maintains an in-memory ETag cache per (method, path, query). +// On the second observation of the same PR the REST GET sends +// If-None-Match and replays the cached body on a 304 — GraphQL is always +// re-fetched because it doesn't expose ETag-based revalidation. +// +// # Out of scope (intentionally — these are different PRs / lanes) +// +// - The poller loop and cadence selection (issue #35). +// - Webhook ingestion (this package is polling-only). +// - Persistence (PR Manager owns the row mapping; see internal/pr). +// - Linear / GitLab providers (separate PRs). +// - Issue tracking (separate lane, see internal/adapters/tracker). +// - Comment-injection-into-session-context (Messenger lane, not SCM). +package github diff --git a/backend/internal/adapters/scm/github/provider.go b/backend/internal/adapters/scm/github/provider.go new file mode 100644 index 0000000000..b57babdb8b --- /dev/null +++ b/backend/internal/adapters/scm/github/provider.go @@ -0,0 +1,698 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "strconv" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// ciFailureLogTailLines is the number of trailing lines of a failed job's +// log we splice into the observation. 20 lines is enough to catch the +// usual "X tests failed" tail without bloating the per-PR row. +const ciFailureLogTailLines = 20 + +// ProviderOptions configures a Provider. Production code typically sets +// Token; tests inject a pre-built Client pointed at httptest. +type ProviderOptions struct { + Client *Client + HTTPClient *http.Client + Token TokenSource + RESTBase string + GraphQLURL string + UserAgent string +} + +// Provider observes one GitHub pull request and returns a normalized +// ports.PRObservation for the PR Manager to persist. There is no polling +// loop in v1 — the loop is a follow-up PR (#35); this adapter is the +// observation primitive that loop will call. +type Provider struct { + client *Client +} + +// NewProvider returns a Provider. If opts.Client is supplied it is used +// verbatim; otherwise a Client is built from the other options. When a +// Token source is supplied it is exercised once so missing credentials +// surface at daemon startup rather than at first observation. Tests that +// want an unauthenticated fake pass opts.Client directly. +func NewProvider(opts ProviderOptions) (*Provider, error) { + if opts.Client == nil && opts.Token != nil { + if _, err := opts.Token.Token(context.Background()); err != nil { + return nil, err + } + } + c := opts.Client + if c == nil { + c = NewClient(ClientOptions{ + HTTPClient: opts.HTTPClient, + Token: opts.Token, + RESTBase: opts.RESTBase, + GraphQLURL: opts.GraphQLURL, + UserAgent: opts.UserAgent, + }) + } + return &Provider{client: c}, nil +} + +// Observe fetches the current state of one PR by its github.com URL and +// returns a normalized ports.PRObservation. Any required network call +// failing yields Fetched=false (caller must not infer "PR closed" from a +// failed observation). +func (p *Provider) Observe(ctx context.Context, prURL string) (ports.PRObservation, error) { + out := ports.PRObservation{URL: prURL} + owner, repo, number, err := parsePRURL(prURL) + if err != nil { + return out, err + } + out.Number = number + + rest, err := p.fetchRESTPull(ctx, owner, repo, number) + if err != nil { + // Network/auth/rate-limit failures must surface as Fetched:false. + // Stable terminal states like 404 also surface that way — the PR + // Manager keeps the prior row rather than fabricating closed/merged. + return out, err + } + + out.Draft = rest.Draft + out.Merged = rest.Merged || (rest.MergedAt != "") + out.Closed = strings.EqualFold(rest.State, "closed") && !out.Merged + + gq, err := p.fetchGraphQL(ctx, owner, repo, number) + if err != nil { + return out, err + } + + out.CI = ciSummaryFromGraphQL(gq) + out.Review = reviewDecisionFromGraphQL(gq) + out.Mergeability = mergeabilityFromGraphQL(gq, rest, out.CI, out.Review) + out.Checks = checksFromGraphQL(gq, rest.Head.SHA) + out.Comments = commentsFromGraphQL(gq) + + // Log-tail enrichment is best-effort: a job-log fetch failure must not + // flip the observation to Fetched:false, because we already have the + // authoritative CI summary from GraphQL. Stamp a one-liner instead. + for i := range out.Checks { + if !isFailingCheckStatus(out.Checks[i].Status) { + continue + } + jobID := jobIDForCheck(gq, out.Checks[i].Name) + if jobID == 0 { + continue + } + log, fetchErr := p.fetchJobLogTail(ctx, owner, repo, jobID) + if fetchErr != nil { + out.Checks[i].LogTail = fmt.Sprintf("", scrubError(fetchErr)) + continue + } + out.Checks[i].LogTail = tailLines(log, ciFailureLogTailLines) + } + + out.Fetched = true + return out, nil +} + +// --------------------------------------------------------------------------- +// REST: pull payload +// --------------------------------------------------------------------------- + +type restPull struct { + State string `json:"state"` + Draft bool `json:"draft"` + Merged bool `json:"merged"` + MergedAt string `json:"merged_at"` + Head struct { + SHA string `json:"sha"` + } `json:"head"` + Mergeable *bool `json:"mergeable"` + MergeableState string `json:"mergeable_state"` +} + +func (p *Provider) fetchRESTPull(ctx context.Context, owner, repo string, number int) (restPull, error) { + resp, err := p.client.doREST(ctx, http.MethodGet, repoPath(owner, repo, "pulls", strconv.Itoa(number)), nil, nil) + if err != nil { + return restPull{}, err + } + if len(resp.Body) == 0 { + return restPull{}, errors.New("github scm: empty pull response") + } + var pull restPull + if err := json.Unmarshal(resp.Body, &pull); err != nil { + return restPull{}, fmt.Errorf("github scm: decode pull: %w", err) + } + return pull, nil +} + +// --------------------------------------------------------------------------- +// GraphQL: the heavy lift +// --------------------------------------------------------------------------- + +// graphQLCheckContextLimit caps how many statusCheckRollup contexts we +// request in one GraphQL hop. 100 is GitHub's documented per-page max +// for the contexts connection. When the rollup has MORE than this many +// contexts the response surfaces pageInfo.hasNextPage=true and +// ciSummaryFromGraphQL is conservative (see the "CIUnknown on +// hasNextPage when not already CIFailing" branch — a partial visible +// set could hide a failure, so we degrade the verdict rather than +// risk reporting a broken PR as passing). +const graphQLCheckContextLimit = 100 + +// prObservationQuery is the GraphQL query (derived from PR #28, credited +// to @whoisasx) that pulls everything we need in one round trip: +// - reviewDecision (APPROVED / CHANGES_REQUESTED / REVIEW_REQUIRED / null) +// - mergeable + mergeStateStatus (DIRTY / BLOCKED / UNSTABLE / CLEAN / ...) +// - latest commit's statusCheckRollup (CheckRuns + StatusContexts) so we +// can derive a CIState without an extra REST hop, and so that bot vs +// human is detected via __typename on review comments. +const prObservationQuery = `query($owner:String!,$repo:String!,$number:Int!){ + repository(owner:$owner,name:$repo){ + pullRequest(number:$number){ + number + url + state + isDraft + merged + closed + mergeable + mergeStateStatus + reviewDecision + headRefOid + commits(last:1){ nodes{ commit{ + oid + statusCheckRollup{ + state + contexts(first:CONTEXT_LIMIT){ + nodes{ + __typename + ... on CheckRun { name status conclusion detailsUrl url databaseId } + ... on StatusContext { context state targetUrl } + } + pageInfo{ hasNextPage } + } + } + } } } + reviewThreads(last:100){ nodes{ + id + isResolved + comments(first:100){ nodes{ + id + body + path + line + url + author{ login __typename } + } } + } } + } + } +}` + +func (p *Provider) fetchGraphQL(ctx context.Context, owner, repo string, number int) (map[string]any, error) { + q := strings.Replace(prObservationQuery, "CONTEXT_LIMIT", strconv.Itoa(graphQLCheckContextLimit), 1) + data, err := p.client.doGraphQL(ctx, q, map[string]any{"owner": owner, "repo": repo, "number": number}) + if err != nil { + return nil, err + } + repoData, _ := data["repository"].(map[string]any) + pr, _ := repoData["pullRequest"].(map[string]any) + if pr == nil { + return nil, fmt.Errorf("%w: pull request not found in graphql response", ErrNotFound) + } + return pr, nil +} + +// --------------------------------------------------------------------------- +// REST: per-job log tail +// --------------------------------------------------------------------------- + +func (p *Provider) fetchJobLogTail(ctx context.Context, owner, repo string, jobID int64) (string, error) { + logPath := repoPath(owner, repo, "actions", "jobs", strconv.FormatInt(jobID, 10), "logs") + body, err := p.client.fetchPlainText(ctx, logPath) + if err != nil { + return "", err + } + return string(body), nil +} + +// --------------------------------------------------------------------------- +// Projection helpers +// --------------------------------------------------------------------------- + +// ciSummaryFromGraphQL maps the per-PR status rollup onto domain.CIState. +// If ANY visible context concluded failure-class we return CIFailing. +// Otherwise any pending context wins over passing. An empty rollup is +// CIUnknown. When the rollup is paginated (pageInfo.hasNextPage=true) +// the verdict is conservative: a known failure is still safe — failures +// don't get un-failed by more pages — but passing/pending/unknown +// verdicts could hide a failing context on the next page, so we degrade +// them all to CIUnknown rather than risk reporting a broken PR as ready. +func ciSummaryFromGraphQL(pr map[string]any) domain.CIState { + roll := statusRollup(pr) + if roll == nil { + return domain.CIUnknown + } + contexts, _ := roll["contexts"].(map[string]any) + rawNodes := nodes(contexts["nodes"]) + if len(rawNodes) == 0 { + // GitHub returns a top-level "state" on the rollup even when the + // nodes list is empty (e.g. SUCCESS / FAILURE / PENDING). Honor it + // rather than returning CIUnknown for an otherwise-decided PR. + return mapRollupState(str(roll["state"])) + } + pending, passing := false, false + for _, n := range rawNodes { + st := checkStatusFromGraphQL(n) + switch st { + case domain.PRCheckFailed, domain.PRCheckCancelled: + return domain.CIFailing + case domain.PRCheckQueued, domain.PRCheckInProgress: + pending = true + case domain.PRCheckPassed: + passing = true + } + } + if pageInfoHasMore(contexts) { + return domain.CIUnknown + } + switch { + case pending: + return domain.CIPending + case passing: + return domain.CIPassing + default: + return domain.CIUnknown + } +} + +// pageInfoHasMore reports whether the rollup contexts have a next page +// the current request didn't fetch. We treat a missing pageInfo block +// as "no more" (older API shapes that don't expose pagination simply +// return everything in one page). +func pageInfoHasMore(contexts map[string]any) bool { + pi, ok := contexts["pageInfo"].(map[string]any) + if !ok { + return false + } + return boolv(pi["hasNextPage"]) +} + +func mapRollupState(s string) domain.CIState { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "SUCCESS": + return domain.CIPassing + case "FAILURE", "ERROR": + return domain.CIFailing + case "PENDING", "EXPECTED": + return domain.CIPending + default: + return domain.CIUnknown + } +} + +// reviewDecisionFromGraphQL normalizes the GraphQL reviewDecision enum +// onto the domain vocabulary. Re-implemented inline because the helper +// referenced in the task brief lived against types that no longer exist. +func reviewDecisionFromGraphQL(pr map[string]any) domain.ReviewDecision { + switch strings.ToUpper(strings.TrimSpace(str(pr["reviewDecision"]))) { + case "APPROVED": + return domain.ReviewApproved + case "CHANGES_REQUESTED": + return domain.ReviewChangesRequest + case "REVIEW_REQUIRED": + return domain.ReviewRequired + default: + return domain.ReviewNone + } +} + +// mergeabilityFromGraphQL composes the merge verdict from three signals: +// the REST mergeable/rebaseable booleans, the GraphQL mergeStateStatus, +// and the already-derived CIState + ReviewDecision. The rules follow the +// spec table in doc.go. +func mergeabilityFromGraphQL(pr map[string]any, rest restPull, ci domain.CIState, review domain.ReviewDecision) domain.Mergeability { + // REST's mergeable_state is the tiebreaker: GraphQL's + // mergeStateStatus enum (DIRTY / BLOCKED / UNSTABLE / CLEAN / + // UNKNOWN) is the primary; if it is empty we fall back to the + // REST string (lowercase: "dirty" / "blocked" / "unstable" / + // "clean" / "behind" / "unknown") uppercased so the same switch + // covers both shapes. The REST API does NOT expose a + // `merge_state_status` field — earlier revs of this code chased + // that ghost; we use mergeable_state instead. + state := strings.ToUpper(strings.TrimSpace(firstNonEmpty(str(pr["mergeStateStatus"]), rest.MergeableState))) + rawMergeable := strings.ToUpper(strings.TrimSpace(str(pr["mergeable"]))) + + switch state { + case "DIRTY": + return domain.MergeConflicting + case "BLOCKED": + return domain.MergeBlocked + case "UNSTABLE": + return domain.MergeUnstable + } + if rawMergeable == "CONFLICTING" { + return domain.MergeConflicting + } + + if review == domain.ReviewChangesRequest { + return domain.MergeBlocked + } + if ci == domain.CIFailing { + return domain.MergeBlocked + } + + // REST's mergeable_state ("clean" / "blocked" / "behind" / "dirty" / "unstable" + // / "draft" / "unknown") backs up the GraphQL view when GitHub hasn't + // computed the rollup yet. + switch strings.ToLower(strings.TrimSpace(rest.MergeableState)) { + case "clean": + if rawMergeable == "MERGEABLE" || (rest.Mergeable != nil && *rest.Mergeable) { + return domain.MergeMergeable + } + case "dirty": + return domain.MergeConflicting + case "blocked": + return domain.MergeBlocked + case "unstable": + return domain.MergeUnstable + } + + if rawMergeable == "MERGEABLE" && state == "CLEAN" { + return domain.MergeMergeable + } + return domain.MergeUnknown +} + +// checksFromGraphQL projects each context node into a PRCheckObservation. +// StatusContext (commit-status) and CheckRun (Actions) are both flattened +// into the same slice because downstream consumers don't distinguish. +func checksFromGraphQL(pr map[string]any, headSHA string) []ports.PRCheckObservation { + roll := statusRollup(pr) + contexts, _ := roll["contexts"].(map[string]any) + rawNodes := nodes(contexts["nodes"]) + if len(rawNodes) == 0 { + return nil + } + out := make([]ports.PRCheckObservation, 0, len(rawNodes)) + for _, n := range rawNodes { + typ := str(n["__typename"]) + var name, urlOut string + switch typ { + case "CheckRun": + name = str(n["name"]) + urlOut = firstNonEmpty(str(n["detailsUrl"]), str(n["url"])) + case "StatusContext": + name = str(n["context"]) + urlOut = str(n["targetUrl"]) + default: + continue + } + if name == "" { + continue + } + out = append(out, ports.PRCheckObservation{ + Name: name, + CommitHash: headSHA, + Status: checkStatusFromGraphQL(n), + URL: urlOut, + }) + } + return out +} + +// commentsFromGraphQL flattens unresolved review threads into one comment +// per node, dropping bot authors entirely (the spec keeps Resolved=false +// always since we filter resolved threads out client-side). +func commentsFromGraphQL(pr map[string]any) []ports.PRCommentObservation { + threads, _ := pr["reviewThreads"].(map[string]any) + rawNodes := nodes(threads["nodes"]) + if len(rawNodes) == 0 { + return nil + } + var out []ports.PRCommentObservation + for _, th := range rawNodes { + if boolv(th["isResolved"]) { + continue + } + comments, _ := th["comments"].(map[string]any) + for _, cn := range nodes(comments["nodes"]) { + author, _ := cn["author"].(map[string]any) + if isBotAuthor(author) { + continue + } + out = append(out, ports.PRCommentObservation{ + ID: str(cn["id"]), + Author: str(author["login"]), + File: str(cn["path"]), + Line: int(num(cn["line"])), + Body: str(cn["body"]), + Resolved: false, + }) + } + } + return out +} + +// isBotAuthor uses ONLY GitHub's typed signal (__typename or User.Type +// === "Bot"). The strings.Contains(login, "bot") fallback from PR #28 +// was deliberately dropped — aa-18 flagged it as a false-positive +// magnet (logins like "robothon", "lambot123" tripped it). +func isBotAuthor(author map[string]any) bool { + if strings.EqualFold(str(author["__typename"]), "Bot") { + return true + } + if strings.EqualFold(str(author["type"]), "Bot") { + return true + } + return false +} + +// jobIDForCheck looks up the Actions job ID for a check by name, so we +// can call /actions/jobs/{job_id}/logs. StatusContext rows have no job +// ID (they're commit statuses, not Actions runs); those return 0 and +// the log fetch is skipped for them. +func jobIDForCheck(pr map[string]any, name string) int64 { + roll := statusRollup(pr) + contexts, _ := roll["contexts"].(map[string]any) + for _, n := range nodes(contexts["nodes"]) { + if str(n["__typename"]) != "CheckRun" { + continue + } + if str(n["name"]) != name { + continue + } + return int64(num(n["databaseId"])) + } + return 0 +} + +// statusRollup extracts the commits[0].commit.statusCheckRollup blob +// from the GraphQL pullRequest payload. Nil when the PR has no commits +// or GitHub hasn't computed the rollup yet. +func statusRollup(pr map[string]any) map[string]any { + commits, _ := pr["commits"].(map[string]any) + for _, n := range nodes(commits["nodes"]) { + commit, _ := n["commit"].(map[string]any) + roll, _ := commit["statusCheckRollup"].(map[string]any) + if roll != nil { + return roll + } + } + return nil +} + +// checkStatusFromGraphQL maps the (status, conclusion) tuple of one node +// onto the domain enum. Failure-class conclusions always win — pending +// status with a final conclusion of "failure" is still a failed check. +func checkStatusFromGraphQL(n map[string]any) domain.PRCheckStatus { + typ := str(n["__typename"]) + if typ == "StatusContext" { + switch strings.ToUpper(strings.TrimSpace(str(n["state"]))) { + case "SUCCESS": + return domain.PRCheckPassed + case "FAILURE", "ERROR": + return domain.PRCheckFailed + case "PENDING", "EXPECTED": + return domain.PRCheckInProgress + default: + return domain.PRCheckUnknown + } + } + conclusion := strings.ToUpper(strings.TrimSpace(str(n["conclusion"]))) + status := strings.ToUpper(strings.TrimSpace(str(n["status"]))) + switch conclusion { + case "SUCCESS", "NEUTRAL": + return domain.PRCheckPassed + case "FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE": + return domain.PRCheckFailed + case "CANCELLED": + return domain.PRCheckCancelled + case "SKIPPED", "STALE": + return domain.PRCheckSkipped + } + switch status { + case "QUEUED", "PENDING", "REQUESTED", "WAITING": + return domain.PRCheckQueued + case "IN_PROGRESS": + return domain.PRCheckInProgress + case "COMPLETED": + // Completed without a conclusion is unusual but reachable — treat + // it as unknown so the caller does not over-trust an absent state. + return domain.PRCheckUnknown + } + return domain.PRCheckUnknown +} + +func isFailingCheckStatus(s domain.PRCheckStatus) bool { + return s == domain.PRCheckFailed || s == domain.PRCheckCancelled +} + +// --------------------------------------------------------------------------- +// URL + path helpers +// --------------------------------------------------------------------------- + +// parsePRURL accepts both the canonical github.com web URL and the API +// pulls URL. Returns owner, repo, number or an error wrapping ErrNotFound +// for shapes we don't recognise (so the caller surfaces them like any +// other "PR isn't on GitHub" outcome). +func parsePRURL(prURL string) (string, string, int, error) { + if prURL == "" { + return "", "", 0, fmt.Errorf("%w: empty PR url", ErrNotFound) + } + u, err := url.Parse(prURL) + if err != nil { + return "", "", 0, fmt.Errorf("%w: parse url: %w", ErrNotFound, err) + } + host := strings.ToLower(u.Host) + // Accept github.com (web) and api.github.com (REST/GraphQL). GitHub + // Enterprise hosts must end in .github.com or .ghe.io (GitHub's own + // dedicated TLDs); anything else looks like a bad URL or a different + // SCM and is rejected. + switch { + case host == "": + // Allow path-only URLs (parsePRURL is also exercised via API + // paths without a host in some tests). + case host == "github.com", host == "www.github.com", host == "api.github.com": + // canonical + case strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".ghe.io"): + // enterprise + default: + return "", "", 0, fmt.Errorf("%w: host %q is not a github host", ErrNotFound, host) + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + // Web form: /owner/repo/pull/123 + if len(parts) >= 4 && (parts[2] == "pull" || parts[2] == "pulls") { + owner, repo := parts[0], parts[1] + n, err := strconv.Atoi(parts[3]) + if err != nil || n <= 0 { + return "", "", 0, fmt.Errorf("%w: bad PR number %q", ErrNotFound, parts[3]) + } + return owner, repo, n, nil + } + // API form: /repos/owner/repo/pulls/123 + if len(parts) >= 5 && parts[0] == "repos" && parts[3] == "pulls" { + owner, repo := parts[1], parts[2] + n, err := strconv.Atoi(parts[4]) + if err != nil || n <= 0 { + return "", "", 0, fmt.Errorf("%w: bad PR number %q", ErrNotFound, parts[4]) + } + return owner, repo, n, nil + } + return "", "", 0, fmt.Errorf("%w: not a github PR url: %s", ErrNotFound, prURL) +} + +func repoPath(owner, repo string, elems ...string) string { + all := append([]string{"repos", owner, repo}, elems...) + for i := range all { + all[i] = url.PathEscape(all[i]) + } + return "/" + path.Join(all...) +} + +// --------------------------------------------------------------------------- +// Small JSON-ish accessors +// --------------------------------------------------------------------------- + +func nodes(v any) []map[string]any { + a, ok := v.([]any) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(a)) + for _, item := range a { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +func str(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func boolv(v any) bool { + if b, ok := v.(bool); ok { + return b + } + return false +} + +func num(v any) float64 { + switch t := v.(type) { + case float64: + return t + case int: + return float64(t) + case int64: + return float64(t) + case json.Number: + f, _ := t.Float64() + return f + default: + return 0 + } +} + +func firstNonEmpty(a, b string) string { + if strings.TrimSpace(a) != "" { + return a + } + return b +} + +func tailLines(s string, n int) string { + s = strings.ReplaceAll(strings.TrimSpace(s), "\r\n", "\n") + if s == "" { + return "" + } + lines := strings.Split(s, "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} + +// scrubError keeps the error message single-line so the LogTail field +// stays a tidy one-liner instead of leaking multi-line API payloads +// into the PR row. +func scrubError(err error) string { + if err == nil { + return "" + } + msg := err.Error() + msg = strings.ReplaceAll(msg, "\n", " ") + msg = strings.ReplaceAll(msg, "\r", " ") + return strings.TrimSpace(msg) +} diff --git a/backend/internal/adapters/scm/github/provider_test.go b/backend/internal/adapters/scm/github/provider_test.go new file mode 100644 index 0000000000..eb407bddf7 --- /dev/null +++ b/backend/internal/adapters/scm/github/provider_test.go @@ -0,0 +1,1122 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// --------------------------------------------------------------------------- +// Test scaffolding: programmable httptest.Server with route-based dispatch. +// Tests register handlers per "METHOD path" key; unmatched requests fail +// loudly so an accidental extra call surfaces immediately. +// --------------------------------------------------------------------------- + +type recordedReq struct { + Method string + Path string + Header http.Header + Body string +} + +type fakeGH struct { + t *testing.T + server *httptest.Server + mu sync.Mutex + requests []recordedReq + handlers map[string]http.HandlerFunc +} + +func newFakeGH(t *testing.T) *fakeGH { + t.Helper() + f := &fakeGH{t: t, handlers: map[string]http.HandlerFunc{}} + f.server = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.server.Close) + return f +} + +// on registers a handler for one METHOD + path tuple. Path is taken +// verbatim (no query string). +func (f *fakeGH) on(method, path string, h http.HandlerFunc) { + f.mu.Lock() + defer f.mu.Unlock() + f.handlers[method+" "+path] = h +} + +func (f *fakeGH) serve(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + hdrCopy := r.Header.Clone() + f.mu.Lock() + f.requests = append(f.requests, recordedReq{Method: r.Method, Path: r.URL.Path, Header: hdrCopy, Body: string(body)}) + h, ok := f.handlers[r.Method+" "+r.URL.Path] + f.mu.Unlock() + if !ok { + f.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.Error(w, "no handler", http.StatusNotImplemented) + return + } + r.Body = io.NopCloser(strings.NewReader(string(body))) + h(w, r) +} + +func (f *fakeGH) calls() []recordedReq { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]recordedReq, len(f.requests)) + copy(out, f.requests) + return out +} + +func (f *fakeGH) callsTo(method, path string) int { + n := 0 + for _, r := range f.calls() { + if r.Method == method && r.Path == path { + n++ + } + } + return n +} + +// newProviderForTest builds a Provider that talks only to the fake. +func newProviderForTest(t *testing.T, f *fakeGH) *Provider { + t.Helper() + p, err := NewProvider(ProviderOptions{ + Token: StaticTokenSource("tkn-test"), + HTTPClient: f.server.Client(), + RESTBase: f.server.URL, + GraphQLURL: f.server.URL + "/graphql", + UserAgent: "ao-scm-test", + }) + if err != nil { + t.Fatalf("NewProvider: %v", err) + } + return p +} + +func ctx() context.Context { return context.Background() } + +// --------------------------------------------------------------------------- +// Fixture builders. Each test composes a REST pull + GraphQL response so +// it can pin the exact shape it cares about without sharing global state +// with other tests. +// --------------------------------------------------------------------------- + +type prFixture struct { + owner, repo string + number int + rest map[string]any + graphql map[string]any + jobLogs map[int64]string // job_id -> log body +} + +func basePRFixture() *prFixture { + return &prFixture{ + owner: "octocat", + repo: "hello", + number: 42, + rest: map[string]any{ + "number": 42, + "title": "Found a bug", + "state": "open", + "draft": false, + "merged": false, + "merged_at": nil, + "html_url": "https://github.com/octocat/hello/pull/42", + "head": map[string]any{"ref": "feat/x", "sha": "deadbeef"}, + "base": map[string]any{"ref": "main"}, + "mergeable": true, + "rebaseable": true, + "mergeable_state": "clean", + "merge_state_status": "CLEAN", + }, + graphql: map[string]any{ + "data": map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "number": 42, + "url": "https://github.com/octocat/hello/pull/42", + "state": "OPEN", + "isDraft": false, + "merged": false, + "closed": false, + "mergeable": "MERGEABLE", + "mergeStateStatus": "CLEAN", + "reviewDecision": "APPROVED", + "headRefOid": "deadbeef", + "commits": map[string]any{"nodes": []any{ + map[string]any{"commit": map[string]any{ + "oid": "deadbeef", + "statusCheckRollup": map[string]any{ + "state": "SUCCESS", + "contexts": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "CheckRun", + "name": "build", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/octocat/hello/runs/9001", + "databaseId": float64(9001), + }, + }, + "pageInfo": map[string]any{"hasNextPage": false}, + }, + }, + }}, + }}, + "reviewThreads": map[string]any{"nodes": []any{}}, + }, + }, + }, + }, + } +} + +// install wires REST + GraphQL handlers onto the fake. +func (f *prFixture) install(t *testing.T, fake *fakeGH) { + restPath := "/repos/" + f.owner + "/" + f.repo + "/pulls/" + strconv.Itoa(f.number) + fake.on(http.MethodGet, restPath, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `W/"v1"`) + _ = json.NewEncoder(w).Encode(f.rest) + }) + fake.on(http.MethodPost, "/graphql", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(f.graphql) + }) + for jobID, body := range f.jobLogs { + fake.on(http.MethodGet, "/repos/"+f.owner+"/"+f.repo+"/actions/jobs/"+strconv.FormatInt(jobID, 10)+"/logs", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(body)) + }) + } +} + +// prData mutates the nested GraphQL pullRequest map. +func (f *prFixture) prData(mut func(pr map[string]any)) *prFixture { + repoData := f.graphql["data"].(map[string]any)["repository"].(map[string]any) + pr := repoData["pullRequest"].(map[string]any) + mut(pr) + return f +} + +func (f *prFixture) prURL() string { + return "https://github.com/" + f.owner + "/" + f.repo + "/pull/" + strconv.Itoa(f.number) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +func TestParsePRURL(t *testing.T) { + cases := []struct { + name string + url string + wantOwner string + wantRepo string + wantNumber int + wantErr bool + }{ + {"web url", "https://github.com/o/r/pull/42", "o", "r", 42, false}, + {"api url", "https://api.github.com/repos/o/r/pulls/42", "o", "r", 42, false}, + {"trailing slash", "https://github.com/o/r/pull/42/", "o", "r", 42, false}, + {"empty", "", "", "", 0, true}, + {"not github", "https://example.com/o/r/pull/1", "", "", 0, true}, + {"bad number", "https://github.com/o/r/pull/abc", "", "", 0, true}, + {"zero", "https://github.com/o/r/pull/0", "", "", 0, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + o, r, n, err := parsePRURL(tc.url) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got %s/%s#%d", o, r, n) + } + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want wraps ErrNotFound", err) + } + return + } + if err != nil { + t.Fatalf("parse: %v", err) + } + if o != tc.wantOwner || r != tc.wantRepo || n != tc.wantNumber { + t.Fatalf("got %s/%s#%d, want %s/%s#%d", o, r, n, tc.wantOwner, tc.wantRepo, tc.wantNumber) + } + }) + } +} + +func TestObserve_HappyPath(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.install(t, f) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if !obs.Fetched { + t.Fatalf("Fetched = false; want true") + } + if obs.URL != fx.prURL() { + t.Errorf("URL = %q, want %q", obs.URL, fx.prURL()) + } + if obs.Number != 42 { + t.Errorf("Number = %d, want 42", obs.Number) + } + if obs.Draft || obs.Merged || obs.Closed { + t.Errorf("Draft/Merged/Closed = %v/%v/%v, want all false", obs.Draft, obs.Merged, obs.Closed) + } + if obs.CI != domain.CIPassing { + t.Errorf("CI = %q, want passing", obs.CI) + } + if obs.Review != domain.ReviewApproved { + t.Errorf("Review = %q, want approved", obs.Review) + } + if obs.Mergeability != domain.MergeMergeable { + t.Errorf("Mergeability = %q, want mergeable", obs.Mergeability) + } + if len(obs.Checks) != 1 { + t.Fatalf("Checks = %#v; want 1 entry", obs.Checks) + } + if obs.Checks[0].Status != domain.PRCheckPassed { + t.Errorf("Checks[0].Status = %q, want passed", obs.Checks[0].Status) + } + if obs.Checks[0].LogTail != "" { + t.Errorf("Checks[0].LogTail = %q; want empty on success", obs.Checks[0].LogTail) + } + if obs.Checks[0].CommitHash != "deadbeef" { + t.Errorf("Checks[0].CommitHash = %q; want deadbeef", obs.Checks[0].CommitHash) + } + if len(obs.Comments) != 0 { + t.Errorf("Comments = %#v; want empty", obs.Comments) + } +} + +func TestObserve_DraftPR(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.rest["draft"] = true + fx.prData(func(pr map[string]any) { pr["isDraft"] = true }) + fx.install(t, f) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if !obs.Draft { + t.Errorf("Draft = false; want true") + } +} + +func TestObserve_MergedPR(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.rest["state"] = "closed" + fx.rest["merged"] = true + fx.rest["merged_at"] = "2026-05-30T12:00:00Z" + fx.prData(func(pr map[string]any) { + pr["state"] = "MERGED" + pr["merged"] = true + pr["closed"] = true + }) + fx.install(t, f) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if !obs.Merged { + t.Errorf("Merged = false; want true") + } + if obs.Closed { + t.Errorf("Closed = true; want false (merged is mutually exclusive)") + } +} + +func TestObserve_ClosedNotMerged(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.rest["state"] = "closed" + fx.rest["merged"] = false + fx.rest["merged_at"] = nil + fx.prData(func(pr map[string]any) { + pr["state"] = "CLOSED" + pr["closed"] = true + }) + fx.install(t, f) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if !obs.Closed { + t.Errorf("Closed = false; want true") + } + if obs.Merged { + t.Errorf("Merged = true; want false") + } +} + +func TestObserve_CIStates(t *testing.T) { + cases := []struct { + name string + nodes []any + wantCI domain.CIState + wantHead domain.PRCheckStatus + }{ + { + name: "passing", + nodes: []any{ + map[string]any{"__typename": "CheckRun", "name": "build", "status": "COMPLETED", "conclusion": "SUCCESS"}, + }, + wantCI: domain.CIPassing, + wantHead: domain.PRCheckPassed, + }, + { + name: "failing wins over passing", + nodes: []any{ + map[string]any{"__typename": "CheckRun", "name": "build", "status": "COMPLETED", "conclusion": "SUCCESS"}, + map[string]any{"__typename": "CheckRun", "name": "lint", "status": "COMPLETED", "conclusion": "FAILURE"}, + }, + wantCI: domain.CIFailing, + }, + { + name: "pending blocks passing-only", + nodes: []any{ + map[string]any{"__typename": "CheckRun", "name": "build", "status": "COMPLETED", "conclusion": "SUCCESS"}, + map[string]any{"__typename": "CheckRun", "name": "test", "status": "IN_PROGRESS"}, + }, + wantCI: domain.CIPending, + }, + { + name: "cancelled is failing", + nodes: []any{ + map[string]any{"__typename": "CheckRun", "name": "deploy", "status": "COMPLETED", "conclusion": "CANCELLED"}, + }, + wantCI: domain.CIFailing, + }, + { + name: "legacy statuscontext failure", + nodes: []any{ + map[string]any{"__typename": "StatusContext", "context": "ci/legacy", "state": "FAILURE", "targetUrl": "https://ci"}, + }, + wantCI: domain.CIFailing, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.prData(func(pr map[string]any) { + commits := pr["commits"].(map[string]any)["nodes"].([]any)[0].(map[string]any) + commit := commits["commit"].(map[string]any) + roll := commit["statusCheckRollup"].(map[string]any) + roll["contexts"].(map[string]any)["nodes"] = tc.nodes + }) + fx.install(t, f) + p := newProviderForTest(t, f) + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if obs.CI != tc.wantCI { + t.Fatalf("CI = %q, want %q", obs.CI, tc.wantCI) + } + }) + } +} + +func TestObserve_LogTailOnFailure(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.jobLogs = map[int64]string{ + 9001: strings.Repeat("line\n", 30) + strings.Join([]string{ + "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", + "11", "12", "13", "14", "15", "16", "17", "18", "19", "FAILED-LAST", + }, "\n"), + } + fx.prData(func(pr map[string]any) { + commits := pr["commits"].(map[string]any)["nodes"].([]any)[0].(map[string]any) + commit := commits["commit"].(map[string]any) + roll := commit["statusCheckRollup"].(map[string]any) + roll["contexts"].(map[string]any)["nodes"] = []any{ + map[string]any{ + "__typename": "CheckRun", + "name": "build", + "status": "COMPLETED", + "conclusion": "FAILURE", + "detailsUrl": "https://github.com/octocat/hello/runs/9001", + "databaseId": float64(9001), + }, + } + }) + fx.install(t, f) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if obs.CI != domain.CIFailing { + t.Fatalf("CI = %q, want failing", obs.CI) + } + if len(obs.Checks) != 1 { + t.Fatalf("Checks = %#v", obs.Checks) + } + tail := obs.Checks[0].LogTail + if tail == "" { + t.Fatalf("LogTail empty; expected last %d lines", ciFailureLogTailLines) + } + lines := strings.Split(tail, "\n") + if len(lines) > ciFailureLogTailLines { + t.Fatalf("LogTail has %d lines, want ≤ %d", len(lines), ciFailureLogTailLines) + } + if !strings.Contains(tail, "FAILED-LAST") { + t.Fatalf("LogTail missing the actual tail content: %q", tail) + } +} + +func TestObserve_LogTailFetchFailureIsBestEffort(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.prData(func(pr map[string]any) { + commits := pr["commits"].(map[string]any)["nodes"].([]any)[0].(map[string]any) + commit := commits["commit"].(map[string]any) + roll := commit["statusCheckRollup"].(map[string]any) + roll["contexts"].(map[string]any)["nodes"] = []any{ + map[string]any{ + "__typename": "CheckRun", + "name": "build", + "status": "COMPLETED", + "conclusion": "FAILURE", + "databaseId": float64(9001), + }, + } + }) + fx.install(t, f) + // Job-log endpoint returns 500 — the observation must still come back + // Fetched=true with a synthetic LogTail. + f.on(http.MethodGet, "/repos/octocat/hello/actions/jobs/9001/logs", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"server exploded"}`, http.StatusInternalServerError) + }) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if !obs.Fetched { + t.Fatalf("Fetched = false; log-fetch failures must not flip the whole observation") + } + if got := obs.Checks[0].LogTail; !strings.HasPrefix(got, " sentinel", got) + } +} + +func TestObserve_MergeabilityStates(t *testing.T) { + cases := []struct { + name string + mutateREST func(map[string]any) + mutateGQL func(map[string]any) + want domain.Mergeability + }{ + { + name: "mergeable", + // base fixture is the happy path + mutateREST: func(m map[string]any) {}, + mutateGQL: func(m map[string]any) {}, + want: domain.MergeMergeable, + }, + { + name: "conflicting via merge_state_status=DIRTY", + mutateREST: func(m map[string]any) { + m["mergeable_state"] = "dirty" + }, + mutateGQL: func(m map[string]any) { + m["mergeable"] = "CONFLICTING" + m["mergeStateStatus"] = "DIRTY" + }, + want: domain.MergeConflicting, + }, + { + name: "blocked by review", + mutateREST: func(m map[string]any) { + m["mergeable_state"] = "blocked" + }, + mutateGQL: func(m map[string]any) { + m["mergeStateStatus"] = "BLOCKED" + m["reviewDecision"] = "CHANGES_REQUESTED" + }, + want: domain.MergeBlocked, + }, + { + name: "unstable via merge_state_status=UNSTABLE", + mutateREST: func(m map[string]any) { + m["mergeable_state"] = "unstable" + }, + mutateGQL: func(m map[string]any) { + m["mergeStateStatus"] = "UNSTABLE" + }, + want: domain.MergeUnstable, + }, + { + name: "unknown when github hasn't computed yet", + mutateREST: func(m map[string]any) { + m["mergeable"] = nil + m["mergeable_state"] = "unknown" + }, + mutateGQL: func(m map[string]any) { + m["mergeable"] = "UNKNOWN" + m["mergeStateStatus"] = "UNKNOWN" + }, + want: domain.MergeUnknown, + }, + { + // Load-bearing aa-18 contract: CI failing must force + // MergeBlocked even when GitHub still reports the rollup + // as CLEAN (mergeStateStatus has not yet flipped to + // UNSTABLE). Without this guard the LCM would think a + // failing-CI PR is ready to merge. + name: "ci failing forces blocked even when mergeStateStatus is CLEAN", + mutateREST: func(m map[string]any) { + m["mergeable_state"] = "clean" + }, + mutateGQL: func(m map[string]any) { + m["mergeable"] = "MERGEABLE" + m["mergeStateStatus"] = "CLEAN" + commits := m["commits"].(map[string]any)["nodes"].([]any)[0].(map[string]any) + commit := commits["commit"].(map[string]any) + roll := commit["statusCheckRollup"].(map[string]any) + // databaseId=0 so the provider skips the per-job log + // fetch (this test is about mergeability, not log tail). + roll["contexts"].(map[string]any)["nodes"] = []any{ + map[string]any{"__typename": "CheckRun", "name": "lint", "status": "COMPLETED", "conclusion": "FAILURE", "databaseId": float64(0)}, + } + }, + want: domain.MergeBlocked, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + tc.mutateREST(fx.rest) + fx.prData(tc.mutateGQL) + fx.install(t, f) + p := newProviderForTest(t, f) + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if obs.Mergeability != tc.want { + t.Fatalf("Mergeability = %q, want %q", obs.Mergeability, tc.want) + } + }) + } +} + +func TestObserve_ReviewDecisions(t *testing.T) { + cases := []struct { + name string + decision any + want domain.ReviewDecision + }{ + {"approved", "APPROVED", domain.ReviewApproved}, + {"changes requested", "CHANGES_REQUESTED", domain.ReviewChangesRequest}, + {"review required", "REVIEW_REQUIRED", domain.ReviewRequired}, + {"none / null", nil, domain.ReviewNone}, + {"unrecognized falls to none", "WAT", domain.ReviewNone}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.prData(func(pr map[string]any) { pr["reviewDecision"] = tc.decision }) + fx.install(t, f) + p := newProviderForTest(t, f) + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if obs.Review != tc.want { + t.Fatalf("Review = %q, want %q", obs.Review, tc.want) + } + }) + } +} + +func TestObserve_BotAuthorFiltering(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.prData(func(pr map[string]any) { + pr["reviewThreads"] = map[string]any{"nodes": []any{ + map[string]any{ + "id": "T1", + "isResolved": false, + "comments": map[string]any{"nodes": []any{ + map[string]any{ + "id": "C1", + "body": "real human concern", + "path": "foo/bar.go", + "line": float64(12), + "url": "https://github.com/octocat/hello/pull/42#discussion_r1", + "author": map[string]any{"login": "alice", "__typename": "User"}, + }, + }}, + }, + // Bot thread — must be filtered out entirely. + map[string]any{ + "id": "T2", + "isResolved": false, + "comments": map[string]any{"nodes": []any{ + map[string]any{ + "id": "C2", + "body": "dependabot says update", + "path": "go.mod", + "line": float64(1), + "author": map[string]any{"login": "dependabot[bot]", "__typename": "Bot"}, + }, + }}, + }, + // Resolved thread — must also be filtered out. + map[string]any{ + "id": "T3", + "isResolved": true, + "comments": map[string]any{"nodes": []any{ + map[string]any{"id": "C3", "body": "lgtm now", "author": map[string]any{"login": "bob", "__typename": "User"}}, + }}, + }, + // Login like "robothon" — must NOT be treated as a bot (aa-18 + // flagged the strings.Contains(login,"bot") fallback as a + // false-positive magnet; we use the typed signal only). + map[string]any{ + "id": "T4", + "isResolved": false, + "comments": map[string]any{"nodes": []any{ + map[string]any{"id": "C4", "body": "actual comment", "path": "a.go", "line": float64(3), "author": map[string]any{"login": "robothon", "__typename": "User"}}, + }}, + }, + }} + }) + fx.install(t, f) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if len(obs.Comments) != 2 { + t.Fatalf("Comments = %#v; want exactly 2 (alice + robothon)", obs.Comments) + } + authors := []string{obs.Comments[0].Author, obs.Comments[1].Author} + if !contains(authors, "alice") { + t.Errorf("missing alice's comment: %v", authors) + } + if !contains(authors, "robothon") { + t.Errorf("robothon misclassified as bot: %v", authors) + } + for _, c := range obs.Comments { + if c.Resolved { + t.Errorf("comment %q marked Resolved=true; observation set is unresolved-only", c.ID) + } + } +} + +// TestObserve_AllBotThreadsYieldsNilComments pins that a PR whose review +// threads are 100% bot-authored produces Comments == nil but a fully +// fetched observation. The PR Manager downstream must handle a nil +// Comments slice without panicking, and Fetched=true means lifecycle +// can still apply the rest of the observation. +func TestObserve_AllBotThreadsYieldsNilComments(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.prData(func(pr map[string]any) { + pr["reviewThreads"] = map[string]any{"nodes": []any{ + map[string]any{ + "id": "T-bot-only", + "isResolved": false, + "comments": map[string]any{"nodes": []any{ + map[string]any{"id": "C1", "body": "auto-merged", "author": map[string]any{"login": "dependabot[bot]", "__typename": "Bot"}}, + map[string]any{"id": "C2", "body": "renovate", "author": map[string]any{"login": "renovate[bot]", "__typename": "Bot"}}, + }}, + }, + }} + }) + fx.install(t, f) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if !obs.Fetched { + t.Fatalf("Fetched = false; want true even when all comments are bots") + } + if len(obs.Comments) != 0 { + t.Fatalf("Comments = %#v; want empty (all authors are bots)", obs.Comments) + } +} + +func contains(ss []string, x string) bool { + for _, s := range ss { + if s == x { + return true + } + } + return false +} + +func TestObserve_ETag304Cached(t *testing.T) { + // Second call to the REST pull endpoint must send If-None-Match and + // reuse the cached body, while still completing the rest of the + // observation (GraphQL is always re-fetched — there's no cache for it). + f := newFakeGH(t) + fx := basePRFixture() + var restHits int + restPath := "/repos/" + fx.owner + "/" + fx.repo + "/pulls/" + strconv.Itoa(fx.number) + f.on(http.MethodGet, restPath, func(w http.ResponseWriter, r *http.Request) { + restHits++ + if r.Header.Get("If-None-Match") == `W/"v1"` { + w.Header().Set("ETag", `W/"v1"`) + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `W/"v1"`) + _ = json.NewEncoder(w).Encode(fx.rest) + }) + f.on(http.MethodPost, "/graphql", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(fx.graphql) + }) + p := newProviderForTest(t, f) + + first, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("first Observe: %v", err) + } + second, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("second Observe: %v", err) + } + if first.CI != second.CI || first.Mergeability != second.Mergeability { + t.Fatalf("304 replay diverged: %#v vs %#v", first, second) + } + if !second.Fetched { + t.Fatalf("second Fetched = false despite 304 hit") + } + if restHits != 2 { + t.Fatalf("expected 2 hits to the REST pull endpoint (one fresh, one 304), got %d", restHits) + } + // And: the second call must have actually sent If-None-Match. + var sentConditional bool + for _, r := range f.calls() { + if r.Method == http.MethodGet && r.Path == restPath && r.Header.Get("If-None-Match") != "" { + sentConditional = true + break + } + } + if !sentConditional { + t.Fatalf("second call did not send If-None-Match; ETag cache is broken") + } +} + +func TestObserve_PrimaryRateLimit(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + reset := time.Now().Add(2 * time.Minute).Unix() + f.on(http.MethodGet, "/repos/octocat/hello/pulls/42", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(reset, 10)) + http.Error(w, `{"message":"API rate limit exceeded"}`, http.StatusForbidden) + }) + // GraphQL would never be reached in this scenario. + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if !errors.Is(err, ErrRateLimited) { + t.Fatalf("err = %v, want ErrRateLimited", err) + } + if obs.Fetched { + t.Fatalf("Fetched = true on rate-limit error; want false") + } + var rle *RateLimitError + if !errors.As(err, &rle) { + t.Fatalf("err = %v, want *RateLimitError", err) + } + if rle.ResetAt.Unix() != reset { + t.Fatalf("ResetAt = %d, want %d", rle.ResetAt.Unix(), reset) + } +} + +func TestObserve_SecondaryRateLimit(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + f.on(http.MethodGet, "/repos/octocat/hello/pulls/42", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "30") + http.Error(w, `{"message":"You have exceeded a secondary rate limit"}`, http.StatusForbidden) + }) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if !errors.Is(err, ErrRateLimited) { + t.Fatalf("err = %v, want ErrRateLimited", err) + } + if obs.Fetched { + t.Fatalf("Fetched = true on rate-limit error") + } + var rle *RateLimitError + if !errors.As(err, &rle) { + t.Fatalf("err = %v, want *RateLimitError", err) + } + if rle.RetryAfter != 30*time.Second { + t.Fatalf("RetryAfter = %v, want 30s", rle.RetryAfter) + } +} + +func TestObserve_AuthFailedSurfacesAsErrAuthFailed(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + f.on(http.MethodGet, "/repos/octocat/hello/pulls/42", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"Bad credentials"}`, http.StatusUnauthorized) + }) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } + if obs.Fetched { + t.Fatalf("Fetched = true on auth-failed; want false") + } +} + +func TestObserve_MalformedJSONIsNotFetched(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + f.on(http.MethodGet, "/repos/octocat/hello/pulls/42", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{not valid json`)) + }) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err == nil { + t.Fatalf("expected decode error, got nil") + } + if obs.Fetched { + t.Fatalf("Fetched = true on decode failure; want false") + } +} + +func TestObserve_NetworkErrorIsNotFetched(t *testing.T) { + // Point the provider at a closed server to force a transport error. + f := newFakeGH(t) + p, err := NewProvider(ProviderOptions{ + Token: StaticTokenSource("tkn"), + HTTPClient: &http.Client{Timeout: 200 * time.Millisecond}, + RESTBase: "http://127.0.0.1:1", // reserved port; refuses connections + GraphQLURL: "http://127.0.0.1:1/graphql", + }) + if err != nil { + t.Fatalf("NewProvider: %v", err) + } + obs, observeErr := p.Observe(ctx(), "https://github.com/o/r/pull/1") + if observeErr == nil { + t.Fatalf("expected network error, got nil") + } + if obs.Fetched { + t.Fatalf("Fetched = true on network error; want false") + } + // Reference f so the test linter doesn't flag it; we don't use the + // fake here but the helper is the canonical way to scope a test. + _ = f +} + +func TestObserve_TokenInjectedAsBearer(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.install(t, f) + p := newProviderForTest(t, f) + if _, err := p.Observe(ctx(), fx.prURL()); err != nil { + t.Fatalf("Observe: %v", err) + } + for _, r := range f.calls() { + if got := r.Header.Get("Authorization"); got != "Bearer tkn-test" { + t.Fatalf("Authorization header on %s %s = %q, want Bearer tkn-test", r.Method, r.Path, got) + } + } +} + +func TestStaticTokenSourceRejectsBlank(t *testing.T) { + if _, err := StaticTokenSource("").Token(context.Background()); !errors.Is(err, ErrNoToken) { + t.Fatalf("err = %v, want ErrNoToken", err) + } + if _, err := StaticTokenSource(" ").Token(context.Background()); !errors.Is(err, ErrNoToken) { + t.Fatalf("blank-with-spaces: err = %v, want ErrNoToken", err) + } +} + +func TestGHTokenSourceUsesInjectedHook(t *testing.T) { + calls := 0 + src := &GHTokenSource{ + GH: func(ctx context.Context) (string, error) { + calls++ + return "from-gh\n", nil + }, + TokenTTL: time.Hour, + } + tok, err := src.Token(context.Background()) + if err != nil { + t.Fatalf("Token: %v", err) + } + if tok != "from-gh" { + t.Fatalf("Token = %q, want %q", tok, "from-gh") + } + // Second call within TTL must be cached. + if _, err := src.Token(context.Background()); err != nil { + t.Fatalf("second Token: %v", err) + } + if calls != 1 { + t.Fatalf("GH called %d times; want 1 (cache miss only)", calls) + } + // Invalidate and the next call must re-run. + src.InvalidateToken() + if _, err := src.Token(context.Background()); err != nil { + t.Fatalf("third Token: %v", err) + } + if calls != 2 { + t.Fatalf("after invalidate, GH called %d times; want 2", calls) + } +} + +// TestObserve_CIPaginationDegradesPassingToUnknown pins the safety +// guard for the GraphQL contexts pagination: when GitHub reports +// hasNextPage=true, a visible "all passing" set could be hiding a +// failure on the next page. The provider must degrade Passing / +// Pending / Unknown to CIUnknown so downstream code doesn't treat a +// possibly-broken PR as ready. A FAILING verdict from the visible +// page is still safe (and must NOT degrade). +func TestObserve_CIPaginationDegradesPassingToUnknown(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.prData(func(pr map[string]any) { + commits := pr["commits"].(map[string]any)["nodes"].([]any)[0].(map[string]any) + commit := commits["commit"].(map[string]any) + roll := commit["statusCheckRollup"].(map[string]any) + ctxs := roll["contexts"].(map[string]any) + // One visible passing context, but hasNextPage=true so a + // failure could be hiding in the unseen tail. + ctxs["nodes"] = []any{ + map[string]any{"__typename": "CheckRun", "name": "build", "status": "COMPLETED", "conclusion": "SUCCESS"}, + } + ctxs["pageInfo"] = map[string]any{"hasNextPage": true} + }) + fx.install(t, f) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if obs.CI != domain.CIUnknown { + t.Fatalf("CI = %q, want CIUnknown (hasNextPage must degrade passing)", obs.CI) + } +} + +func TestObserve_CIPaginationDoesNotMaskKnownFailure(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.prData(func(pr map[string]any) { + commits := pr["commits"].(map[string]any)["nodes"].([]any)[0].(map[string]any) + commit := commits["commit"].(map[string]any) + roll := commit["statusCheckRollup"].(map[string]any) + ctxs := roll["contexts"].(map[string]any) + ctxs["nodes"] = []any{ + map[string]any{"__typename": "CheckRun", "name": "lint", "status": "COMPLETED", "conclusion": "FAILURE", "databaseId": float64(0)}, + } + ctxs["pageInfo"] = map[string]any{"hasNextPage": true} + }) + fx.install(t, f) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if obs.CI != domain.CIFailing { + t.Fatalf("CI = %q, want CIFailing (a known failure on page 1 must NOT degrade)", obs.CI) + } +} + +// TestObserve_StatusContextLegacyHasNoLogTail pins that we do NOT try to +// fetch a job log for a legacy commit-status row (those have no Actions +// job ID, so /actions/jobs/0/logs would 404 if we let the path leak). +func TestObserve_StatusContextLegacyHasNoLogTail(t *testing.T) { + f := newFakeGH(t) + fx := basePRFixture() + fx.prData(func(pr map[string]any) { + commits := pr["commits"].(map[string]any)["nodes"].([]any)[0].(map[string]any) + commit := commits["commit"].(map[string]any) + roll := commit["statusCheckRollup"].(map[string]any) + roll["contexts"].(map[string]any)["nodes"] = []any{ + map[string]any{"__typename": "StatusContext", "context": "ci/legacy", "state": "FAILURE", "targetUrl": "https://ci"}, + } + }) + fx.install(t, f) + p := newProviderForTest(t, f) + + obs, err := p.Observe(ctx(), fx.prURL()) + if err != nil { + t.Fatalf("Observe: %v", err) + } + if obs.CI != domain.CIFailing { + t.Fatalf("CI = %q, want failing", obs.CI) + } + if len(obs.Checks) != 1 { + t.Fatalf("Checks = %#v", obs.Checks) + } + if obs.Checks[0].LogTail != "" { + t.Fatalf("LogTail = %q; want empty (StatusContext has no job log)", obs.Checks[0].LogTail) + } + if f.callsTo(http.MethodGet, "/repos/octocat/hello/actions/jobs/0/logs") != 0 { + t.Fatalf("unexpected attempt to fetch a /actions/jobs/0/logs URL") + } +} + +// TestObserve_AssertsPRObservationShape is a belt-and-braces compile-time +// guard that PRObservation has the fields we depend on. If the port adds +// or renames a field, this test fails to compile rather than failing at +// runtime. +func TestObserve_AssertsPRObservationShape(t *testing.T) { + var o ports.PRObservation + o.Fetched = true + o.URL = "" + o.Number = 0 + o.Draft = false + o.Merged = false + o.Closed = false + o.CI = domain.CIUnknown + o.Review = domain.ReviewNone + o.Mergeability = domain.MergeUnknown + o.Checks = nil + o.Comments = nil + _ = o +} From 424e6e824bd743cfad038ca05085e41c34c4f992 Mon Sep 17 00:00:00 2001 From: prateek Date: Mon, 1 Jun 2026 23:31:21 +0530 Subject: [PATCH 100/250] refactor: move session status assembly to service (#62) (#67) Co-authored-by: itrytoohard --- README.md | 2 +- backend/internal/domain/status.go | 49 --- backend/internal/domain/status_test.go | 38 --- backend/internal/httpd/api.go | 6 + backend/internal/httpd/apispec/openapi.yaml | 323 ++++++++++++++++++ .../internal/httpd/controllers/sessions.go | 276 +++++++++++++++ .../httpd/controllers/sessions_test.go | 174 ++++++++++ .../integration/lifecycle_sqlite_test.go | 8 +- backend/internal/service/session.go | 143 ++++++++ backend/internal/service/session_status.go | 49 +++ .../internal/service/session_status_test.go | 42 +++ backend/internal/service/session_test.go | 68 ++++ .../{session => session_manager}/manager.go | 86 ++--- .../manager_test.go | 22 +- docs/README.md | 2 +- docs/architecture.md | 19 +- docs/status.md | 10 +- 17 files changed, 1138 insertions(+), 179 deletions(-) delete mode 100644 backend/internal/domain/status_test.go create mode 100644 backend/internal/httpd/controllers/sessions.go create mode 100644 backend/internal/httpd/controllers/sessions_test.go create mode 100644 backend/internal/service/session.go create mode 100644 backend/internal/service/session_status.go create mode 100644 backend/internal/service/session_status_test.go create mode 100644 backend/internal/service/session_test.go rename backend/internal/{session => session_manager}/manager.go (76%) rename backend/internal/{session => session_manager}/manager_test.go (92%) diff --git a/README.md b/README.md index 33e08dae8c..146087bdea 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Rewrite of the agent-orchestrator: a long-running Go backend daemon (`backend/`) paired with a placeholder Electron + TypeScript frontend shell (`frontend/`). See [`docs/`](docs/README.md) for architecture and status — start with the -Lifecycle Manager + Session Manager lane in [`docs/architecture.md`](docs/architecture.md). +Lifecycle Manager + Session Service lane in [`docs/architecture.md`](docs/architecture.md). ## Backend daemon diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index ad88468555..e42a0ed0df 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -19,52 +19,3 @@ const ( StatusIdle SessionStatus = "idle" StatusTerminated SessionStatus = "terminated" ) - -// DeriveStatus is the ONLY producer of display status. It is a pure function of -// persisted session facts and PR facts: is_terminated, activity_state, and the PR -// table are the durable facts that tell the UI what it needs to know. -func DeriveStatus(rec SessionRecord, pr *PRFacts) SessionStatus { - if rec.IsTerminated { - if pr != nil && pr.Merged { - return StatusMerged - } - return StatusTerminated - } - - if rec.Activity.State == ActivityWaitingInput { - return StatusNeedsInput - } - - if pr != nil { - if pr.Merged { - return StatusMerged - } - if !pr.Closed { - return prPipelineStatus(*pr) - } - } - - if rec.Activity.State == ActivityActive { - return StatusWorking - } - return StatusIdle -} - -func prPipelineStatus(pr PRFacts) SessionStatus { - switch { - case pr.CI == CIFailing: - return StatusCIFailed - case pr.Draft: - return StatusDraft - case pr.Review == ReviewChangesRequest || pr.ReviewComments: - return StatusChangesRequested - case pr.Mergeability == MergeMergeable: - return StatusMergeable - case pr.Review == ReviewApproved: - return StatusApproved - case pr.Review == ReviewRequired: - return StatusReviewPending - default: - return StatusPROpen - } -} diff --git a/backend/internal/domain/status_test.go b/backend/internal/domain/status_test.go deleted file mode 100644 index 075098687c..0000000000 --- a/backend/internal/domain/status_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package domain - -import "testing" - -func rec(activity ActivityState, terminated bool) SessionRecord { - return SessionRecord{Activity: Activity{State: activity}, IsTerminated: terminated} -} - -func pr(facts PRFacts) *PRFacts { return &facts } - -func TestDeriveStatusFromSessionFactsAndPR(t *testing.T) { - tests := []struct { - name string - rec SessionRecord - pr *PRFacts - want SessionStatus - }{ - {"terminated", rec(ActivityExited, true), nil, StatusTerminated}, - {"merged-pr", rec(ActivityIdle, true), pr(PRFacts{Merged: true}), StatusMerged}, - {"needs-input", rec(ActivityWaitingInput, false), pr(PRFacts{CI: CIFailing}), StatusNeedsInput}, - {"ci-failed", rec(ActivityIdle, false), pr(PRFacts{CI: CIFailing}), StatusCIFailed}, - {"draft", rec(ActivityIdle, false), pr(PRFacts{Draft: true}), StatusDraft}, - {"changes-requested", rec(ActivityIdle, false), pr(PRFacts{Review: ReviewChangesRequest}), StatusChangesRequested}, - {"mergeable", rec(ActivityIdle, false), pr(PRFacts{Mergeability: MergeMergeable}), StatusMergeable}, - {"approved", rec(ActivityIdle, false), pr(PRFacts{Review: ReviewApproved}), StatusApproved}, - {"review-pending", rec(ActivityIdle, false), pr(PRFacts{Review: ReviewRequired}), StatusReviewPending}, - {"pr-open", rec(ActivityIdle, false), pr(PRFacts{}), StatusPROpen}, - {"working", rec(ActivityActive, false), nil, StatusWorking}, - {"idle", rec(ActivityIdle, false), nil, StatusIdle}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := DeriveStatus(tt.rec, tt.pr); got != tt.want { - t.Fatalf("got %q, want %q", got, tt.want) - } - }) - } -} diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 9480cdad4c..a61ba78497 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -19,6 +19,7 @@ import ( // registered but returns the OpenAPI-backed 501 response. type APIDeps struct { Projects project.Manager + Sessions controllers.SessionService } // API owns one controller per resource and is the single Register call the @@ -26,6 +27,7 @@ type APIDeps struct { type API struct { cfg config.Config projects *controllers.ProjectsController + sessions *controllers.SessionsController } // NewAPI constructs the API surface from its dependencies. cfg carries the @@ -37,6 +39,9 @@ func NewAPI(cfg config.Config, deps APIDeps) *API { projects: &controllers.ProjectsController{ Mgr: deps.Projects, }, + sessions: &controllers.SessionsController{ + Svc: deps.Sessions, + }, } } @@ -55,6 +60,7 @@ func (a *API) Register(root chi.Router) { r.Group(func(r chi.Router) { r.Use(middleware.Timeout(timeout)) a.projects.Register(r) + a.sessions.Register(r) // Sibling REST controllers plug in here. }) // Surfaces that intentionally bypass the REST timeout register at this level. diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 970ec7f481..b626c57880 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -15,6 +15,8 @@ servers: tags: - name: projects description: Project registry, configuration, and lifecycle administration + - name: sessions + description: Agent session lifecycle and messaging paths: /api/v1/projects: @@ -228,6 +230,219 @@ paths: "404": { $ref: "#/components/responses/ProjectNotFound" } "501": { $ref: "#/components/responses/NotImplemented" } + /api/v1/sessions: + get: + operationId: listSessions + tags: [sessions] + summary: List sessions + parameters: + - name: project + in: query + schema: { type: string } + - name: active + in: query + schema: { type: boolean } + - name: orchestratorOnly + in: query + schema: { type: boolean } + - name: fresh + in: query + schema: { type: boolean } + responses: + "200": + description: Sessions listed + content: + application/json: + schema: + type: object + required: [sessions] + properties: + sessions: + type: array + items: { $ref: "#/components/schemas/Session" } + "400": + description: Bad request + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + "501": { $ref: "#/components/responses/NotImplemented" } + post: + operationId: spawnSession + tags: [sessions] + summary: Spawn a new agent session + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SpawnSessionRequest" } + responses: + "201": + description: Session spawned + content: + application/json: + schema: + type: object + required: [session] + properties: + session: { $ref: "#/components/schemas/Session" } + "400": + description: Bad request + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + examples: + invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } + projectRequired: { value: { error: bad_request, code: PROJECT_ID_REQUIRED, message: "projectId is required" } } + promptTooLong: { value: { error: bad_request, code: PROMPT_TOO_LONG, message: "prompt is too long" } } + "404": { $ref: "#/components/responses/ProjectNotFound" } + "500": { $ref: "#/components/responses/SessionOperationFailed" } + "501": { $ref: "#/components/responses/NotImplemented" } + + /api/v1/sessions/{sessionId}: + parameters: + - $ref: "#/components/parameters/SessionIDPath" + get: + operationId: getSession + tags: [sessions] + summary: Fetch one session + responses: + "200": + description: Session fetched + content: + application/json: + schema: + type: object + required: [session] + properties: + session: { $ref: "#/components/schemas/Session" } + "404": { $ref: "#/components/responses/SessionNotFound" } + "501": { $ref: "#/components/responses/NotImplemented" } + patch: + operationId: renameSession + tags: [sessions] + summary: Rename a session display name + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [displayName] + properties: + displayName: { type: string, minLength: 1 } + responses: + "200": + description: Session renamed + content: + application/json: + schema: + type: object + required: [session] + properties: + session: { $ref: "#/components/schemas/Session" } + "400": + description: Bad request + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + "404": { $ref: "#/components/responses/SessionNotFound" } + "501": { $ref: "#/components/responses/NotImplemented" } + + /api/v1/sessions/{sessionId}/restore: + parameters: + - $ref: "#/components/parameters/SessionIDPath" + post: + operationId: restoreSession + tags: [sessions] + summary: Restore a terminated session + responses: + "200": + description: Session restored + content: + application/json: + schema: + type: object + required: [ok, sessionId, session] + properties: + ok: { type: boolean } + sessionId: { type: string } + session: { $ref: "#/components/schemas/Session" } + "404": { $ref: "#/components/responses/SessionNotFound" } + "409": { $ref: "#/components/responses/SessionConflict" } + "501": { $ref: "#/components/responses/NotImplemented" } + + /api/v1/sessions/{sessionId}/kill: + parameters: + - $ref: "#/components/parameters/SessionIDPath" + post: + operationId: killSession + tags: [sessions] + summary: Mark a session terminated and tear down runtime/workspace resources + responses: + "200": + description: Kill attempted + content: + application/json: + schema: { $ref: "#/components/schemas/KillSessionResponse" } + "404": { $ref: "#/components/responses/SessionNotFound" } + "409": { $ref: "#/components/responses/SessionConflict" } + "501": { $ref: "#/components/responses/NotImplemented" } + + /api/v1/sessions/{sessionId}/send: + parameters: + - $ref: "#/components/parameters/SessionIDPath" + post: + operationId: sendSessionMessage + tags: [sessions] + summary: Send a message to a running session's agent + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SendSessionMessageRequest" } + responses: + "200": + description: Message accepted + content: + application/json: + schema: { $ref: "#/components/schemas/SendSessionMessageResponse" } + "400": + description: Bad request + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + examples: + invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } + messageRequired: { value: { error: bad_request, code: MESSAGE_REQUIRED, message: "Message is required" } } + "404": { $ref: "#/components/responses/SessionNotFound" } + "500": { $ref: "#/components/responses/SessionOperationFailed" } + "501": { $ref: "#/components/responses/NotImplemented" } + + /api/v1/orchestrators: + post: + operationId: spawnOrchestrator + tags: [sessions] + summary: Spawn an orchestrator session + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SpawnOrchestratorRequest" } + responses: + "201": + description: Orchestrator spawned + content: + application/json: + schema: { $ref: "#/components/schemas/SpawnOrchestratorResponse" } + "400": + description: Bad request + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + "404": { $ref: "#/components/responses/ProjectNotFound" } + "500": { $ref: "#/components/responses/SessionOperationFailed" } + "501": { $ref: "#/components/responses/NotImplemented" } + components: parameters: ProjectIDPath: @@ -237,6 +452,13 @@ components: schema: { type: string, minLength: 1 } description: Project identifier (registry key). + SessionIDPath: + name: sessionId + in: path + required: true + schema: { type: string, minLength: 1 } + description: Session identifier, e.g. project-1. + responses: NotImplemented: description: | @@ -255,6 +477,29 @@ components: schema: { $ref: "#/components/schemas/APIError" } example: { error: not_found, code: PROJECT_NOT_FOUND, message: "Unknown project" } + SessionNotFound: + description: Session not found + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: not_found, code: SESSION_NOT_FOUND, message: "Unknown session" } + + SessionConflict: + description: Session is not in a valid state for the requested operation + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + examples: + notRestorable: { value: { error: conflict, code: SESSION_NOT_RESTORABLE, message: "Session is not restorable" } } + incompleteHandle: { value: { error: conflict, code: SESSION_INCOMPLETE_HANDLE, message: "Session is missing runtime or workspace handles" } } + + SessionOperationFailed: + description: Session operation failed + content: + application/json: + schema: { $ref: "#/components/schemas/APIError" } + example: { error: internal, code: SESSION_OPERATION_FAILED, message: "Session operation failed" } + schemas: APIError: type: object @@ -369,6 +614,84 @@ components: projectCount: { type: integer } degradedCount: { type: integer } + + Session: + type: object + required: [id, projectId, kind, activity, isTerminated, createdAt, updatedAt, status] + properties: + id: { type: string } + projectId: { type: string } + issueId: { type: string } + kind: { type: string, enum: [worker, orchestrator] } + harness: { type: string, enum: ["", claude-code, codex, aider, opencode] } + activity: { $ref: "#/components/schemas/SessionActivity" } + isTerminated: { type: boolean } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + status: + type: string + enum: [working, pr_open, draft, ci_failed, review_pending, changes_requested, approved, mergeable, merged, needs_input, idle, terminated] + + SessionActivity: + type: object + required: [state, lastActivityAt] + properties: + state: { type: string, enum: [active, idle, waiting_input, exited] } + lastActivityAt: { type: string, format: date-time } + + SpawnSessionRequest: + type: object + required: [projectId] + properties: + projectId: { type: string } + issueId: { type: string } + kind: { type: string, enum: [worker, orchestrator], default: worker } + harness: { type: string, enum: ["", claude-code, codex, aider, opencode] } + branch: { type: string } + prompt: { type: string, maxLength: 4096 } + agentRules: { type: string } + + SendSessionMessageRequest: + type: object + required: [message] + properties: + message: { type: string, minLength: 1, maxLength: 4096 } + + SendSessionMessageResponse: + type: object + required: [ok, sessionId, message] + properties: + ok: { type: boolean } + sessionId: { type: string } + message: { type: string } + + KillSessionResponse: + type: object + required: [ok, sessionId] + properties: + ok: { type: boolean } + sessionId: { type: string } + freed: { type: boolean } + + SpawnOrchestratorRequest: + type: object + required: [projectId] + properties: + projectId: { type: string } + clean: { type: boolean, default: false } + + SpawnOrchestratorResponse: + type: object + required: [orchestrator] + properties: + orchestrator: + type: object + required: [id, projectId] + properties: + id: { type: string } + projectId: { type: string } + projectName: { type: string } + # ---- Behaviour config blobs ---- TrackerConfig: diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go new file mode 100644 index 0000000000..6beccf8e36 --- /dev/null +++ b/backend/internal/httpd/controllers/sessions.go @@ -0,0 +1,276 @@ +package controllers + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "unicode" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/service" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" +) + +const ( + maxPromptLen = 4096 + maxMessageLen = 4096 +) + +// SessionService is the controller-facing session service contract. +type SessionService interface { + List(ctx context.Context, filter service.SessionListFilter) ([]domain.Session, error) + Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) + Get(ctx context.Context, id domain.SessionID) (domain.Session, error) + Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) + Kill(ctx context.Context, id domain.SessionID) (bool, error) + Send(ctx context.Context, id domain.SessionID, message string) error +} + +// SessionsController owns the session routes. Nil keeps routes registered but +// returns OpenAPI-backed 501s. +type SessionsController struct { + Svc SessionService +} + +// Register mounts the session routes on the supplied router. +func (c *SessionsController) Register(r chi.Router) { + r.Get("/sessions", c.list) + r.Post("/sessions", c.spawn) + r.Get("/sessions/{sessionId}", c.get) + r.Patch("/sessions/{sessionId}", c.rename) + r.Post("/sessions/{sessionId}/restore", c.restore) + r.Post("/sessions/{sessionId}/kill", c.kill) + r.Post("/sessions/{sessionId}/send", c.send) + r.Post("/orchestrators", c.spawnOrchestrator) +} + +func (c *SessionsController) list(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/sessions") + return + } + filter, err := parseSessionListFilter(r) + if err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_QUERY", err.Error(), nil) + return + } + sessions, err := c.Svc.List(r.Context(), filter) + if err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"sessions": sessions}) +} + +type spawnSessionRequest struct { + ProjectID domain.ProjectID `json:"projectId"` + IssueID domain.IssueID `json:"issueId"` + Kind domain.SessionKind `json:"kind"` + Harness domain.AgentHarness `json:"harness"` + Branch string `json:"branch"` + Prompt string `json:"prompt"` + AgentRules string `json:"agentRules"` +} + +func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions") + return + } + var in spawnSessionRequest + if err := decodeJSON(r, &in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + if in.ProjectID == "" { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROJECT_ID_REQUIRED", "projectId is required", nil) + return + } + if len(in.Prompt) > maxPromptLen { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROMPT_TOO_LONG", "prompt is too long", nil) + return + } + if in.Kind == "" { + in.Kind = domain.KindWorker + } + sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt, AgentRules: in.AgentRules}) + if err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusCreated, map[string]any{"session": sess}) +} + +func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}") + return + } + sess, err := c.Svc.Get(r.Context(), sessionID(r)) + if err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"session": sess}) +} + +func (c *SessionsController) rename(w http.ResponseWriter, r *http.Request) { + apispec.NotImplemented(w, r, "PATCH", "/api/v1/sessions/{sessionId}") +} + +func (c *SessionsController) restore(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/restore") + return + } + sess, err := c.Svc.Restore(r.Context(), sessionID(r)) + if err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "session": sess}) +} + +func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/kill") + return + } + freed, err := c.Svc.Kill(r.Context(), sessionID(r)) + if err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "freed": freed}) +} + +type sendSessionRequest struct { + Message string `json:"message"` +} + +func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/send") + return + } + var in sendSessionRequest + if err := decodeJSON(r, &in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + if in.Message == "" { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "MESSAGE_REQUIRED", "Message is required", nil) + return + } + if len(in.Message) > maxMessageLen { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "MESSAGE_TOO_LONG", "Message is too long", nil) + return + } + message := stripUnsafeControlChars(in.Message) + if err := c.Svc.Send(r.Context(), sessionID(r), message); err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "message": message}) +} + +type spawnOrchestratorRequest struct { + ProjectID domain.ProjectID `json:"projectId"` + Clean bool `json:"clean"` +} + +func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/orchestrators") + return + } + var in spawnOrchestratorRequest + if err := decodeJSON(r, &in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + if in.ProjectID == "" { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROJECT_ID_REQUIRED", "projectId is required", nil) + return + } + if in.Clean { + active := true + orchestrators, err := c.Svc.List(r.Context(), service.SessionListFilter{ProjectID: in.ProjectID, Active: &active, OrchestratorOnly: true}) + if err != nil { + writeSessionError(w, r, err) + return + } + for _, existing := range orchestrators { + if _, err := c.Svc.Kill(r.Context(), existing.ID); err != nil { + writeSessionError(w, r, err) + return + } + } + } + sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, Kind: domain.KindOrchestrator}) + if err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusCreated, map[string]any{"orchestrator": map[string]any{"id": sess.ID, "projectId": sess.ProjectID}}) +} + +func sessionID(r *http.Request) domain.SessionID { + return domain.SessionID(chi.URLParam(r, "sessionId")) +} + +func parseSessionListFilter(r *http.Request) (service.SessionListFilter, error) { + q := r.URL.Query() + filter := service.SessionListFilter{ProjectID: domain.ProjectID(q.Get("project"))} + if raw := q.Get("active"); raw != "" { + active, err := strconv.ParseBool(raw) + if err != nil { + return service.SessionListFilter{}, errors.New("active must be a boolean") + } + filter.Active = &active + } + if raw := q.Get("orchestratorOnly"); raw != "" { + orchestratorOnly, err := strconv.ParseBool(raw) + if err != nil { + return service.SessionListFilter{}, errors.New("orchestratorOnly must be a boolean") + } + filter.OrchestratorOnly = orchestratorOnly + } + if raw := q.Get("fresh"); raw != "" { + fresh, err := strconv.ParseBool(raw) + if err != nil { + return service.SessionListFilter{}, errors.New("fresh must be a boolean") + } + filter.Fresh = fresh + } + return filter, nil +} + +func stripUnsafeControlChars(message string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' { + return -1 + } + return r + }, message) +} + +func writeSessionError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, sessionmanager.ErrNotFound): + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "SESSION_NOT_FOUND", "Unknown session", nil) + case errors.Is(err, sessionmanager.ErrNotRestorable): + envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_NOT_RESTORABLE", "Session is not restorable", nil) + case errors.Is(err, sessionmanager.ErrIncompleteHandle): + envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_INCOMPLETE_HANDLE", "Session is missing runtime or workspace handles", nil) + default: + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "SESSION_OPERATION_FAILED", "Session operation failed", nil) + } +} diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go new file mode 100644 index 0000000000..62ef4c8c3f --- /dev/null +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -0,0 +1,174 @@ +package controllers_test + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/service" +) + +type fakeSessionService struct { + sessions map[domain.SessionID]domain.Session + sent string +} + +func newFakeSessionService() *fakeSessionService { + now := time.Now().UTC() + s := domain.Session{SessionRecord: domain.SessionRecord{ID: "ao-1", ProjectID: "ao", Kind: domain.KindWorker, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle} + return &fakeSessionService{sessions: map[domain.SessionID]domain.Session{s.ID: s}} +} + +func (f *fakeSessionService) List(_ context.Context, filter service.SessionListFilter) ([]domain.Session, error) { + var out []domain.Session + for _, s := range f.sessions { + if filter.ProjectID != "" && s.ProjectID != filter.ProjectID { + continue + } + if filter.Active != nil && s.IsTerminated == *filter.Active { + continue + } + if filter.OrchestratorOnly && s.Kind != domain.KindOrchestrator { + continue + } + out = append(out, s) + } + return out, nil +} + +func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) { + now := time.Now().UTC() + s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle} + f.sessions[s.ID] = s + return s, nil +} + +func (f *fakeSessionService) Get(_ context.Context, id domain.SessionID) (domain.Session, error) { + return f.sessions[id], nil +} + +func (f *fakeSessionService) Restore(_ context.Context, id domain.SessionID) (domain.Session, error) { + s := f.sessions[id] + s.IsTerminated = false + s.Status = domain.StatusIdle + f.sessions[id] = s + return s, nil +} + +func (f *fakeSessionService) Kill(_ context.Context, id domain.SessionID) (bool, error) { + s := f.sessions[id] + s.IsTerminated = true + s.Status = domain.StatusTerminated + f.sessions[id] = s + return true, nil +} + +func (f *fakeSessionService) Send(_ context.Context, _ domain.SessionID, message string) error { + f.sent = message + return nil +} + +func newSessionTestServer(t *testing.T, svc *fakeSessionService) *httptest.Server { + t.Helper() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{Sessions: svc})) + t.Cleanup(srv.Close) + return srv +} + +func TestSessionsRoutes_DefaultToStubsWithoutService(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil)) + t.Cleanup(srv.Close) + + body, status, headers := doRequest(t, srv, "GET", "/api/v1/sessions", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") +} + +func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { + svc := newFakeSessionService() + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "GET", "/api/v1/sessions?project=ao", "") + if status != http.StatusOK { + t.Fatalf("GET sessions = %d, want 200; body=%s", status, body) + } + var list struct { + Sessions []sessionBody `json:"sessions"` + } + mustJSON(t, body, &list) + if len(list.Sessions) != 1 || list.Sessions[0].ID != "ao-1" || list.Sessions[0].Status != string(domain.StatusIdle) { + t.Fatalf("list = %#v", list) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix"}`) + if status != http.StatusCreated { + t.Fatalf("POST session = %d, want 201; body=%s", status, body) + } + var spawned struct { + Session sessionBody `json:"session"` + } + mustJSON(t, body, &spawned) + if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" { + t.Fatalf("spawned = %#v", spawned) + } + + body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "") + if status != http.StatusOK { + t.Fatalf("GET session = %d, want 200; body=%s", status, body) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/send", "{\"message\":\"con\\u0000tinue\"}") + if status != http.StatusOK || svc.sent != "continue" { + t.Fatalf("send status=%d sent=%q body=%s", status, svc.sent, body) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/kill", "") + if status != http.StatusOK { + t.Fatalf("kill = %d, want 200; body=%s", status, body) + } + var killed struct { + SessionID string `json:"sessionId"` + Freed bool `json:"freed"` + } + mustJSON(t, body, &killed) + if killed.SessionID != "ao-2" || !killed.Freed { + t.Fatalf("kill response = %#v", killed) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/restore", "") + if status != http.StatusOK { + t.Fatalf("restore = %d, want 200; body=%s", status, body) + } + + body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-2", `{"displayName":"Renamed"}`) + assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/orchestrators", `{"projectId":"ao"}`) + if status != http.StatusCreated { + t.Fatalf("orchestrator = %d, want 201; body=%s", status, body) + } +} + +func TestSessionsAPI_SendValidation(t *testing.T) { + srv := newSessionTestServer(t, newFakeSessionService()) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/send", `{"message":""}`) + assertErrorCode(t, body, status, http.StatusBadRequest, "MESSAGE_REQUIRED") +} + +type sessionBody struct { + ID string `json:"id"` + IssueID string `json:"issueId"` + Harness string `json:"harness"` + Status string `json:"status"` +} diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index 670fa150ad..b8d3633283 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -11,7 +11,8 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" prsvc "github.com/aoagents/agent-orchestrator/backend/internal/pr" "github.com/aoagents/agent-orchestrator/backend/internal/project" - "github.com/aoagents/agent-orchestrator/backend/internal/session" + "github.com/aoagents/agent-orchestrator/backend/internal/service" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -54,7 +55,7 @@ func (c *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg strin type stack struct { store *sqlite.Store - sm *session.Manager + sm *service.Session lcm *lifecycle.Manager prm *prsvc.Manager rt *stubRuntime @@ -78,7 +79,8 @@ func newStack(t *testing.T) *stack { prm := prsvc.New(prsvc.Deps{Writer: store, Lifecycle: lcm}) rt := &stubRuntime{} ws := &stubWorkspace{} - sm := session.New(session.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm}) + mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm}) + sm := service.NewSession(mgr, store) return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg} } diff --git a/backend/internal/service/session.go b/backend/internal/service/session.go new file mode 100644 index 0000000000..fae8d76bdd --- /dev/null +++ b/backend/internal/service/session.go @@ -0,0 +1,143 @@ +package service + +import ( + "context" + "fmt" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" +) + +// SessionStore is the read-only persistence surface needed to assemble controller-facing session read models. +type SessionStore interface { + GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) + ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) + ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) + GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) +} + +// SessionListFilter captures API-facing session list query filters. +type SessionListFilter struct { + ProjectID domain.ProjectID + Active *bool + OrchestratorOnly bool + Fresh bool +} + +// Session is the controller-facing session service. It delegates command-side +// session operations to the internal sessionmanager.Manager and owns read-model +// assembly, including user-facing display status derivation. +type Session struct { + manager *sessionmanager.Manager + store SessionStore +} + +// NewSession wires a controller-facing session service over an internal session Manager. +func NewSession(manager *sessionmanager.Manager, store SessionStore) *Session { + return &Session{manager: manager, store: store} +} + +// Spawn creates a session and returns the API-facing read model. +func (s *Session) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { + rec, err := s.manager.Spawn(ctx, cfg) + if err != nil { + return domain.Session{}, err + } + return s.toSession(ctx, rec) +} + +// Restore relaunches a terminated session and returns the API-facing read model. +func (s *Session) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { + rec, err := s.manager.Restore(ctx, id) + if err != nil { + return domain.Session{}, err + } + return s.toSession(ctx, rec) +} + +// Kill delegates terminal intent and teardown to the internal manager. +func (s *Session) Kill(ctx context.Context, id domain.SessionID) (bool, error) { + return s.manager.Kill(ctx, id) +} + +// Send delegates agent messaging to the internal manager. +func (s *Session) Send(ctx context.Context, id domain.SessionID, message string) error { + return s.manager.Send(ctx, id, message) +} + +// Cleanup delegates terminal workspace cleanup to the internal manager. +func (s *Session) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) { + return s.manager.Cleanup(ctx, project) +} + +// List returns sessions as enriched display models after applying API filters. +func (s *Session) List(ctx context.Context, filter SessionListFilter) ([]domain.Session, error) { + recs, err := s.listRecords(ctx, filter.ProjectID) + if err != nil { + return nil, err + } + out := make([]domain.Session, 0, len(recs)) + for _, rec := range recs { + if !matchesSessionFilter(rec, filter) { + continue + } + sess, err := s.toSession(ctx, rec) + if err != nil { + return nil, err + } + out = append(out, sess) + } + return out, nil +} + +func (s *Session) listRecords(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { + if project == "" { + recs, err := s.store.ListAllSessions(ctx) + if err != nil { + return nil, fmt.Errorf("list all sessions: %w", err) + } + return recs, nil + } + recs, err := s.store.ListSessions(ctx, project) + if err != nil { + return nil, fmt.Errorf("list %s: %w", project, err) + } + return recs, nil +} + +func matchesSessionFilter(rec domain.SessionRecord, filter SessionListFilter) bool { + if filter.Active != nil && rec.IsTerminated == *filter.Active { + return false + } + if filter.OrchestratorOnly && rec.Kind != domain.KindOrchestrator { + return false + } + if filter.Fresh && rec.IsTerminated { + return false + } + return true +} + +// Get returns one session as an enriched display model, or sessionmanager.ErrNotFound if it is absent. +func (s *Session) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { + rec, ok, err := s.store.GetSession(ctx, id) + if err != nil { + return domain.Session{}, fmt.Errorf("get %s: %w", id, err) + } + if !ok { + return domain.Session{}, fmt.Errorf("get %s: %w", id, sessionmanager.ErrNotFound) + } + return s.toSession(ctx, rec) +} + +func (s *Session) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) { + pr, ok, err := s.store.GetDisplayPRFactsForSession(ctx, rec.ID) + if err != nil { + return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err) + } + if !ok { + return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, nil)}, nil + } + return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, &pr)}, nil +} diff --git a/backend/internal/service/session_status.go b/backend/internal/service/session_status.go new file mode 100644 index 0000000000..801b7b191c --- /dev/null +++ b/backend/internal/service/session_status.go @@ -0,0 +1,49 @@ +package service + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +func deriveStatus(rec domain.SessionRecord, pr *domain.PRFacts) domain.SessionStatus { + if rec.IsTerminated { + if pr != nil && pr.Merged { + return domain.StatusMerged + } + return domain.StatusTerminated + } + + if rec.Activity.State == domain.ActivityWaitingInput { + return domain.StatusNeedsInput + } + + if pr != nil { + if pr.Merged { + return domain.StatusMerged + } + if !pr.Closed { + return prPipelineStatus(*pr) + } + } + + if rec.Activity.State == domain.ActivityActive { + return domain.StatusWorking + } + return domain.StatusIdle +} + +func prPipelineStatus(pr domain.PRFacts) domain.SessionStatus { + switch { + case pr.CI == domain.CIFailing: + return domain.StatusCIFailed + case pr.Draft: + return domain.StatusDraft + case pr.Review == domain.ReviewChangesRequest || pr.ReviewComments: + return domain.StatusChangesRequested + case pr.Mergeability == domain.MergeMergeable: + return domain.StatusMergeable + case pr.Review == domain.ReviewApproved: + return domain.StatusApproved + case pr.Review == domain.ReviewRequired: + return domain.StatusReviewPending + default: + return domain.StatusPROpen + } +} diff --git a/backend/internal/service/session_status_test.go b/backend/internal/service/session_status_test.go new file mode 100644 index 0000000000..b45125e123 --- /dev/null +++ b/backend/internal/service/session_status_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func statusRec(activity domain.ActivityState, terminated bool) domain.SessionRecord { + return domain.SessionRecord{Activity: domain.Activity{State: activity}, IsTerminated: terminated} +} + +func statusPR(facts domain.PRFacts) *domain.PRFacts { return &facts } + +func TestServiceDerivesStatusFromSessionFactsAndPR(t *testing.T) { + tests := []struct { + name string + rec domain.SessionRecord + pr *domain.PRFacts + want domain.SessionStatus + }{ + {"terminated", statusRec(domain.ActivityExited, true), nil, domain.StatusTerminated}, + {"merged-pr", statusRec(domain.ActivityIdle, true), statusPR(domain.PRFacts{Merged: true}), domain.StatusMerged}, + {"needs-input", statusRec(domain.ActivityWaitingInput, false), statusPR(domain.PRFacts{CI: domain.CIFailing}), domain.StatusNeedsInput}, + {"ci-failed", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{CI: domain.CIFailing}), domain.StatusCIFailed}, + {"draft", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Draft: true}), domain.StatusDraft}, + {"changes-requested", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewChangesRequest}), domain.StatusChangesRequested}, + {"mergeable", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Mergeability: domain.MergeMergeable}), domain.StatusMergeable}, + {"approved", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewApproved}), domain.StatusApproved}, + {"review-pending", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewRequired}), domain.StatusReviewPending}, + {"pr-open", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{}), domain.StatusPROpen}, + {"working", statusRec(domain.ActivityActive, false), nil, domain.StatusWorking}, + {"idle", statusRec(domain.ActivityIdle, false), nil, domain.StatusIdle}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := deriveStatus(tt.rec, tt.pr); got != tt.want { + t.Fatalf("got %q want %q", got, tt.want) + } + }) + } +} diff --git a/backend/internal/service/session_test.go b/backend/internal/service/session_test.go new file mode 100644 index 0000000000..f092dc1957 --- /dev/null +++ b/backend/internal/service/session_test.go @@ -0,0 +1,68 @@ +package service + +import ( + "context" + "fmt" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +type fakeSessionStore struct { + sessions map[domain.SessionID]domain.SessionRecord + pr map[domain.SessionID]domain.PRFacts + num int +} + +func newFakeSessionStore() *fakeSessionStore { + return &fakeSessionStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}} +} + +func (f *fakeSessionStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { + f.num++ + rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num)) + f.sessions[rec.ID] = rec + return rec, nil +} + +func (f *fakeSessionStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { + r, ok := f.sessions[id] + return r, ok, nil +} + +func (f *fakeSessionStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) { + var out []domain.SessionRecord + for _, r := range f.sessions { + if r.ProjectID == p { + out = append(out, r) + } + } + return out, nil +} + +func (f *fakeSessionStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) { + out := make([]domain.SessionRecord, 0, len(f.sessions)) + for _, r := range f.sessions { + out = append(out, r) + } + return out, nil +} + +func (f *fakeSessionStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) { + pr, ok := f.pr[id] + return pr, ok, nil +} + +func TestSessionListDerivesStatusFromPRFacts(t *testing.T) { + st := newFakeSessionStore() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}} + st.pr["mer-1"] = domain.PRFacts{URL: "pr1", CI: domain.CIFailing} + + list, err := (&Session{store: st}).List(context.Background(), SessionListFilter{ProjectID: "mer"}) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].Status != domain.StatusCIFailed { + t.Fatalf("got %+v", list) + } +} diff --git a/backend/internal/session/manager.go b/backend/internal/session_manager/manager.go similarity index 76% rename from backend/internal/session/manager.go rename to backend/internal/session_manager/manager.go index ca4d0fa68e..18c21c5c45 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session_manager/manager.go @@ -1,7 +1,6 @@ -// Package session drives the runtime/agent/workspace plugins to create and tear -// down sessions, routes durable lifecycle fact writes through lifecycle, and -// attaches derived display status on read. -package session +// Package sessionmanager drives internal session command operations over runtime, +// agent, workspace, storage, messenger, and lifecycle dependencies. +package sessionmanager import ( "context" @@ -37,20 +36,20 @@ type runtimeController interface { Destroy(ctx context.Context, handle ports.RuntimeHandle) error } -type sessionStore interface { +// Store is the persistence surface needed by the internal session Manager. +type Store interface { CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) - GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) } -// Manager coordinates session spawn, restore, kill, listing, and cleanup over -// the outbound ports. +// Manager coordinates internal session spawn, restore, kill, and cleanup over +// the outbound ports. User-facing read-model assembly lives in the service package. type Manager struct { runtime runtimeController agent ports.Agent workspace ports.Workspace - store sessionStore + store Store messenger ports.AgentMessenger lcm lifecycleRecorder clock func() time.Time @@ -61,7 +60,7 @@ type Deps struct { Runtime runtimeController Agent ports.Agent Workspace ports.Workspace - Store sessionStore + Store Store Messenger ports.AgentMessenger Lifecycle lifecycleRecorder Clock func() time.Time @@ -88,17 +87,17 @@ func New(d Deps) *Manager { // Spawn creates the session row (which assigns the "{project}-{n}" id), then the // workspace and runtime, then reports completion to the LCM. A failure after the // row exists parks it as terminated and rolls back what was built. -func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { +func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) { rec, err := m.store.CreateSession(ctx, seedRecord(cfg, m.clock())) if err != nil { - return domain.Session{}, fmt.Errorf("spawn: create: %w", err) + return domain.SessionRecord{}, fmt.Errorf("spawn: create: %w", err) } id := rec.ID ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ProjectID: cfg.ProjectID, SessionID: id, Branch: cfg.Branch}) if err != nil { m.markSpawnFailedTerminated(ctx, id) - return domain.Session{}, fmt.Errorf("spawn %s: workspace: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err) } agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: buildPrompt(cfg)} @@ -111,7 +110,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess if err != nil { _ = m.workspace.Destroy(ctx, ws) m.markSpawnFailedTerminated(ctx, id) - return domain.Session{}, fmt.Errorf("spawn %s: runtime: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err) } metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, Prompt: agentCfg.Prompt} @@ -119,9 +118,9 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess _ = m.runtime.Destroy(ctx, handle) _ = m.workspace.Destroy(ctx, ws) m.markSpawnFailedTerminated(ctx, id) - return domain.Session{}, fmt.Errorf("spawn %s: completed: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("spawn %s: completed: %w", id, err) } - return m.Get(ctx, id) + return m.getRecord(ctx, id) } // markSpawnFailedTerminated best-effort parks an orphaned spawn as terminated. @@ -161,25 +160,25 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { // Restore relaunches a torn-down session in its workspace. The fallible I/O runs // before any durable session write, so a failure never resurrects the row or destroys // the worktree (it may hold the agent's prior work). -func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { +func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error) { rec, ok, err := m.store.GetSession(ctx, id) if err != nil { - return domain.Session{}, fmt.Errorf("restore %s: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) } if !ok { - return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotFound) + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, ErrNotFound) } if !rec.IsTerminated { - return domain.Session{}, fmt.Errorf("restore %s: %w", id, ErrNotRestorable) + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, ErrNotRestorable) } meta := rec.Metadata if meta.AgentSessionID == "" && meta.Prompt == "" { - return domain.Session{}, fmt.Errorf("restore %s: nothing to resume from", id) + return domain.SessionRecord{}, fmt.Errorf("restore %s: nothing to resume from", id) } ws, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ProjectID: rec.ProjectID, SessionID: id, Branch: meta.Branch}) if err != nil { - return domain.Session{}, fmt.Errorf("restore %s: workspace: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("restore %s: workspace: %w", id, err) } agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: meta.Prompt} launch := m.agent.GetRestoreCommand(meta.AgentSessionID) @@ -193,43 +192,25 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, rec.ProjectID, rec.IssueID), }) if err != nil { - return domain.Session{}, fmt.Errorf("restore %s: runtime: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("restore %s: runtime: %w", id, err) } metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, AgentSessionID: meta.AgentSessionID, Prompt: meta.Prompt} if err := m.lcm.MarkSpawned(ctx, id, metadata); err != nil { _ = m.runtime.Destroy(ctx, handle) - return domain.Session{}, fmt.Errorf("restore %s: completed: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("restore %s: completed: %w", id, err) } - return m.Get(ctx, id) + return m.getRecord(ctx, id) } -// List returns the project's sessions as enriched display models. -func (m *Manager) List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) { - recs, err := m.store.ListSessions(ctx, project) - if err != nil { - return nil, fmt.Errorf("list %s: %w", project, err) - } - out := make([]domain.Session, 0, len(recs)) - for _, rec := range recs { - s, err := m.toSession(ctx, rec) - if err != nil { - return nil, err - } - out = append(out, s) - } - return out, nil -} - -// Get returns one session as a display model, or ErrNotFound if it is absent. -func (m *Manager) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { +func (m *Manager) getRecord(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error) { rec, ok, err := m.store.GetSession(ctx, id) if err != nil { - return domain.Session{}, fmt.Errorf("get %s: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("get %s: %w", id, err) } if !ok { - return domain.Session{}, fmt.Errorf("get %s: %w", id, ErrNotFound) + return domain.SessionRecord{}, fmt.Errorf("get %s: %w", id, ErrNotFound) } - return m.toSession(ctx, rec) + return rec, nil } // Send delivers a message to a running session's agent via the messenger. @@ -269,17 +250,6 @@ func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) ([]doma // ---- helpers ---- -func (m *Manager) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) { - pr, ok, err := m.store.GetDisplayPRFactsForSession(ctx, rec.ID) - if err != nil { - return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err) - } - if !ok { - return domain.Session{SessionRecord: rec, Status: domain.DeriveStatus(rec, nil)}, nil - } - return domain.Session{SessionRecord: rec, Status: domain.DeriveStatus(rec, &pr)}, nil -} - func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { return domain.SessionRecord{ ProjectID: cfg.ProjectID, diff --git a/backend/internal/session/manager_test.go b/backend/internal/session_manager/manager_test.go similarity index 92% rename from backend/internal/session/manager_test.go rename to backend/internal/session_manager/manager_test.go index f682a51af0..22ef787551 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -1,4 +1,4 @@ -package session +package sessionmanager import ( "context" @@ -149,8 +149,8 @@ func TestSpawn_AssignsIDAndGoesIdle(t *testing.T) { if s.ID != "mer-1" { t.Fatalf("got %q", s.ID) } - if s.Status != domain.StatusIdle { - t.Fatalf("fresh session displays idle, got %q", s.Status) + if s.Activity.State != domain.ActivityIdle { + t.Fatalf("fresh session records idle, got %q", s.Activity.State) } if rt.created != 1 { t.Fatal("runtime not created") @@ -197,8 +197,8 @@ func TestRestore_ReopensTerminal(t *testing.T) { if err != nil { t.Fatal(err) } - if s.Status != domain.StatusIdle { - t.Fatalf("restored displays idle, got %q", s.Status) + if s.Activity.State != domain.ActivityIdle { + t.Fatalf("restored records idle, got %q", s.Activity.State) } if rt.created != 1 { t.Fatal("restore should relaunch") @@ -211,18 +211,6 @@ func TestRestore_RefusesLiveSession(t *testing.T) { t.Fatalf("want ErrNotRestorable, got %v", err) } } -func TestList_DerivesStatusFromPRFacts(t *testing.T) { - m, st, _, _ := newManager() - st.sessions["mer-1"] = mkLive("mer-1") - st.pr["mer-1"] = domain.PRFacts{URL: "pr1", CI: domain.CIFailing} - list, err := m.List(ctx, "mer") - if err != nil { - t.Fatal(err) - } - if len(list) != 1 || list[0].Status != domain.StatusCIFailed { - t.Fatalf("got %+v", list) - } -} func TestCleanup_ReclaimsTerminalWorkspaces(t *testing.T) { m, st, _, ws := newManager() seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1"}) diff --git a/docs/README.md b/docs/README.md index ad4c1453e6..e14cb669ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,4 +14,4 @@ Persist durable facts, derive display status: - session table: `activity_state`, `is_terminated`, identity, metadata - PR tables: PR/CI/review facts -- derived read model: `domain.DeriveStatus(session, prFacts)` +- derived read model: `service.Session` computes display status from session + PR facts diff --git a/docs/architecture.md b/docs/architecture.md index 8e768c55f5..c3c26affa6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,15 +17,16 @@ The durable session facts are: - `is_terminated` — whether the session should be treated as over. - PR facts in the `pr`, `pr_checks`, and `pr_comment` tables. -The UI status is not stored. `domain.DeriveStatus` computes it from the session -record plus PR facts. +The UI status is not stored. `service.Session` computes it from the session +record plus PR facts while assembling controller-facing read models. ## Package layout ``` -backend/internal/domain shared vocabulary and display-status derivation +backend/internal/domain shared vocabulary and API status value types backend/internal/ports inbound/outbound interfaces -backend/internal/session explicit mutations: spawn, kill, restore, send, cleanup +backend/internal/service controller-facing services and read-model assembly +backend/internal/session_manager internal session command manager backend/internal/lifecycle runtime/activity/spawn/termination session fact reducer backend/internal/pr PR observation ingestion backend/internal/storage SQLite persistence and DB-triggered CDC @@ -37,8 +38,8 @@ backend/internal/adapters Zellij/git-worktree/GitHub adapters ## Status derivation -`session.Manager` selects the display PR from all PR snapshots for a session, then -`domain.DeriveStatus(session, prFacts)` applies this rough precedence: +`service.Session` selects the display PR from all PR snapshots for a session, then +applies this rough precedence: 1. `is_terminated` → `terminated`, except merged PRs display `merged`. 2. `activity_state=waiting_input` → `needs_input`. @@ -69,14 +70,16 @@ consumed at read time for display status. ## Session manager -`session.Manager` performs explicit user mutations: +`session_manager.Manager` performs internal session mutations: - `Spawn` creates a row, creates workspace/runtime resources, and reports the handles to the lifecycle manager. - `Kill` marks the row terminated, then tears down runtime/workspace resources. - `Restore` relaunches a terminated session and clears `is_terminated` via the spawn-completed path. -- `List`/`Get` attach the derived display status. + +`service.Session` is the controller-facing boundary. It delegates commands to +`session_manager.Manager` and attaches derived display status on read paths. ## Persistence and CDC diff --git a/docs/status.md b/docs/status.md index 6ca5bc2746..1da4946a3e 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,7 +1,7 @@ # agent-orchestrator status Current main contains the Go backend daemon, Cobra CLI foundation, SQLite store, -CDC poller/broadcaster, lifecycle/session managers, terminal mux, project API +CDC poller/broadcaster, lifecycle/session services, terminal mux, project API controller/manager work, runtime/workspace/tracker adapters, and CDC-backed event rows. ## Build & test @@ -18,9 +18,11 @@ npm run lint from those plus PR facts. - SQLite: migrations create projects, sessions, PR/check/comment, and `change_log` tables. - CDC: DB triggers append to `change_log`; the poller broadcasts live events. -- Session Manager: spawn/kill/restore/list/get/send/cleanup over runtime, - workspace, agent, store, messenger, and lifecycle ports. It is package-level - code today; daemon HTTP routes for session commands are not wired yet. +- Session Manager: internal spawn/kill/restore/send/cleanup over runtime, + workspace, agent, store, messenger, and lifecycle ports. +- Service package: controller-facing session boundary that delegates commands to + the manager and assembles list/get/spawn/restore read models with display status. + Daemon HTTP routes for session commands are not wired yet. ## Next integration work From 3a93e33331236cbff27e324d99fed88938fb9a5c Mon Sep 17 00:00:00 2001 From: neversettle <41864816+neversettle17-101@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:26:48 +0530 Subject: [PATCH 101/250] refactor: move project manager to service layer (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(project): manager talks to the sqlite store; drop the in-memory store The project Manager now runs only against the durable backend store: remove the process-local MemoryStore (and NewMemoryManager), and require a real Store. The daemon already wires the sqlite store; tests now build a real temp-dir sqlite store instead of the mock. - Move Row + the Store port to project/store.go. The Store interface stays because it is the dependency-inversion port that lets the manager reach the backend without an import cycle (storage imports project.Row), not an extra mock layer — there is no longer any in-memory implementation. - NewManager requires a non-nil Store (no in-memory fallback). - Add project/manager_test.go: List/Add/Get/Remove happy paths + PATH_REQUIRED/NOT_A_GIT_REPO/PATH_ALREADY_REGISTERED/ID_ALREADY_REGISTERED, PROJECT_NOT_FOUND/INVALID_PROJECT_ID, and UpdateConfig — all against a real sqlite store (the service-logic tests #47 lacked). Co-Authored-By: Claude Opus 4.8 * refactor(project): trim routes, consolidate package, add code-first OpenAPI - Remove POST /reload, PATCH /{id}, POST /{id}/repair routes and their Manager methods (Reload, UpdateConfig, Repair) and DTOs (ReloadResult, UpdateConfigInput) — not needed at this stage - Merge Manager interface into manager.go; delete project.go (single-impl split served no purpose) - Remove dead notImplemented helper from errors.go - Port PR #59 code-first OpenAPI generation: controllers/dto.go named response types, specgen/build.go (4 routes), parity + drift tests, cmd/genspec, go generate wiring; regenerate openapi.yaml - Add swaggest deps; add YAML() method to apispec.Spec Co-Authored-By: Claude Haiku 4.5 * fix(project): address PR review comments - t.Skipf → t.Fatalf in gitRepo helper: git failures now hard-fail instead of silently skipping manager tests on a misconfigured runner - FindProjectByPath: add AND archived_at IS NULL so archived paths don't permanently block re-registration (update queries/projects.sql and generated gen/projects.sql.go) - Add TestManager_ReaddAfterRemove to lock the fix Co-Authored-By: Claude Haiku 4.5 * fixed lint and fmt * addressed greptile comments * Apply suggestions from code review Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * project tests fix * project_tests fix * fix: Linting and formatting fix * refactor: move project manager into service layer (#68) * refactor: split service package by resource (#68) * fix: ignore archived project id conflicts (#68) * refactor: move pr manager into service layer (#68) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: itrytoohard --- backend/cmd/genspec/main.go | 26 + backend/go.mod | 4 + backend/go.sum | 22 + backend/internal/adapters/scm/github/doc.go | 2 +- backend/internal/cdc/cdc_test.go | 3 +- backend/internal/daemon/daemon.go | 4 +- backend/internal/daemon/wiring_test.go | 3 +- backend/internal/domain/project.go | 13 + backend/internal/httpd/api.go | 4 +- backend/internal/httpd/apispec/apispec.go | 10 +- backend/internal/httpd/apispec/gen.go | 6 + backend/internal/httpd/apispec/openapi.yaml | 1175 +++++++++-------- backend/internal/httpd/apispec/parity_test.go | 66 + .../internal/httpd/apispec/specgen/build.go | 362 +++++ .../httpd/apispec/specgen/build_test.go | 40 + backend/internal/httpd/controllers/dto.go | 176 +++ .../internal/httpd/controllers/projects.go | 107 +- .../httpd/controllers/projects_test.go | 311 ++++- .../internal/httpd/controllers/sessions.go | 57 +- .../httpd/controllers/sessions_test.go | 4 +- .../integration/lifecycle_sqlite_test.go | 11 +- backend/internal/project/dto.go | 53 - backend/internal/project/memory_store.go | 117 -- backend/internal/project/project.go | 41 - backend/internal/{ => service}/pr/manager.go | 0 .../internal/{ => service}/pr/manager_test.go | 0 backend/internal/service/project/dto.go | 23 + .../internal/{ => service}/project/errors.go | 6 +- .../manager.go => service/project/service.go} | 111 +- .../internal/service/project/service_test.go | 156 +++ backend/internal/service/project/store.go | 17 + .../internal/{ => service}/project/types.go | 33 +- .../{session.go => session/service.go} | 42 +- .../service_test.go} | 22 +- .../{session_status.go => session/status.go} | 2 +- .../status_test.go} | 2 +- .../storage/sqlite/gen/projects.sql.go | 2 +- .../storage/sqlite/queries/projects.sql | 4 +- .../storage/sqlite/store/project_store.go | 37 +- .../storage/sqlite/store/store_test.go | 13 +- 40 files changed, 1987 insertions(+), 1100 deletions(-) create mode 100644 backend/cmd/genspec/main.go create mode 100644 backend/internal/domain/project.go create mode 100644 backend/internal/httpd/apispec/gen.go create mode 100644 backend/internal/httpd/apispec/parity_test.go create mode 100644 backend/internal/httpd/apispec/specgen/build.go create mode 100644 backend/internal/httpd/apispec/specgen/build_test.go create mode 100644 backend/internal/httpd/controllers/dto.go delete mode 100644 backend/internal/project/dto.go delete mode 100644 backend/internal/project/memory_store.go delete mode 100644 backend/internal/project/project.go rename backend/internal/{ => service}/pr/manager.go (100%) rename backend/internal/{ => service}/pr/manager_test.go (100%) create mode 100644 backend/internal/service/project/dto.go rename backend/internal/{ => service}/project/errors.go (82%) rename backend/internal/{project/manager.go => service/project/service.go} (64%) create mode 100644 backend/internal/service/project/service_test.go create mode 100644 backend/internal/service/project/store.go rename backend/internal/{ => service}/project/types.go (54%) rename backend/internal/service/{session.go => session/service.go} (73%) rename backend/internal/service/{session_test.go => session/service_test.go} (54%) rename backend/internal/service/{session_status.go => session/status.go} (98%) rename backend/internal/service/{session_status_test.go => session/status_test.go} (99%) diff --git a/backend/cmd/genspec/main.go b/backend/cmd/genspec/main.go new file mode 100644 index 0000000000..c2310e9422 --- /dev/null +++ b/backend/cmd/genspec/main.go @@ -0,0 +1,26 @@ +// Command genspec writes the code-first OpenAPI document produced by +// apispec.Build() to disk. It is invoked via `go generate` (see +// internal/httpd/apispec/gen.go); the output openapi.yaml is committed and +// embedded by the apispec package. +package main + +import ( + "flag" + "log" + "os" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec/specgen" +) + +func main() { + out := flag.String("out", "openapi.yaml", "output path for the generated OpenAPI document") + flag.Parse() + + doc, err := specgen.Build() + if err != nil { + log.Fatalf("genspec: build openapi: %v", err) + } + if err := os.WriteFile(*out, doc, 0o600); err != nil { + log.Fatalf("genspec: write %s: %v", *out, err) + } +} diff --git a/backend/go.mod b/backend/go.mod index a2de66a0a0..70689d8518 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,6 +8,8 @@ require ( github.com/go-chi/chi/v5 v5.1.0 github.com/pressly/goose/v3 v3.27.1 github.com/spf13/cobra v1.10.1 + github.com/swaggest/jsonschema-go v0.3.79 + github.com/swaggest/openapi-go v0.2.61 golang.org/x/sys v0.43.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.51.0 @@ -23,8 +25,10 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/swaggest/refl v1.4.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.20.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index cf3f00291e..718a4a1156 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,7 @@ +github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= +github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -15,6 +19,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= @@ -30,6 +36,8 @@ github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76cs github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= @@ -38,6 +46,18 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= +github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= +github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= +github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= +github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= +github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= +github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= @@ -50,6 +70,8 @@ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/backend/internal/adapters/scm/github/doc.go b/backend/internal/adapters/scm/github/doc.go index 8dee9a3467..6bc7b1464d 100644 --- a/backend/internal/adapters/scm/github/doc.go +++ b/backend/internal/adapters/scm/github/doc.go @@ -114,7 +114,7 @@ // // - The poller loop and cadence selection (issue #35). // - Webhook ingestion (this package is polling-only). -// - Persistence (PR Manager owns the row mapping; see internal/pr). +// - Persistence (PR Manager owns the row mapping; see internal/service/pr). // - Linear / GitLab providers (separate PRs). // - Issue tracking (separate lane, see internal/adapters/tracker). // - Comment-injection-into-session-context (Messenger lane, not SCM). diff --git a/backend/internal/cdc/cdc_test.go b/backend/internal/cdc/cdc_test.go index 6196120fdd..59d9e69062 100644 --- a/backend/internal/cdc/cdc_test.go +++ b/backend/internal/cdc/cdc_test.go @@ -9,7 +9,6 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/cdc" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -27,7 +26,7 @@ func seedSession(t *testing.T, s *sqlite.Store) domain.SessionRecord { t.Helper() ctx := context.Background() now := time.Now().UTC().Truncate(time.Second) - if err := s.Upsert(ctx, project.Row{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil { + if err := s.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil { t.Fatal(err) } r, err := s.CreateSession(ctx, domain.SessionRecord{ diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index b8d89053e9..59926922e8 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -14,8 +14,8 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" - "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) @@ -66,7 +66,7 @@ func Run() error { termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) defer termMgr.Close() - srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: project.NewManager(store)}) + srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: projectsvc.New(store)}) if err != nil { stop() if cdcErr := cdcPipe.Stop(); cdcErr != nil { diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 6d6dae0426..d8c7a610c6 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -10,7 +10,6 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -37,7 +36,7 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { var got []cdc.Event bcast.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) - if err := store.Upsert(ctx, project.Row{ID: "mer", Path: "/repo/mer"}); err != nil { + if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer"}); err != nil { t.Fatal(err) } rec, err := store.CreateSession(ctx, domain.SessionRecord{ diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go new file mode 100644 index 0000000000..b00e65c758 --- /dev/null +++ b/backend/internal/domain/project.go @@ -0,0 +1,13 @@ +package domain + +import "time" + +// ProjectRecord is the durable project registry row used by storage and services. +type ProjectRecord struct { + ID string + Path string + RepoOriginURL string + DisplayName string + RegisteredAt time.Time + ArchivedAt time.Time +} diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index a61ba78497..b174197278 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -10,7 +10,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" - "github.com/aoagents/agent-orchestrator/backend/internal/project" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) // APIDeps bundles every Manager the API layer's controllers depend on. @@ -18,7 +18,7 @@ import ( // lifecycle reducers, adapters, or storage. A nil dependency keeps its routes // registered but returns the OpenAPI-backed 501 response. type APIDeps struct { - Projects project.Manager + Projects projectsvc.Manager Sessions controllers.SessionService } diff --git a/backend/internal/httpd/apispec/apispec.go b/backend/internal/httpd/apispec/apispec.go index 2603820fa8..971958533e 100644 --- a/backend/internal/httpd/apispec/apispec.go +++ b/backend/internal/httpd/apispec/apispec.go @@ -27,7 +27,8 @@ var openapiYAML []byte // preserves the YAML shape verbatim so the JSON we emit on 501 responses // matches the on-disk source. type Spec struct { - doc map[string]any + doc map[string]any + rawYAML []byte } var ( @@ -61,7 +62,12 @@ func New(yamlBytes []byte) (*Spec, error) { if doc == nil { return nil, fmt.Errorf("parse openapi: empty document") } - return &Spec{doc: doc}, nil + return &Spec{doc: doc, rawYAML: yamlBytes}, nil +} + +// YAML returns the raw YAML bytes this spec was built from. +func (s *Spec) YAML() []byte { + return s.rawYAML } // Operation returns the spec slice for a single (method, path) pair, ready diff --git a/backend/internal/httpd/apispec/gen.go b/backend/internal/httpd/apispec/gen.go new file mode 100644 index 0000000000..cd89585009 --- /dev/null +++ b/backend/internal/httpd/apispec/gen.go @@ -0,0 +1,6 @@ +package apispec + +// openapi.yaml is generated from Go (see build.go) — do not edit it by hand. +// Regenerate with `go generate ./...` from the backend module root. +// +//go:generate go run ../../../cmd/genspec -out openapi.yaml diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index b626c57880..c121ffe49b 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1,721 +1,824 @@ openapi: 3.1.0 info: + description: Loopback-only HTTP surface served by the Go daemon. Generated from + Go (code-first) — do not edit by hand; run `go generate ./...`. title: Agent Orchestrator HTTP daemon - version: 0.1.0 - description: | - Loopback-only HTTP surface served by the Go daemon. This document describes - the registered /api/v1 project routes and the shared error envelope used by - OpenAPI-backed 501 responses. Daemon control endpoints such as /healthz, - /readyz, /shutdown, and /mux are intentionally outside this REST spec. - + version: 0.1.0-route-shell servers: - - url: http://127.0.0.1:3001 - description: Local daemon (loopback only) - -tags: - - name: projects - description: Project registry, configuration, and lifecycle administration - - name: sessions - description: Agent session lifecycle and messaging - +- description: Local daemon (loopback only) + url: http://127.0.0.1:3001 paths: - /api/v1/projects: - get: - operationId: listProjects - tags: [projects] - summary: List active registered projects - responses: - "200": - description: Projects listed - content: - application/json: - schema: - type: object - required: [projects] - properties: - projects: - type: array - items: { $ref: "#/components/schemas/ProjectSummary" } - "500": - description: Failed to load projects - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: PROJECTS_LIST_FAILED, message: "Failed to load projects" } - "501": { $ref: "#/components/responses/NotImplemented" } - + /api/v1/orchestrators: post: - operationId: addProject - tags: [projects] - summary: Register a new project from a git repository path + operationId: spawnOrchestrator requestBody: - required: true content: application/json: - schema: { $ref: "#/components/schemas/AddProjectRequest" } + schema: + $ref: '#/components/schemas/SpawnOrchestratorRequest' + required: true responses: "201": - description: Project registered content: application/json: schema: - type: object - required: [project] - properties: - project: { $ref: "#/components/schemas/Project" } + $ref: '#/components/schemas/SpawnOrchestratorResponse' + description: Created "400": - description: Bad request content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - pathRequired: { value: { error: bad_request, code: PATH_REQUIRED, message: "Repository path is required" } } - notAGitRepo: { value: { error: bad_request, code: NOT_A_GIT_REPO, message: "Repository path must point to a git repository" } } - "409": - description: Conflict with an already-registered project - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - pathAlready: - value: - error: conflict - code: PATH_ALREADY_REGISTERED - message: "A project at this path is already registered" - details: - existingProjectId: existing-project-id - suggestedProjectId: suggested-project-id - idAlready: - value: - error: conflict - code: ID_ALREADY_REGISTERED - message: "A project with this id is already registered for a different path" - details: - existingProjectId: existing-project-id - suggestedProjectId: suggested-project-id - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/projects/reload: - post: - operationId: reloadProjects - tags: [projects] - summary: Invalidate cached config and re-scan the global registry - responses: - "200": - description: Reload complete + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": content: application/json: - schema: { $ref: "#/components/schemas/ReloadResult" } + schema: + $ref: '#/components/schemas/APIError' + description: Not Found "500": - description: Reload failed content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: RELOAD_FAILED, message: "Failed to reload projects" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/projects/{id}: - parameters: - - $ref: "#/components/parameters/ProjectIDPath" + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Spawn an orchestrator session + tags: + - sessions + /api/v1/projects: get: - operationId: getProject - tags: [projects] - summary: Fetch one project; discriminates ok vs degraded + operationId: listProjects responses: "200": - description: Project resolved (status discriminates ok vs degraded) content: application/json: - schema: { $ref: "#/components/schemas/ProjectGetResponse" } - "404": { $ref: "#/components/responses/ProjectNotFound" } + schema: + $ref: '#/components/schemas/ListProjectsResponse' + description: OK "500": - description: Failed to load project content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: PROJECT_LOAD_FAILED, message: "Failed to load project" } - "501": { $ref: "#/components/responses/NotImplemented" } - patch: - operationId: updateProjectConfig - tags: [projects] - summary: Patch behaviour-only fields (not implemented until config persistence lands) + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: List all registered projects (active + degraded) + tags: + - projects + post: + operationId: addProject requestBody: - required: true content: application/json: - schema: { $ref: "#/components/schemas/UpdateProjectConfigRequest" } + schema: + $ref: '#/components/schemas/AddProjectInput' + required: true responses: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectResponse' + description: Created "400": - description: Bad request - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - identityFrozen: - value: - error: bad_request - code: IDENTITY_FROZEN - message: "Identity fields cannot be patched" - details: { fields: [projectId, path, repo, defaultBranch] } - invalidConfig: { value: { error: bad_request, code: INVALID_LOCAL_CONFIG, message: "Local project config failed validation" } } - "404": { $ref: "#/components/responses/ProjectNotFound" } + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request "409": - description: Project not in a patchable state content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - degraded: { value: { error: conflict, code: PROJECT_DEGRADED, message: "Project config is degraded; repair before patching" } } - missingPath: { value: { error: conflict, code: PROJECT_MISSING_PATH, message: "Project registry entry is missing a path" } } - "501": - description: Behaviour config persistence is not wired yet + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: not_implemented, code: PROJECT_CONFIG_NOT_IMPLEMENTED, message: "Project config patching is not available until config persistence is wired" } + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Register a new project from a git repository path + tags: + - projects + /api/v1/projects/{id}: delete: operationId: removeProject - tags: [projects] - summary: Archive a project; hides it from active lists while preserving id references + parameters: + - description: Project identifier (registry key). + in: path + name: id + required: true + schema: + description: Project identifier (registry key). + type: string responses: "200": - description: Project archived content: application/json: - schema: { $ref: "#/components/schemas/RemoveProjectResult" } + schema: + $ref: '#/components/schemas/RemoveProjectResult' + description: OK "400": - description: Invalid project id content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: bad_request, code: INVALID_PROJECT_ID, message: "Project id failed storage-path validation" } - "404": { $ref: "#/components/responses/ProjectNotFound" } + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found "500": - description: Removal failed content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: PROJECT_REMOVE_FAILED, message: "Failed to remove project" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/projects/{id}/repair: - parameters: - - $ref: "#/components/parameters/ProjectIDPath" - post: - operationId: repairProject - tags: [projects] - summary: Repair a degraded project where automatic recovery is available - x-replaces: - - "POST /api/v1/projects/{id}" + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Remove a project; stops sessions, cleans workspaces, unregisters + tags: + - projects + get: + operationId: getProject + parameters: + - description: Project identifier (registry key). + in: path + name: id + required: true + schema: + description: Project identifier (registry key). + type: string responses: "200": - description: Project repaired content: application/json: schema: - type: object - required: [project] - properties: - project: { $ref: "#/components/schemas/Project" } - "400": - description: Bad request + $ref: '#/components/schemas/ProjectGetResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - notDegraded: { value: { error: bad_request, code: PROJECT_NOT_DEGRADED, message: "Project does not need repair" } } - notAvailable: { value: { error: bad_request, code: REPAIR_NOT_AVAILABLE, message: "Automatic repair is not available for this degraded config" } } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "501": { $ref: "#/components/responses/NotImplemented" } - + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Fetch one project; discriminates ok vs degraded + tags: + - projects /api/v1/sessions: get: operationId: listSessions - tags: [sessions] - summary: List sessions parameters: - - name: project - in: query - schema: { type: string } - - name: active - in: query - schema: { type: boolean } - - name: orchestratorOnly - in: query - schema: { type: boolean } - - name: fresh - in: query - schema: { type: boolean } + - description: Project id filter. + in: query + name: project + schema: + description: Project id filter. + type: string + - description: When true, return non-terminated sessions; when false, return + terminated sessions. + in: query + name: active + schema: + description: When true, return non-terminated sessions; when false, return + terminated sessions. + type: + - "null" + - boolean + - description: When true, return only orchestrator sessions. + in: query + name: orchestratorOnly + schema: + description: When true, return only orchestrator sessions. + type: + - "null" + - boolean + - description: When true, return only fresh non-terminated sessions. + in: query + name: fresh + schema: + description: When true, return only fresh non-terminated sessions. + type: + - "null" + - boolean responses: "200": - description: Sessions listed content: application/json: schema: - type: object - required: [sessions] - properties: - sessions: - type: array - items: { $ref: "#/components/schemas/Session" } + $ref: '#/components/schemas/ListSessionsResponse' + description: OK "400": - description: Bad request content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - "501": { $ref: "#/components/responses/NotImplemented" } + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: List sessions + tags: + - sessions post: operationId: spawnSession - tags: [sessions] - summary: Spawn a new agent session requestBody: - required: true content: application/json: - schema: { $ref: "#/components/schemas/SpawnSessionRequest" } + schema: + $ref: '#/components/schemas/SpawnSessionRequest' + required: true responses: "201": - description: Session spawned content: application/json: schema: - type: object - required: [session] - properties: - session: { $ref: "#/components/schemas/Session" } + $ref: '#/components/schemas/SessionResponse' + description: Created "400": - description: Bad request - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - projectRequired: { value: { error: bad_request, code: PROJECT_ID_REQUIRED, message: "projectId is required" } } - promptTooLong: { value: { error: bad_request, code: PROMPT_TOO_LONG, message: "prompt is too long" } } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "500": { $ref: "#/components/responses/SessionOperationFailed" } - "501": { $ref: "#/components/responses/NotImplemented" } - + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Spawn a new agent session + tags: + - sessions /api/v1/sessions/{sessionId}: - parameters: - - $ref: "#/components/parameters/SessionIDPath" get: operationId: getSession - tags: [sessions] - summary: Fetch one session + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string responses: "200": - description: Session fetched content: application/json: schema: - type: object - required: [session] - properties: - session: { $ref: "#/components/schemas/Session" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "501": { $ref: "#/components/responses/NotImplemented" } + $ref: '#/components/schemas/SessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Fetch one session + tags: + - sessions patch: operationId: renameSession - tags: [sessions] - summary: Rename a session display name - requestBody: + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId required: true + schema: + description: Session identifier, e.g. project-1. + type: string + requestBody: content: application/json: schema: - type: object - required: [displayName] - properties: - displayName: { type: string, minLength: 1 } + $ref: '#/components/schemas/RenameSessionRequest' + required: true responses: "200": - description: Session renamed content: application/json: schema: - type: object - required: [session] - properties: - session: { $ref: "#/components/schemas/Session" } + $ref: '#/components/schemas/SessionResponse' + description: OK "400": - description: Bad request content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/sessions/{sessionId}/restore: - parameters: - - $ref: "#/components/parameters/SessionIDPath" - post: - operationId: restoreSession - tags: [sessions] - summary: Restore a terminated session - responses: - "200": - description: Session restored + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "501": content: application/json: schema: - type: object - required: [ok, sessionId, session] - properties: - ok: { type: boolean } - sessionId: { type: string } - session: { $ref: "#/components/schemas/Session" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "409": { $ref: "#/components/responses/SessionConflict" } - "501": { $ref: "#/components/responses/NotImplemented" } - + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Rename a session display name + tags: + - sessions /api/v1/sessions/{sessionId}/kill: - parameters: - - $ref: "#/components/parameters/SessionIDPath" post: operationId: killSession - tags: [sessions] - summary: Mark a session terminated and tear down runtime/workspace resources + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string responses: "200": - description: Kill attempted content: application/json: - schema: { $ref: "#/components/schemas/KillSessionResponse" } - "404": { $ref: "#/components/responses/SessionNotFound" } - "409": { $ref: "#/components/responses/SessionConflict" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/sessions/{sessionId}/send: - parameters: - - $ref: "#/components/parameters/SessionIDPath" + schema: + $ref: '#/components/schemas/KillSessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Mark a session terminated and tear down runtime/workspace resources + tags: + - sessions + /api/v1/sessions/{sessionId}/restore: post: - operationId: sendSessionMessage - tags: [sessions] - summary: Send a message to a running session's agent - requestBody: + operationId: restoreSession + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId required: true - content: - application/json: - schema: { $ref: "#/components/schemas/SendSessionMessageRequest" } + schema: + description: Session identifier, e.g. project-1. + type: string responses: "200": - description: Message accepted content: application/json: - schema: { $ref: "#/components/schemas/SendSessionMessageResponse" } - "400": - description: Bad request + schema: + $ref: '#/components/schemas/RestoreSessionResponse' + description: OK + "404": content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - invalidJson: { value: { error: bad_request, code: INVALID_JSON, message: "Invalid JSON body" } } - messageRequired: { value: { error: bad_request, code: MESSAGE_REQUIRED, message: "Message is required" } } - "404": { $ref: "#/components/responses/SessionNotFound" } - "500": { $ref: "#/components/responses/SessionOperationFailed" } - "501": { $ref: "#/components/responses/NotImplemented" } - - /api/v1/orchestrators: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Restore a terminated session + tags: + - sessions + /api/v1/sessions/{sessionId}/send: post: - operationId: spawnOrchestrator - tags: [sessions] - summary: Spawn an orchestrator session - requestBody: + operationId: sendSessionMessage + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId required: true + schema: + description: Session identifier, e.g. project-1. + type: string + requestBody: content: application/json: - schema: { $ref: "#/components/schemas/SpawnOrchestratorRequest" } + schema: + $ref: '#/components/schemas/SendSessionMessageRequest' + required: true responses: - "201": - description: Orchestrator spawned + "200": content: application/json: - schema: { $ref: "#/components/schemas/SpawnOrchestratorResponse" } + schema: + $ref: '#/components/schemas/SendSessionMessageResponse' + description: OK "400": - description: Bad request content: application/json: - schema: { $ref: "#/components/schemas/APIError" } - "404": { $ref: "#/components/responses/ProjectNotFound" } - "500": { $ref: "#/components/responses/SessionOperationFailed" } - "501": { $ref: "#/components/responses/NotImplemented" } - + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Send a message to a running session's agent + tags: + - sessions components: - parameters: - ProjectIDPath: - name: id - in: path - required: true - schema: { type: string, minLength: 1 } - description: Project identifier (registry key). - - SessionIDPath: - name: sessionId - in: path - required: true - schema: { type: string, minLength: 1 } - description: Session identifier, e.g. project-1. - - responses: - NotImplemented: - description: | - Route is registered but the handler has not been implemented yet. - The body carries the locked APIError envelope plus a `spec` field - containing this operation's slice of the OpenAPI document so - callers can discover the contract from the endpoint itself. - content: - application/json: - schema: { $ref: "#/components/schemas/NotImplementedResponse" } - - ProjectNotFound: - description: Project not found - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: not_found, code: PROJECT_NOT_FOUND, message: "Unknown project" } - - SessionNotFound: - description: Session not found - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: not_found, code: SESSION_NOT_FOUND, message: "Unknown session" } - - SessionConflict: - description: Session is not in a valid state for the requested operation - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - examples: - notRestorable: { value: { error: conflict, code: SESSION_NOT_RESTORABLE, message: "Session is not restorable" } } - incompleteHandle: { value: { error: conflict, code: SESSION_INCOMPLETE_HANDLE, message: "Session is missing runtime or workspace handles" } } - - SessionOperationFailed: - description: Session operation failed - content: - application/json: - schema: { $ref: "#/components/schemas/APIError" } - example: { error: internal, code: SESSION_OPERATION_FAILED, message: "Session operation failed" } - schemas: APIError: - type: object - required: [error, code, message] properties: - error: { type: string, description: "Short kind, e.g. not_found" } - code: { type: string, description: "SCREAMING_SNAKE machine code" } - message: { type: string, description: "Human-readable detail" } - requestId: { type: string } + code: + type: string details: + additionalProperties: {} type: object - additionalProperties: true - - NotImplementedResponse: - allOf: - - $ref: "#/components/schemas/APIError" - - type: object - required: [spec] - properties: - spec: - type: object - description: | - The OpenAPI Operation object for this method+path, served - inline so consumers discover the contract from the 501 - response without fetching the full spec. Mirrors the YAML - shape — see /api/v1/openapi.yaml for the full document. - - ProjectSummary: + error: + type: string + message: + type: string + requestId: + type: string + required: + - error + - code + - message type: object - required: [id, name, sessionPrefix] + AddProjectInput: properties: - id: { type: string } - name: { type: string } - sessionPrefix: { type: string } - resolveError: + name: + type: + - "null" + - string + path: type: string - description: Present iff the project is degraded. - - Project: + projectId: + type: + - "null" + - string + required: + - path type: object - required: [id, name, path, repo, defaultBranch] + DegradedProject: properties: - id: { type: string } - name: { type: string } - path: { type: string } - repo: + id: type: string - description: "\"owner/name\" or empty string when unset" - defaultBranch: { type: string, default: main } - agent: { type: string } - tracker: { $ref: "#/components/schemas/TrackerConfig" } - scm: { $ref: "#/components/schemas/SCMConfig" } - - DegradedProject: + name: + type: string + path: + type: string + resolveError: + type: string + required: + - id + - name + - path + - resolveError type: object - required: [id, name, path, resolveError] + DomainActivity: properties: - id: { type: string } - name: { type: string } - path: { type: string } - resolveError: { type: string } - - ProjectGetResponse: + lastActivityAt: + format: date-time + type: string + state: + type: string + required: + - state + - lastActivityAt type: object - required: [status, project] + KillSessionResponse: properties: - status: + freed: + type: boolean + ok: + type: boolean + sessionId: type: string - enum: [ok, degraded] - project: - oneOf: - - $ref: "#/components/schemas/Project" - - $ref: "#/components/schemas/DegradedProject" - AddProjectRequest: + required: + - ok + - sessionId type: object - required: [path] + ListProjectsResponse: properties: - path: + projects: + items: + $ref: '#/components/schemas/ProjectSummary' + type: array + required: + - projects + type: object + ListSessionsResponse: + properties: + sessions: + items: + $ref: '#/components/schemas/Session' + type: array + required: + - sessions + type: object + OrchestratorResponse: + properties: + id: type: string - description: Repository path; supports ~ home-expansion. Must be a git repo. projectId: type: string - description: Optional override; defaults to basename(path). + projectName: + type: string + required: + - id + - projectId + type: object + Project: + properties: + agent: + type: string + defaultBranch: + type: string + id: + type: string name: type: string - description: Optional display name; defaults to projectId. - - UpdateProjectConfigRequest: + path: + type: string + repo: + type: string + scm: + $ref: '#/components/schemas/SCMConfig' + tracker: + $ref: '#/components/schemas/TrackerConfig' + required: + - id + - name + - path + - repo + - defaultBranch type: object - description: | - Behaviour-only patch. Identity fields (projectId, path, repo, - defaultBranch) are rejected with 400 IDENTITY_FROZEN. The current Go - handler returns 501 PROJECT_CONFIG_NOT_IMPLEMENTED until config - persistence exists. + ProjectGetResponse: properties: - agent: { type: string } - tracker: { $ref: "#/components/schemas/TrackerConfig" } - scm: { $ref: "#/components/schemas/SCMConfig" } - - RemoveProjectResult: + project: + $ref: '#/components/schemas/ProjectOrDegraded' + status: + enum: + - ok + - degraded + type: string + required: + - status + - project + type: object + ProjectOrDegraded: + oneOf: + - $ref: '#/components/schemas/Project' + - $ref: '#/components/schemas/DegradedProject' type: object - required: [projectId, removedStorageDir] + ProjectResponse: properties: - projectId: { type: string } - removedStorageDir: { type: boolean } - - ReloadResult: + project: + $ref: '#/components/schemas/Project' + required: + - project type: object - required: [reloaded, projectCount, degradedCount] + ProjectSummary: properties: - reloaded: { type: boolean } - projectCount: { type: integer } - degradedCount: { type: integer } - - - Session: + id: + type: string + name: + type: string + resolveError: + type: string + sessionPrefix: + type: string + required: + - id + - name + - sessionPrefix type: object - required: [id, projectId, kind, activity, isTerminated, createdAt, updatedAt, status] + RemoveProjectResult: properties: - id: { type: string } - projectId: { type: string } - issueId: { type: string } - kind: { type: string, enum: [worker, orchestrator] } - harness: { type: string, enum: ["", claude-code, codex, aider, opencode] } - activity: { $ref: "#/components/schemas/SessionActivity" } - isTerminated: { type: boolean } - createdAt: { type: string, format: date-time } - updatedAt: { type: string, format: date-time } - status: + projectId: type: string - enum: [working, pr_open, draft, ci_failed, review_pending, changes_requested, approved, mergeable, merged, needs_input, idle, terminated] - - SessionActivity: + removedStorageDir: + type: boolean + required: + - projectId + - removedStorageDir type: object - required: [state, lastActivityAt] + RenameSessionRequest: properties: - state: { type: string, enum: [active, idle, waiting_input, exited] } - lastActivityAt: { type: string, format: date-time } - - SpawnSessionRequest: + displayName: + minLength: 1 + type: string + required: + - displayName type: object - required: [projectId] + RestoreSessionResponse: properties: - projectId: { type: string } - issueId: { type: string } - kind: { type: string, enum: [worker, orchestrator], default: worker } - harness: { type: string, enum: ["", claude-code, codex, aider, opencode] } - branch: { type: string } - prompt: { type: string, maxLength: 4096 } - agentRules: { type: string } - - SendSessionMessageRequest: + ok: + type: boolean + session: + $ref: '#/components/schemas/Session' + sessionId: + type: string + required: + - ok + - sessionId + - session + type: object + SCMConfig: + properties: + package: + type: string + path: + type: string + plugin: + type: string + webhook: + $ref: '#/components/schemas/SCMWebhookConfig' + type: object + SCMWebhookConfig: + properties: + deliveryHeader: + type: string + enabled: + type: + - "null" + - boolean + eventHeader: + type: string + maxBodyBytes: + type: integer + path: + type: string + secretEnvVar: + type: string + signatureHeader: + type: string type: object - required: [message] + SendSessionMessageRequest: properties: - message: { type: string, minLength: 1, maxLength: 4096 } - + message: + maxLength: 4096 + minLength: 1 + type: string + required: + - message + type: object SendSessionMessageResponse: + properties: + message: + type: string + ok: + type: boolean + sessionId: + type: string + required: + - ok + - sessionId + - message type: object - required: [ok, sessionId, message] + Session: properties: - ok: { type: boolean } - sessionId: { type: string } - message: { type: string } - - KillSessionResponse: + activity: + $ref: '#/components/schemas/DomainActivity' + createdAt: + format: date-time + type: string + harness: + type: string + id: + type: string + isTerminated: + type: boolean + issueId: + type: string + kind: + type: string + projectId: + type: string + status: + type: string + updatedAt: + format: date-time + type: string + required: + - id + - projectId + - kind + - activity + - isTerminated + - createdAt + - updatedAt + - status type: object - required: [ok, sessionId] + SessionResponse: properties: - ok: { type: boolean } - sessionId: { type: string } - freed: { type: boolean } - - SpawnOrchestratorRequest: + session: + $ref: '#/components/schemas/Session' + required: + - session type: object - required: [projectId] + SpawnOrchestratorRequest: properties: - projectId: { type: string } - clean: { type: boolean, default: false } - - SpawnOrchestratorResponse: + clean: + type: boolean + projectId: + type: string + required: + - projectId type: object - required: [orchestrator] + SpawnOrchestratorResponse: properties: orchestrator: - type: object - required: [id, projectId] - properties: - id: { type: string } - projectId: { type: string } - projectName: { type: string } - - # ---- Behaviour config blobs ---- - - TrackerConfig: + $ref: '#/components/schemas/OrchestratorResponse' + required: + - orchestrator type: object - additionalProperties: true + SpawnSessionRequest: properties: - plugin: { type: string } - package: { type: string } - path: { type: string } - - SCMConfig: + agentRules: + type: string + branch: + type: string + harness: + enum: + - claude-code + - codex + - aider + - opencode + type: string + issueId: + type: string + kind: + enum: + - worker + - orchestrator + type: string + projectId: + type: string + prompt: + maxLength: 4096 + type: string + required: + - projectId type: object - additionalProperties: true + TrackerConfig: properties: - plugin: { type: string } - package: { type: string } - path: { type: string } - webhook: - type: object - properties: - enabled: { type: boolean } - path: { type: string } - secretEnvVar: { type: string } - signatureHeader: { type: string } - eventHeader: { type: string } - deliveryHeader: { type: string } - maxBodyBytes: { type: integer } + package: + type: string + path: + type: string + plugin: + type: string + type: object +tags: +- description: Project registry, configuration, and lifecycle administration + name: projects +- description: Agent session lifecycle and messaging + name: sessions diff --git a/backend/internal/httpd/apispec/parity_test.go b/backend/internal/httpd/apispec/parity_test.go new file mode 100644 index 0000000000..5bd294104d --- /dev/null +++ b/backend/internal/httpd/apispec/parity_test.go @@ -0,0 +1,66 @@ +package apispec_test + +import ( + "io" + "log/slog" + "net/http" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + yaml "gopkg.in/yaml.v3" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" +) + +// TestRouteSpecParity asserts the mounted /api/v1 routes and the OpenAPI +// operations are in 1:1 correspondence — so a route can't be added without +// spec coverage, and the spec can't describe a route that isn't served. +func TestRouteSpecParity(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + router := httpd.NewRouter(config.Config{}, log, nil) + + mounted := map[string]bool{} + err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + if strings.HasPrefix(route, "/api/v1/") && route != "/api/v1/openapi.yaml" { + mounted[strings.ToUpper(method)+" "+route] = true + } + return nil + }) + if err != nil { + t.Fatalf("walk routes: %v", err) + } + if len(mounted) == 0 { + t.Fatal("no /api/v1 routes mounted — router wiring changed?") + } + + // Forward: every mounted route resolves to an operation slice. + for r := range mounted { + mp := strings.SplitN(r, " ", 2) + if apispec.Default().Operation(mp[0], mp[1]) == nil { + t.Errorf("mounted route %s has no OpenAPI operation", r) + } + } + + // Reverse: every spec operation is a mounted route. + var doc struct { + Paths map[string]map[string]yaml.Node `yaml:"paths"` + } + if err := yaml.Unmarshal(apispec.Default().YAML(), &doc); err != nil { + t.Fatalf("parse spec: %v", err) + } + httpMethods := map[string]bool{"get": true, "post": true, "put": true, "patch": true, "delete": true} + for path, item := range doc.Paths { + for method := range item { + if !httpMethods[method] { + continue // skip parameters, summary, etc. + } + key := strings.ToUpper(method) + " " + path + if !mounted[key] { + t.Errorf("spec operation %s has no mounted route", key) + } + } + } +} diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go new file mode 100644 index 0000000000..f3cc8f435b --- /dev/null +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -0,0 +1,362 @@ +// Package specgen builds the code-first OpenAPI document from the Go contract +// types. It lives outside apispec because it imports the controllers (to +// reflect their request/response shapes), and controllers import apispec (for +// the 501 stub) — keeping Build here breaks that cycle. apispec only embeds and +// serves the committed openapi.yaml; specgen produces it. +package specgen + +import ( + "fmt" + "net/http" + "reflect" + "strings" + + jsonschema "github.com/swaggest/jsonschema-go" + openapi "github.com/swaggest/openapi-go" + "github.com/swaggest/openapi-go/openapi31" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" +) + +// Build reflects the Go contract types and the operation registry below into +// the OpenAPI document. It is the single source of truth for the /api/v1 +// contract: `cmd/genspec` writes its output to apispec/openapi.yaml (the +// committed, embedded artifact) and TestBuild_MatchesEmbedded asserts the embed +// equals fresh Build() output so the two can never drift. Schema facets live as +// struct tags on the service.*/controllers.* types; operation metadata (path, +// status codes, summaries) lives here. +// +// Every wire shape is reflected straight from where it is used at runtime — the +// request bodies, path params, and response envelopes from controllers, the +// error envelope from httpd/envelope — so the served responses and the +// generated schema share one definition each. +func Build() ([]byte, error) { + r := openapi31.NewReflector() + // Derive `required` from the idiomatic Go convention: a JSON field without + // `omitempty` is required. swaggest does not infer this on its own, so the + // structs stay clean (only description/enum tags) and this hook adds the + // required array. nonNullableSlices drops the spurious "null" type swaggest + // stamps on every Go slice. + r.DefaultOptions = append(r.DefaultOptions, + jsonschema.InterceptProp(requiredFromJSONTag), + jsonschema.InterceptNullability(nonNullableSlices), + // Clean component schema names (which become the generated TS type names): + // swaggest defaults to PackageType, e.g. "ProjectProject", "EnvelopeAPIError". + jsonschema.InterceptDefName(schemaName), + ) + + r.Spec.SetTitle("Agent Orchestrator HTTP daemon") + r.Spec.SetVersion("0.1.0-route-shell") + r.Spec.SetDescription("Loopback-only HTTP surface served by the Go daemon. " + + "Generated from Go (code-first) — do not edit by hand; run `go generate ./...`.") + r.Spec.Servers = []openapi31.Server{ + *(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"), + } + r.Spec.Tags = []openapi31.Tag{ + *(&openapi31.Tag{Name: "projects"}).WithDescription( + "Project registry, configuration, and lifecycle administration"), + *(&openapi31.Tag{Name: "sessions"}).WithDescription( + "Agent session lifecycle and messaging"), + } + + for _, op := range operations() { + oc, err := r.NewOperationContext(op.method, op.path) + if err != nil { + return nil, fmt.Errorf("new operation %s %s: %w", op.method, op.path, err) + } + oc.SetID(op.id) + oc.SetSummary(op.summary) + oc.SetTags(op.tag) + for _, param := range op.pathParams { + oc.AddReqStructure(param) + } + if op.reqBody != nil { + // AddReqStructure leaves requestBody.required absent, which + // OpenAPI reads as optional. These bodies are mandatory, so force + // it — otherwise validators/generators treat the body as skippable. + oc.AddReqStructure(op.reqBody, openapi.WithCustomize(markRequestBodyRequired)) + } + for _, resp := range op.resps { + oc.AddRespStructure(resp.body, openapi.WithHTTPStatus(resp.status)) + } + if err := r.AddOperation(oc); err != nil { + return nil, fmt.Errorf("add operation %s %s: %w", op.method, op.path, err) + } + } + + return r.Spec.MarshalYAML() +} + +// schemaName maps swaggest's default PackageType component names (e.g. +// "ProjectProject", "EnvelopeAPIError") to the clean, stable schema names that +// become the generated TypeScript type names. Every reflected type is listed +// explicitly: an unrecognised default name is returned verbatim, so a new type +// surfaces as a visibly-wrong "PackageType" name in the diff (and the drift +// test) rather than silently colliding with an existing schema via a +// TrimPrefix catch-all. +func schemaName(_ reflect.Type, defaultName string) string { + if clean, ok := schemaNames[defaultName]; ok { + return clean + } + return defaultName +} + +// schemaNames is the exhaustive default→clean mapping for every type reflected +// by projectOperations(). Add an entry when a new contract type is introduced; +// the drift test fails until the spec is regenerated, which flags the gap. +var schemaNames = map[string]string{ + // httpd/envelope + "EnvelopeAPIError": "APIError", + // domain + "DomainProjectID": "ProjectID", + "DomainSessionID": "SessionID", + "DomainIssueID": "IssueID", + "DomainSession": "Session", + // httpd/controllers (wire envelopes) + "ControllersListProjectsResponse": "ListProjectsResponse", + "ControllersProjectResponse": "ProjectResponse", + "ControllersGetProjectResponse": "ProjectGetResponse", + "ControllersProjectOrDegraded": "ProjectOrDegraded", + "ControllersProjectIDParam": "ProjectIDParam", + "ControllersSessionIDParam": "SessionIDParam", + "ControllersListSessionsQuery": "ListSessionsQuery", + "ControllersListSessionsResponse": "ListSessionsResponse", + "ControllersSpawnSessionRequest": "SpawnSessionRequest", + "ControllersSessionResponse": "SessionResponse", + "ControllersRenameSessionRequest": "RenameSessionRequest", + "ControllersRestoreSessionResponse": "RestoreSessionResponse", + "ControllersKillSessionResponse": "KillSessionResponse", + "ControllersSendSessionMessageRequest": "SendSessionMessageRequest", + "ControllersSendSessionMessageResponse": "SendSessionMessageResponse", + "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", + "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", + "ControllersOrchestratorResponse": "OrchestratorResponse", + // service/project entities + DTOs + "ProjectProject": "Project", + "ProjectSummary": "ProjectSummary", + "ProjectDegraded": "DegradedProject", + "ProjectAddInput": "AddProjectInput", + "ProjectRemoveResult": "RemoveProjectResult", + "ProjectTrackerConfig": "TrackerConfig", + "ProjectSCMConfig": "SCMConfig", + "ProjectSCMWebhookConfig": "SCMWebhookConfig", +} + +// markRequestBodyRequired sets requestBody.required: true on the operation's +// JSON body. swaggest leaves it absent (== optional) for AddReqStructure bodies. +func markRequestBodyRequired(cor openapi.ContentOrReference) { + if rb, ok := cor.(*openapi31.RequestBodyOrReference); ok && rb.RequestBody != nil { + rb.RequestBody.WithRequired(true) + } +} + +// nonNullableSlices drops the "null" that swaggest unions into every Go slice +// type (a nil slice marshals as JSON null). A required array field should be +// `T[]`, not `T[] | null`; the handlers normalise nil to an empty slice, so +// null never reaches the wire. Byte slices (base64 strings) are left alone. +func nonNullableSlices(p jsonschema.InterceptNullabilityParams) { + if !p.NullAdded || p.Type == nil || p.Type.Kind() != reflect.Slice { + return + } + if p.Type.Elem().Kind() == reflect.Uint8 { + return + } + p.Schema.TypeEns().WithSimpleTypes(jsonschema.Array) + p.Schema.Type.SliceOfSimpleTypeValues = nil +} + +// requiredFromJSONTag marks a property required when its json tag lacks +// `omitempty` (the Go convention for "always present"). Runs after default +// processing so ParentSchema exists; skips fields without a json tag (e.g. path +// params, which swaggest marks required on their own). +func requiredFromJSONTag(p jsonschema.InterceptPropParams) error { + if !p.Processed || p.ParentSchema == nil { + return nil + } + jsonTag := p.Field.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + return nil + } + parts := strings.Split(jsonTag, ",") + name := parts[0] + if name == "" { + name = p.Name + } + for _, opt := range parts[1:] { + if opt == "omitempty" { + return nil + } + } + for _, existing := range p.ParentSchema.Required { + if existing == name { + return nil + } + } + p.ParentSchema.Required = append(p.ParentSchema.Required, name) + return nil +} + +// --- operation registry ----------------------------------------------------- + +type respUnit struct { + status int + body any +} + +type operation struct { + method, path, id, summary string + tag string + pathParams []any // path/query param containers (e.g. ProjectIDParam) + reqBody any // JSON request body struct, nil when the op takes none + resps []respUnit +} + +func operations() []operation { + ops := append([]operation{}, projectOperations()...) + ops = append(ops, sessionOperations()...) + return ops +} + +// projectOperations declares the 4 canonical /projects operations. The set must +// stay 1:1 with the routes ProjectsController.Register mounts — +// TestRouteSpecParity fails the build otherwise. +func projectOperations() []operation { + return []operation{ + { + method: http.MethodGet, path: "/api/v1/projects", id: "listProjects", tag: "projects", + summary: "List all registered projects (active + degraded)", + resps: []respUnit{ + {http.StatusOK, controllers.ListProjectsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/projects", id: "addProject", tag: "projects", + summary: "Register a new project from a git repository path", + reqBody: projectsvc.AddInput{}, + resps: []respUnit{ + {http.StatusCreated, controllers.ProjectResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodGet, path: "/api/v1/projects/{id}", id: "getProject", tag: "projects", + summary: "Fetch one project; discriminates ok vs degraded", + pathParams: []any{controllers.ProjectIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.GetProjectResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject", tag: "projects", + summary: "Remove a project; stops sessions, cleans workspaces, unregisters", + pathParams: []any{controllers.ProjectIDParam{}}, + resps: []respUnit{ + {http.StatusOK, projectsvc.RemoveResult{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + } +} + +func sessionOperations() []operation { + return []operation{ + { + method: http.MethodGet, path: "/api/v1/sessions", id: "listSessions", tag: "sessions", + summary: "List sessions", + pathParams: []any{controllers.ListSessionsQuery{}}, + resps: []respUnit{ + {http.StatusOK, controllers.ListSessionsResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions", id: "spawnSession", tag: "sessions", + summary: "Spawn a new agent session", + reqBody: controllers.SpawnSessionRequest{}, + resps: []respUnit{ + {http.StatusCreated, controllers.SessionResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodGet, path: "/api/v1/sessions/{sessionId}", id: "getSession", tag: "sessions", + summary: "Fetch one session", + pathParams: []any{controllers.SessionIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.SessionResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPatch, path: "/api/v1/sessions/{sessionId}", id: "renameSession", tag: "sessions", + summary: "Rename a session display name", + pathParams: []any{controllers.SessionIDParam{}}, + reqBody: controllers.RenameSessionRequest{}, + resps: []respUnit{ + {http.StatusOK, controllers.SessionResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/restore", id: "restoreSession", tag: "sessions", + summary: "Restore a terminated session", + pathParams: []any{controllers.SessionIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.RestoreSessionResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/kill", id: "killSession", tag: "sessions", + summary: "Mark a session terminated and tear down runtime/workspace resources", + pathParams: []any{controllers.SessionIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.KillSessionResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/send", id: "sendSessionMessage", tag: "sessions", + summary: "Send a message to a running session's agent", + pathParams: []any{controllers.SessionIDParam{}}, + reqBody: controllers.SendSessionMessageRequest{}, + resps: []respUnit{ + {http.StatusOK, controllers.SendSessionMessageResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/orchestrators", id: "spawnOrchestrator", tag: "sessions", + summary: "Spawn an orchestrator session", + reqBody: controllers.SpawnOrchestratorRequest{}, + resps: []respUnit{ + {http.StatusCreated, controllers.SpawnOrchestratorResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + } +} diff --git a/backend/internal/httpd/apispec/specgen/build_test.go b/backend/internal/httpd/apispec/specgen/build_test.go new file mode 100644 index 0000000000..9951456bce --- /dev/null +++ b/backend/internal/httpd/apispec/specgen/build_test.go @@ -0,0 +1,40 @@ +package specgen_test + +import ( + "bytes" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec/specgen" +) + +// TestBuild_MatchesEmbedded is the drift guard: the committed (embedded) +// openapi.yaml must equal fresh Build() output. If this fails, run +// `go generate ./...` and commit the result. +func TestBuild_MatchesEmbedded(t *testing.T) { + got, err := specgen.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + embedded := apispec.Default().YAML() + if !bytes.Equal(got, embedded) { + t.Fatalf("embedded openapi.yaml is stale — run `go generate ./...` and commit.\n"+ + "len(fresh)=%d len(embedded)=%d", len(got), len(embedded)) + } +} + +// TestBuild_Deterministic guards against nondeterministic output (which would +// make the drift check flaky in CI). +func TestBuild_Deterministic(t *testing.T) { + a, err := specgen.Build() + if err != nil { + t.Fatalf("Build #1: %v", err) + } + b, err := specgen.Build() + if err != nil { + t.Fatalf("Build #2: %v", err) + } + if !bytes.Equal(a, b) { + t.Fatal("Build() is not deterministic across calls") + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go new file mode 100644 index 0000000000..22d8f42e75 --- /dev/null +++ b/backend/internal/httpd/controllers/dto.go @@ -0,0 +1,176 @@ +package controllers + +import ( + "encoding/json" + "errors" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" +) + +// HTTP response envelopes for the projects surface — the SINGLE definition of +// each wire shape. The handlers encode these (envelope.WriteJSON), and +// apispec.Build reflects these same types into openapi.yaml, so the served +// contract and the generated spec can't disagree. The request side needs no +// wrappers: handlers decode the body straight into the project commands +// (projectsvc.AddInput), which apispec also reflects. + +// ProjectIDParam is the {id} path parameter shared by the /projects/{id} +// routes. Handlers read it via chi.URLParam (see projectID); it is declared here +// so every wire input/output shape has one home, and apispec.Build reflects it +// as the path parameter. +type ProjectIDParam struct { + ID string `path:"id" description:"Project identifier (registry key)."` +} + +// ListProjectsResponse is the body of GET /api/v1/projects. +type ListProjectsResponse struct { + Projects []projectsvc.Summary `json:"projects"` +} + +// ProjectResponse is the { project } body shared by POST /projects (201). +type ProjectResponse struct { + Project projectsvc.Project `json:"project"` +} + +// GetProjectResponse is the { status, project } body of GET /projects/{id}, +// where project is oneOf Project|Degraded discriminated by status. +type GetProjectResponse struct { + Status string `json:"status" enum:"ok,degraded"` + Project ProjectOrDegraded `json:"project"` +} + +// ProjectOrDegraded is the discriminated `project` field: exactly one of +// Project/Degraded is set. It marshals as whichever is present (so the handler +// emits the right object) and exposes the oneOf variants to the spec reflector +// (so apispec.Build emits `oneOf: [Project, Degraded]`) — one type, both jobs. +type ProjectOrDegraded struct { + Project *projectsvc.Project + Degraded *projectsvc.Degraded +} + +// MarshalJSON encodes whichever variant is set (Project or Degraded). +func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) { + switch { + case p.Degraded != nil: + return json.Marshal(p.Degraded) + case p.Project != nil: + return json.Marshal(p.Project) + default: + // Unreachable in practice: the handler validates the GetResult via + // newGetProjectResponse and writes a 500 before committing the 200 + // status, so this never encodes. Kept as a last-resort backstop — + // erroring is still better than emitting a contract-breaking `null`, + // though by here the status is already sent, so the real guard is + // upstream. + return nil, errEmptyProjectOrDegraded + } +} + +// errEmptyProjectOrDegraded marks a GetResult that set neither variant — a +// Manager-contract violation. newGetProjectResponse returns it so the handler +// can map it to a 500 before any response bytes are written. +var errEmptyProjectOrDegraded = errors.New("controllers: GetResult has neither Project nor Degraded set") + +// JSONSchemaOneOf is read by swaggest's reflector (apispec.Build) to emit the +// oneOf for this field; it is not used at runtime. +func (ProjectOrDegraded) JSONSchemaOneOf() []interface{} { + return []interface{}{projectsvc.Project{}, projectsvc.Degraded{}} +} + +// newGetProjectResponse maps the internal GetResult onto the wire envelope — +// the explicit project→httpd boundary the result type exists for. It errors +// when the result sets neither variant, so the handler can return a clean 500 +// BEFORE writing the 200 status rather than flushing a truncated body. +func newGetProjectResponse(res projectsvc.GetResult) (GetProjectResponse, error) { + if res.Project == nil && res.Degraded == nil { + return GetProjectResponse{}, errEmptyProjectOrDegraded + } + return GetProjectResponse{ + Status: res.Status, + Project: ProjectOrDegraded{Project: res.Project, Degraded: res.Degraded}, + }, nil +} + +// SessionIDParam is the {sessionId} path parameter shared by session routes. +type SessionIDParam struct { + SessionID string `path:"sessionId" description:"Session identifier, e.g. project-1."` +} + +// ListSessionsQuery is the query string accepted by GET /api/v1/sessions. +type ListSessionsQuery struct { + Project string `query:"project,omitempty" description:"Project id filter."` + Active *bool `query:"active,omitempty" description:"When true, return non-terminated sessions; when false, return terminated sessions."` + OrchestratorOnly *bool `query:"orchestratorOnly,omitempty" description:"When true, return only orchestrator sessions."` + Fresh *bool `query:"fresh,omitempty" description:"When true, return only fresh non-terminated sessions."` +} + +// ListSessionsResponse is the body of GET /api/v1/sessions. +type ListSessionsResponse struct { + Sessions []domain.Session `json:"sessions"` +} + +// SpawnSessionRequest is the body of POST /api/v1/sessions. +type SpawnSessionRequest struct { + ProjectID domain.ProjectID `json:"projectId"` + IssueID domain.IssueID `json:"issueId,omitempty"` + Kind domain.SessionKind `json:"kind,omitempty" enum:"worker,orchestrator"` + Harness domain.AgentHarness `json:"harness,omitempty" enum:"claude-code,codex,aider,opencode"` + Branch string `json:"branch,omitempty"` + Prompt string `json:"prompt,omitempty" maxLength:"4096"` + AgentRules string `json:"agentRules,omitempty"` +} + +// SessionResponse is the { session } body shared by session create/get. +type SessionResponse struct { + Session domain.Session `json:"session"` +} + +// RenameSessionRequest is the body of PATCH /api/v1/sessions/{sessionId}. +type RenameSessionRequest struct { + DisplayName string `json:"displayName" minLength:"1"` +} + +// RestoreSessionResponse is the body of POST /api/v1/sessions/{sessionId}/restore. +type RestoreSessionResponse struct { + OK bool `json:"ok"` + SessionID domain.SessionID `json:"sessionId"` + Session domain.Session `json:"session"` +} + +// KillSessionResponse is the body of POST /api/v1/sessions/{sessionId}/kill. +type KillSessionResponse struct { + OK bool `json:"ok"` + SessionID domain.SessionID `json:"sessionId"` + Freed bool `json:"freed,omitempty"` +} + +// SendSessionMessageRequest is the body of POST /api/v1/sessions/{sessionId}/send. +type SendSessionMessageRequest struct { + Message string `json:"message" minLength:"1" maxLength:"4096"` +} + +// SendSessionMessageResponse is the body of POST /api/v1/sessions/{sessionId}/send. +type SendSessionMessageResponse struct { + OK bool `json:"ok"` + SessionID domain.SessionID `json:"sessionId"` + Message string `json:"message"` +} + +// SpawnOrchestratorRequest is the body of POST /api/v1/orchestrators. +type SpawnOrchestratorRequest struct { + ProjectID domain.ProjectID `json:"projectId"` + Clean bool `json:"clean,omitempty"` +} + +// SpawnOrchestratorResponse is the body of POST /api/v1/orchestrators. +type SpawnOrchestratorResponse struct { + Orchestrator OrchestratorResponse `json:"orchestrator"` +} + +// OrchestratorResponse is the minimal orchestrator read model returned after spawn. +type OrchestratorResponse struct { + ID domain.SessionID `json:"id"` + ProjectID domain.ProjectID `json:"projectId"` + ProjectName string `json:"projectName,omitempty"` +} diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index 91a1e47dcf..a23d9dffd7 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -5,10 +5,8 @@ package controllers import ( - "bytes" "encoding/json" "errors" - "io" "net/http" "github.com/go-chi/chi/v5" @@ -16,26 +14,21 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" - "github.com/aoagents/agent-orchestrator/backend/internal/project" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) // ProjectsController owns the /projects routes. The controller depends only on -// project.Manager; nil keeps routes registered but returns OpenAPI-backed 501s. +// projectsvc.Manager; nil keeps routes registered but returns OpenAPI-backed 501s. type ProjectsController struct { - Mgr project.Manager + Mgr projectsvc.Manager } -// Register mounts the project routes on the supplied router. Route order -// matters: /projects/reload must register before /projects/{id} for the POST -// verb, otherwise chi would treat "reload" as an {id} match for repair. +// Register mounts the project routes on the supplied router. func (c *ProjectsController) Register(r chi.Router) { r.Get("/projects", c.list) r.Post("/projects", c.add) - r.Post("/projects/reload", c.reload) // BEFORE /projects/{id} r.Get("/projects/{id}", c.get) - r.Patch("/projects/{id}", c.updateConfig) r.Delete("/projects/{id}", c.remove) - r.Post("/projects/{id}/repair", c.repair) } func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { @@ -48,7 +41,10 @@ func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"projects": projects}) + if projects == nil { + projects = []projectsvc.Summary{} + } + envelope.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: projects}) } func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { @@ -56,7 +52,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { apispec.NotImplemented(w, r, "POST", "/api/v1/projects") return } - var in project.AddInput + var in projectsvc.AddInput if err := decodeJSON(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return @@ -66,7 +62,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } - envelope.WriteJSON(w, http.StatusCreated, map[string]any{"project": p}) + envelope.WriteJSON(w, http.StatusCreated, ProjectResponse{Project: p}) } func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { @@ -79,37 +75,12 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { writeProjectError(w, r, err) return } - if got.Status == "degraded" { - envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Degraded}) - return - } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Project}) -} - -func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request) { - if c.Mgr == nil { - apispec.NotImplemented(w, r, "PATCH", "/api/v1/projects/{id}") - return - } - if frozen, err := containsFrozenIdentityField(r); err != nil { - envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) - return - } else if len(frozen) > 0 { - envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "IDENTITY_FROZEN", "Identity fields cannot be patched", map[string]any{"fields": frozen}) - return - } - - var patch project.UpdateConfigInput - if err := decodeJSON(r, &patch); err != nil { - envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) - return - } - p, err := c.Mgr.UpdateConfig(r.Context(), projectID(r), patch) + resp, err := newGetProjectResponse(got) if err != nil { - writeProjectError(w, r, err) + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "INTERNAL_ERROR", "Internal server error", nil) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) + envelope.WriteJSON(w, http.StatusOK, resp) } func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { @@ -125,32 +96,6 @@ func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusOK, result) } -func (c *ProjectsController) repair(w http.ResponseWriter, r *http.Request) { - if c.Mgr == nil { - apispec.NotImplemented(w, r, "POST", "/api/v1/projects/{id}/repair") - return - } - p, err := c.Mgr.Repair(r.Context(), projectID(r)) - if err != nil { - writeProjectError(w, r, err) - return - } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p}) -} - -func (c *ProjectsController) reload(w http.ResponseWriter, r *http.Request) { - if c.Mgr == nil { - apispec.NotImplemented(w, r, "POST", "/api/v1/projects/reload") - return - } - result, err := c.Mgr.Reload(r.Context()) - if err != nil { - writeProjectError(w, r, err) - return - } - envelope.WriteJSON(w, http.StatusOK, result) -} - func projectID(r *http.Request) domain.ProjectID { return domain.ProjectID(chi.URLParam(r, "id")) } @@ -159,30 +104,10 @@ func decodeJSON(r *http.Request, out any) error { return json.NewDecoder(r.Body).Decode(out) } -func containsFrozenIdentityField(r *http.Request) ([]string, error) { - body, err := io.ReadAll(r.Body) - if err != nil { - return nil, err - } - r.Body = io.NopCloser(bytes.NewReader(body)) - - var raw map[string]json.RawMessage - if err := json.Unmarshal(body, &raw); err != nil { - return nil, err - } - var frozen []string - for _, field := range []string{"projectId", "path", "repo", "defaultBranch"} { - if _, ok := raw[field]; ok { - frozen = append(frozen, field) - } - } - return frozen, nil -} - -// writeProjectError maps a project.Error to its HTTP status, falling back to -// 500 for an unrecognized kind or a non-project.Error. +// writeProjectError maps a projectsvc.Error to its HTTP status, falling back to +// 500 for an unrecognized kind or a non-projectsvc.Error. func writeProjectError(w http.ResponseWriter, r *http.Request, err error) { - var pe *project.Error + var pe *projectsvc.Error if errors.As(err, &pe) { status := http.StatusInternalServerError switch pe.Kind { diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index 8d303da51c..7de640ef50 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -1,310 +1,533 @@ package controllers_test import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" "github.com/aoagents/agent-orchestrator/backend/internal/config" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" - "github.com/aoagents/agent-orchestrator/backend/internal/project" + + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) +// emptyGetManager returns a GetResult that sets neither Project nor Degraded — + +// a Manager-contract violation — so the test can prove the handler answers a + +// clean 500 before writing the 200 status. + +type emptyGetManager struct{ projectsvc.Manager } + +func (emptyGetManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) { + + return projectsvc.GetResult{}, nil + +} + +// TestProjectsAPI_GetEmptyResultIs500 locks the fix for the discriminated-union + +// invariant: a degenerate GetResult must surface as a parseable 500 envelope, + +// not a 200 with truncated JSON. + +func TestProjectsAPI_GetEmptyResultIs500(t *testing.T) { + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + + srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ + + Projects: emptyGetManager{}, + })) + + t.Cleanup(srv.Close) + + body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/whatever", "") + + assertJSON(t, headers) + + assertErrorCode(t, body, status, http.StatusInternalServerError, "INTERNAL_ERROR") + +} + func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + + store, err := sqlite.Open(t.TempDir()) + + if err != nil { + + t.Fatalf("open store: %v", err) + + } + + t.Cleanup(func() { _ = store.Close() }) + srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ - Projects: project.NewMemoryManager(), + + Projects: projectsvc.New(store), })) + t.Cleanup(srv.Close) + return srv + } func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil)) + t.Cleanup(srv.Close) body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") + } -func TestProjectsAPI_ListAddGetReload(t *testing.T) { +func TestProjectsAPI_ListAddGet(t *testing.T) { + srv := newTestServer(t) + repo := gitRepo(t, "agent-orchestrator") body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects", "") + if status != http.StatusOK { + t.Fatalf("GET projects = %d, want 200; body=%s", status, body) + } + assertJSON(t, headers) + var list struct { Projects []projectSummary `json:"projects"` } + mustJSON(t, body, &list) + if len(list.Projects) != 0 { + t.Fatalf("initial project count = %d, want 0", len(list.Projects)) + } body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"ao","name":"Agent Orchestrator"}`) + if status != http.StatusCreated { + t.Fatalf("POST project = %d, want 201; body=%s", status, body) + } + var add struct { Project projectBody `json:"project"` } + mustJSON(t, body, &add) + if add.Project.ID != "ao" || add.Project.Name != "Agent Orchestrator" || add.Project.DefaultBranch != "main" { + t.Fatalf("created project = %#v", add.Project) + } body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/ao", "") + if status != http.StatusOK { + t.Fatalf("GET project = %d, want 200; body=%s", status, body) + } + var get struct { - Status string `json:"status"` + Status string `json:"status"` + Project projectBody `json:"project"` } + mustJSON(t, body, &get) + if get.Status != "ok" || get.Project.ID != "ao" { + t.Fatalf("get response = %#v", get) - } - body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/reload", "") - if status != http.StatusOK { - t.Fatalf("reload = %d, want 200; body=%s", status, body) - } - var reload struct { - Reloaded bool `json:"reloaded"` - ProjectCount int `json:"projectCount"` - DegradedCount int `json:"degradedCount"` - } - mustJSON(t, body, &reload) - if !reload.Reloaded || reload.ProjectCount != 1 || reload.DegradedCount != 0 { - t.Fatalf("reload response = %#v", reload) } + } func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { + srv := newTestServer(t) + repoA := gitRepo(t, "repo-a") + repoB := gitRepo(t, "repo-b") + notRepo := t.TempDir() cases := []struct { name, body, wantCode string - wantStatus int + + wantStatus int }{ + {name: "invalid json", body: `{`, wantStatus: 400, wantCode: "INVALID_JSON"}, + {name: "missing path", body: `{}`, wantStatus: 400, wantCode: "PATH_REQUIRED"}, + {name: "not git", body: `{"path":` + quote(notRepo) + `}`, wantStatus: 400, wantCode: "NOT_A_GIT_REPO"}, } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", tc.body) + assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode) + }) + } body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"shared"}`) + if status != http.StatusCreated { + t.Fatalf("seed create = %d, want 201; body=%s", status, body) + } body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoA)+`,"projectId":"other"}`) + assertErrorCode(t, body, status, http.StatusConflict, "PATH_ALREADY_REGISTERED") body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repoB)+`,"projectId":"shared"}`) + assertErrorCode(t, body, status, http.StatusConflict, "ID_ALREADY_REGISTERED") + } -func TestProjectsAPI_UpdateDeleteRepair(t *testing.T) { +func TestProjectsAPI_Delete(t *testing.T) { + srv := newTestServer(t) + repo := gitRepo(t, "repo") body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"proj"}`) - if status != http.StatusCreated { - t.Fatalf("seed create = %d, want 201; body=%s", status, body) - } - body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"agent":"claude"}`) - assertErrorCode(t, body, status, http.StatusNotImplemented, "PROJECT_CONFIG_NOT_IMPLEMENTED") + if status != http.StatusCreated { - body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/projects/proj", `{"path":"elsewhere"}`) - assertErrorCode(t, body, status, http.StatusBadRequest, "IDENTITY_FROZEN") + t.Fatalf("seed create = %d, want 201; body=%s", status, body) - body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects/proj/repair", "") - assertErrorCode(t, body, status, http.StatusBadRequest, "REPAIR_NOT_AVAILABLE") + } body, status, _ = doRequest(t, srv, "DELETE", "/api/v1/projects/proj", "") + if status != http.StatusOK { + t.Fatalf("DELETE = %d, want 200; body=%s", status, body) + } + var removed struct { - ProjectID string `json:"projectId"` - RemovedStorageDir bool `json:"removedStorageDir"` + ProjectID string `json:"projectId"` + + RemovedStorageDir bool `json:"removedStorageDir"` } + mustJSON(t, body, &removed) + if removed.ProjectID != "proj" || removed.RemovedStorageDir { + t.Fatalf("delete response = %#v", removed) + } body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects/proj", "") - if status != http.StatusOK { - t.Fatalf("GET archived project = %d, want 200; body=%s", status, body) + + if status != http.StatusNotFound { + + t.Fatalf("GET archived project = %d, want 404; body=%s", status, body) + } body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "") + if status != http.StatusOK { + t.Fatalf("GET projects after archive = %d, want 200; body=%s", status, body) + } + var list struct { Projects []projectSummary `json:"projects"` } + mustJSON(t, body, &list) + if len(list.Projects) != 0 { + t.Fatalf("active projects after archive = %d, want 0", len(list.Projects)) + } + } func TestProjectsRoutes_LegacyUnregistered(t *testing.T) { + srv := newTestServer(t) cases := []struct { method, path, wantCode, why string - wantStatus int + + wantStatus int }{ + {method: "PUT", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R3 PUT not registered"}, - {method: "POST", path: "/api/v1/projects/p1", wantStatus: 405, wantCode: "METHOD_NOT_ALLOWED", why: "R4 repair moved to /repair"}, } for _, tc := range cases { + t.Run(tc.why, func(t *testing.T) { + body, status, _ := doRequest(t, srv, tc.method, tc.path, "") + assertErrorCode(t, body, status, tc.wantStatus, tc.wantCode) + }) + } + } func TestProjectsRoutes_MissingRoute(t *testing.T) { + srv := newTestServer(t) + body, status, headers := doRequest(t, srv, "GET", "/api/v1/projects/p1/does-not-exist", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotFound, "ROUTE_NOT_FOUND") + } func TestOpenAPIYAMLServed(t *testing.T) { + srv := newTestServer(t) + body, status, headers := doRequest(t, srv, "GET", "/api/v1/openapi.yaml", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { + t.Errorf("Content-Type = %q, want application/yaml*", ct) + } + if !strings.Contains(string(body), "openapi: 3.1.0") { + t.Errorf("served body did not start with an OpenAPI 3.1 doc") + } + } type projectSummary struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + + Name string `json:"name"` + SessionPrefix string `json:"sessionPrefix"` } type projectBody struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - Repo string `json:"repo"` + ID string `json:"id"` + + Name string `json:"name"` + + Path string `json:"path"` + + Repo string `json:"repo"` + DefaultBranch string `json:"defaultBranch"` - Agent string `json:"agent"` + + Agent string `json:"agent"` } type errorBody struct { - Error string `json:"error"` - Code string `json:"code"` - Message string `json:"message"` + Error string `json:"error"` + + Code string `json:"code"` + + Message string `json:"message"` + Details map[string]any `json:"details"` } func doRequest(t *testing.T, srv *httptest.Server, method, path, body string) ([]byte, int, http.Header) { + t.Helper() + var req *http.Request + var err error + if body != "" { + req, err = http.NewRequest(method, srv.URL+path, strings.NewReader(body)) + } else { + req, err = http.NewRequest(method, srv.URL+path, nil) + } + if err != nil { + t.Fatalf("new request: %v", err) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + buf, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return buf, resp.StatusCode, resp.Header + } func gitRepo(t *testing.T, name string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create git repo fixture: %v", err) + } + if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { + t.Fatalf("git init fixture: %v\n%s", err, out) + } + return dir + } func quote(s string) string { + b, _ := json.Marshal(s) + return string(b) + } func mustJSON(t *testing.T, body []byte, out any) { + t.Helper() + if err := json.Unmarshal(body, out); err != nil { + t.Fatalf("unmarshal: %v\nbody=%s", err, body) + } + } func assertJSON(t *testing.T, headers http.Header) { + t.Helper() + if ct := headers.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("Content-Type = %q, want JSON", ct) + } + } func assertErrorCode(t *testing.T, body []byte, status, wantStatus int, wantCode string) { + t.Helper() + if status != wantStatus { + t.Fatalf("status = %d, want %d\nbody=%s", status, wantStatus, body) + } + var got errorBody + mustJSON(t, body, &got) + if got.Code != wantCode { + t.Fatalf("code = %q, want %q\nbody=%s", got.Code, wantCode, body) + } + } diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 6beccf8e36..c97f388ad3 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -14,7 +14,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/service" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) @@ -25,7 +25,7 @@ const ( // SessionService is the controller-facing session service contract. type SessionService interface { - List(ctx context.Context, filter service.SessionListFilter) ([]domain.Session, error) + List(ctx context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) @@ -66,17 +66,7 @@ func (c *SessionsController) list(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"sessions": sessions}) -} - -type spawnSessionRequest struct { - ProjectID domain.ProjectID `json:"projectId"` - IssueID domain.IssueID `json:"issueId"` - Kind domain.SessionKind `json:"kind"` - Harness domain.AgentHarness `json:"harness"` - Branch string `json:"branch"` - Prompt string `json:"prompt"` - AgentRules string `json:"agentRules"` + envelope.WriteJSON(w, http.StatusOK, ListSessionsResponse{Sessions: sessions}) } func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { @@ -84,7 +74,7 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { apispec.NotImplemented(w, r, "POST", "/api/v1/sessions") return } - var in spawnSessionRequest + var in SpawnSessionRequest if err := decodeJSON(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return @@ -105,7 +95,7 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusCreated, map[string]any{"session": sess}) + envelope.WriteJSON(w, http.StatusCreated, SessionResponse{Session: sess}) } func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) { @@ -118,7 +108,7 @@ func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"session": sess}) + envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sess}) } func (c *SessionsController) rename(w http.ResponseWriter, r *http.Request) { @@ -135,7 +125,7 @@ func (c *SessionsController) restore(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "session": sess}) + envelope.WriteJSON(w, http.StatusOK, RestoreSessionResponse{OK: true, SessionID: sessionID(r), Session: sess}) } func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) { @@ -148,11 +138,7 @@ func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "freed": freed}) -} - -type sendSessionRequest struct { - Message string `json:"message"` + envelope.WriteJSON(w, http.StatusOK, KillSessionResponse{OK: true, SessionID: sessionID(r), Freed: freed}) } func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { @@ -160,7 +146,7 @@ func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/send") return } - var in sendSessionRequest + var in SendSessionMessageRequest if err := decodeJSON(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return @@ -178,12 +164,7 @@ func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "sessionId": sessionID(r), "message": message}) -} - -type spawnOrchestratorRequest struct { - ProjectID domain.ProjectID `json:"projectId"` - Clean bool `json:"clean"` + envelope.WriteJSON(w, http.StatusOK, SendSessionMessageResponse{OK: true, SessionID: sessionID(r), Message: message}) } func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Request) { @@ -191,7 +172,7 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re apispec.NotImplemented(w, r, "POST", "/api/v1/orchestrators") return } - var in spawnOrchestratorRequest + var in SpawnOrchestratorRequest if err := decodeJSON(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return @@ -202,7 +183,7 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re } if in.Clean { active := true - orchestrators, err := c.Svc.List(r.Context(), service.SessionListFilter{ProjectID: in.ProjectID, Active: &active, OrchestratorOnly: true}) + orchestrators, err := c.Svc.List(r.Context(), sessionsvc.ListFilter{ProjectID: in.ProjectID, Active: &active, OrchestratorOnly: true}) if err != nil { writeSessionError(w, r, err) return @@ -219,34 +200,36 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re writeSessionError(w, r, err) return } - envelope.WriteJSON(w, http.StatusCreated, map[string]any{"orchestrator": map[string]any{"id": sess.ID, "projectId": sess.ProjectID}}) + envelope.WriteJSON(w, http.StatusCreated, SpawnOrchestratorResponse{ + Orchestrator: OrchestratorResponse{ID: sess.ID, ProjectID: sess.ProjectID}, + }) } func sessionID(r *http.Request) domain.SessionID { return domain.SessionID(chi.URLParam(r, "sessionId")) } -func parseSessionListFilter(r *http.Request) (service.SessionListFilter, error) { +func parseSessionListFilter(r *http.Request) (sessionsvc.ListFilter, error) { q := r.URL.Query() - filter := service.SessionListFilter{ProjectID: domain.ProjectID(q.Get("project"))} + filter := sessionsvc.ListFilter{ProjectID: domain.ProjectID(q.Get("project"))} if raw := q.Get("active"); raw != "" { active, err := strconv.ParseBool(raw) if err != nil { - return service.SessionListFilter{}, errors.New("active must be a boolean") + return sessionsvc.ListFilter{}, errors.New("active must be a boolean") } filter.Active = &active } if raw := q.Get("orchestratorOnly"); raw != "" { orchestratorOnly, err := strconv.ParseBool(raw) if err != nil { - return service.SessionListFilter{}, errors.New("orchestratorOnly must be a boolean") + return sessionsvc.ListFilter{}, errors.New("orchestratorOnly must be a boolean") } filter.OrchestratorOnly = orchestratorOnly } if raw := q.Get("fresh"); raw != "" { fresh, err := strconv.ParseBool(raw) if err != nil { - return service.SessionListFilter{}, errors.New("fresh must be a boolean") + return sessionsvc.ListFilter{}, errors.New("fresh must be a boolean") } filter.Fresh = fresh } diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 62ef4c8c3f..7ec882cc18 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -13,7 +13,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/service" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) type fakeSessionService struct { @@ -27,7 +27,7 @@ func newFakeSessionService() *fakeSessionService { return &fakeSessionService{sessions: map[domain.SessionID]domain.Session{s.ID: s}} } -func (f *fakeSessionService) List(_ context.Context, filter service.SessionListFilter) ([]domain.Session, error) { +func (f *fakeSessionService) List(_ context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) { var out []domain.Session for _, s := range f.sessions { if filter.ProjectID != "" && s.ProjectID != filter.ProjectID { diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index b8d3633283..4a103f453d 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -9,9 +9,8 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - prsvc "github.com/aoagents/agent-orchestrator/backend/internal/pr" - "github.com/aoagents/agent-orchestrator/backend/internal/project" - "github.com/aoagents/agent-orchestrator/backend/internal/service" + prsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/pr" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -55,7 +54,7 @@ func (c *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg strin type stack struct { store *sqlite.Store - sm *service.Session + sm *sessionsvc.Service lcm *lifecycle.Manager prm *prsvc.Manager rt *stubRuntime @@ -71,7 +70,7 @@ func newStack(t *testing.T) *stack { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) - if err := store.Upsert(ctx, project.Row{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil { + if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil { t.Fatal(err) } msg := &captureMessenger{} @@ -80,7 +79,7 @@ func newStack(t *testing.T) *stack { rt := &stubRuntime{} ws := &stubWorkspace{} mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm}) - sm := service.NewSession(mgr, store) + sm := sessionsvc.New(mgr, store) return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg} } diff --git a/backend/internal/project/dto.go b/backend/internal/project/dto.go deleted file mode 100644 index 7146d455bf..0000000000 --- a/backend/internal/project/dto.go +++ /dev/null @@ -1,53 +0,0 @@ -package project - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// Request/response shapes for Manager. They carry the data across the service -// boundary; the entities they reference (Project, Summary, Degraded) live in -// types.go in this same package. Named without a "Project" prefix because the -// package name already supplies it (project.AddInput, project.GetResult). - -// GetResult is the discriminated union returned by Manager.Get. Exactly one of -// Project / Degraded is non-nil; Status mirrors the discriminator on the wire -// so consumers branch on it without nil-checking both fields. -type GetResult struct { - Status string // "ok" | "degraded" - Project *Project // populated when Status == "ok" - Degraded *Degraded // populated when Status == "degraded" -} - -// AddInput is the body shape for POST /api/v1/projects. Path is required; -// ProjectID and Name default to basename(path) at the manager. Pointer fields -// preserve the "field absent" vs "field present empty" distinction so the -// manager can decide what to default and what to reject. -type AddInput struct { - Path string `json:"path"` - ProjectID *string `json:"projectId,omitempty"` - Name *string `json:"name,omitempty"` -} - -// UpdateConfigInput is the body shape for PATCH /api/v1/projects/{id}. Only -// behaviour fields are mutable; identity fields (projectId, path, repo, -// defaultBranch) are rejected by the handler with a 400 IDENTITY_FROZEN. -type UpdateConfigInput struct { - Agent *string `json:"agent,omitempty"` - Tracker *TrackerConfig `json:"tracker,omitempty"` - SCM *SCMConfig `json:"scm,omitempty"` -} - -// RemoveResult reports what DELETE /api/v1/projects/{id} actually did. -// RemovedStorageDir is false when the project was registry-only (no on-disk -// session/workspace directory existed). -type RemoveResult struct { - ProjectID domain.ProjectID `json:"projectId"` - RemovedStorageDir bool `json:"removedStorageDir"` -} - -// ReloadResult is the response body of POST /api/v1/projects/reload — the -// manager invalidates its cached config and re-scans the registry; the counts -// help the dashboard show "loaded N projects, M degraded" feedback. -type ReloadResult struct { - Reloaded bool `json:"reloaded"` - ProjectCount int `json:"projectCount"` - DegradedCount int `json:"degradedCount"` -} diff --git a/backend/internal/project/memory_store.go b/backend/internal/project/memory_store.go deleted file mode 100644 index c9f91a2ae0..0000000000 --- a/backend/internal/project/memory_store.go +++ /dev/null @@ -1,117 +0,0 @@ -package project - -import ( - "context" - "sync" - "time" -) - -// Row mirrors the project table shape from the sqlite storage PR. The -// memory store is intentionally row-based so the API layer does not depend on a -// richer mock model than the real DB will provide. -type Row struct { - ID string - Path string - RepoOriginURL string - DisplayName string - RegisteredAt time.Time - ArchivedAt time.Time -} - -// Store is the project persistence the manager depends on. MemoryStore is the -// current in-process implementation; the sqlite adapter uses the same row shape. -type Store interface { - List(ctx context.Context) ([]Row, error) - Get(ctx context.Context, id string) (Row, bool, error) - FindByPath(ctx context.Context, path string) (Row, bool, error) - Upsert(ctx context.Context, row Row) error - Archive(ctx context.Context, id string, at time.Time) (bool, error) -} - -// MemoryStore is the mocked DB layer for the project API implementation. It is -// process-local and intentionally small, but concurrency-safe for HTTP tests. -type MemoryStore struct { - mu sync.Mutex - projects map[string]Row - paths map[string]string -} - -var _ Store = (*MemoryStore)(nil) - -// NewMemoryStore returns an empty, ready-to-use in-memory project store. -func NewMemoryStore() *MemoryStore { - return &MemoryStore{ - projects: map[string]Row{}, - paths: map[string]string{}, - } -} - -// List returns all non-archived projects, in unspecified order. -func (s *MemoryStore) List(context.Context) ([]Row, error) { - s.mu.Lock() - defer s.mu.Unlock() - - out := make([]Row, 0, len(s.projects)) - for _, row := range s.projects { - if row.ArchivedAt.IsZero() { - out = append(out, row) - } - } - return out, nil -} - -// Get returns the project with the given id, or ok=false if absent. -func (s *MemoryStore) Get(_ context.Context, id string) (Row, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - - row, ok := s.projects[id] - if !ok { - return Row{}, false, nil - } - return row, true, nil -} - -// FindByPath returns the project registered at a filesystem path, or ok=false. -func (s *MemoryStore) FindByPath(_ context.Context, path string) (Row, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - - id, ok := s.paths[path] - if !ok { - return Row{}, false, nil - } - row, ok := s.projects[id] - if !ok { - return Row{}, false, nil - } - return row, true, nil -} - -// Upsert inserts or replaces a project, keeping the path→id index in sync. -func (s *MemoryStore) Upsert(_ context.Context, row Row) error { - s.mu.Lock() - defer s.mu.Unlock() - - if existing, ok := s.projects[row.ID]; ok && existing.Path != row.Path { - delete(s.paths, existing.Path) - } - s.projects[row.ID] = row - s.paths[row.Path] = row.ID - return nil -} - -// Archive soft-deletes a project by stamping ArchivedAt; returns ok=false if -// the project doesn't exist. -func (s *MemoryStore) Archive(_ context.Context, id string, at time.Time) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - - row, ok := s.projects[id] - if !ok { - return false, nil - } - row.ArchivedAt = at - s.projects[id] = row - return true, nil -} diff --git a/backend/internal/project/project.go b/backend/internal/project/project.go deleted file mode 100644 index 14bf731ac0..0000000000 --- a/backend/internal/project/project.go +++ /dev/null @@ -1,41 +0,0 @@ -// Package project owns the projects service contract: the Manager interface -// the HTTP layer calls and the request/response DTOs that cross it (dto.go). -// -// This is the pilot for the feature-package layout the backend is migrating -// toward: a resource's interface, implementation, and DTOs live with the -// resource, not in a central catch-all. Controllers depend on project.Manager -// and nothing beneath it. -package project - -import ( - "context" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// Manager is the inbound contract for the /api/v1/projects surface. One -// implementation lives in this package; the HTTP controller is the consumer. -type Manager interface { - // List returns every registered project, including degraded entries - // (those whose config failed to load but whose registry entry survives). - List(ctx context.Context) ([]Summary, error) - - // Get returns one project, discriminating ok vs degraded via GetResult. - Get(ctx context.Context, id domain.ProjectID) (GetResult, error) - - // Add registers a new project from a git repository path. - Add(ctx context.Context, in AddInput) (Project, error) - - // UpdateConfig patches behaviour-only fields; identity fields are frozen. - UpdateConfig(ctx context.Context, id domain.ProjectID, patch UpdateConfigInput) (Project, error) - - // Remove unregisters a project, stopping its sessions and reclaiming - // managed workspaces. - Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) - - // Repair recovers a degraded project where automatic repair is available. - Repair(ctx context.Context, id domain.ProjectID) (Project, error) - - // Reload invalidates cached config and re-scans the global registry. - Reload(ctx context.Context) (ReloadResult, error) -} diff --git a/backend/internal/pr/manager.go b/backend/internal/service/pr/manager.go similarity index 100% rename from backend/internal/pr/manager.go rename to backend/internal/service/pr/manager.go diff --git a/backend/internal/pr/manager_test.go b/backend/internal/service/pr/manager_test.go similarity index 100% rename from backend/internal/pr/manager_test.go rename to backend/internal/service/pr/manager_test.go diff --git a/backend/internal/service/project/dto.go b/backend/internal/service/project/dto.go new file mode 100644 index 0000000000..3d5329320a --- /dev/null +++ b/backend/internal/service/project/dto.go @@ -0,0 +1,23 @@ +package project + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// GetResult is the discriminated result returned by Service.Get. +type GetResult struct { + Status string + Project *Project + Degraded *Degraded +} + +// AddInput is the body shape for POST /api/v1/projects. +type AddInput struct { + Path string `json:"path"` + ProjectID *string `json:"projectId,omitempty"` + Name *string `json:"name,omitempty"` +} + +// RemoveResult reports what DELETE /api/v1/projects/{id} actually did. +type RemoveResult struct { + ProjectID domain.ProjectID `json:"projectId"` + RemovedStorageDir bool `json:"removedStorageDir"` +} diff --git a/backend/internal/project/errors.go b/backend/internal/service/project/errors.go similarity index 82% rename from backend/internal/project/errors.go rename to backend/internal/service/project/errors.go index f6687e1f70..9b61c49fbd 100644 --- a/backend/internal/project/errors.go +++ b/backend/internal/service/project/errors.go @@ -1,6 +1,6 @@ package project -// Error is the manager-level error shape controllers can translate into the +// Error is the service-level error shape controllers translate into the // locked HTTP APIError envelope without knowing store internals. type Error struct { Kind string @@ -32,10 +32,6 @@ func conflict(code, message string, details map[string]any) *Error { return newError("conflict", code, message, details) } -func notImplemented(code, message string) *Error { - return newError("not_implemented", code, message, nil) -} - func internal(code, message string) *Error { return newError("internal", code, message, nil) } diff --git a/backend/internal/project/manager.go b/backend/internal/service/project/service.go similarity index 64% rename from backend/internal/project/manager.go rename to backend/internal/service/project/service.go index 54c93b9a4d..0eb213abe8 100644 --- a/backend/internal/project/manager.go +++ b/backend/internal/service/project/service.go @@ -13,29 +13,38 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -type manager struct { - store Store -} +// Manager is the controller-facing contract for the /api/v1/projects surface. +type Manager interface { + // List returns every registered project, including degraded entries + // (those whose config failed to load but whose registry entry survives). + List(ctx context.Context) ([]Summary, error) -var _ Manager = (*manager)(nil) + // Get returns one project, discriminating ok vs degraded via GetResult. + Get(ctx context.Context, id domain.ProjectID) (GetResult, error) -// NewManager returns a project Manager backed by the given Store, defaulting to -// an in-memory store when store is nil. -func NewManager(store Store) Manager { - if store == nil { - store = NewMemoryStore() - } - return &manager{store: store} + // Add registers a new project from a git repository path. + Add(ctx context.Context, in AddInput) (Project, error) + + // Remove unregisters a project, stopping its sessions and reclaiming + // managed workspaces. + Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) } -// NewMemoryManager returns a project Manager backed by a fresh in-memory store, -// for tests and ephemeral use. -func NewMemoryManager() Manager { - return NewManager(NewMemoryStore()) +// Service implements project registration and lookup use-cases for controllers. +type Service struct { + store Store +} + +var _ Manager = (*Service)(nil) + +// New returns a project service backed by the given durable store. +func New(store Store) *Service { + return &Service{store: store} } -func (m *manager) List(ctx context.Context) ([]Summary, error) { - projects, err := m.store.List(ctx) +// List returns every active registered project. +func (m *Service) List(ctx context.Context) ([]Summary, error) { + projects, err := m.store.ListProjects(ctx) if err != nil { return nil, internal("PROJECTS_LIST_FAILED", "Failed to load projects") } @@ -50,22 +59,24 @@ func (m *manager) List(ctx context.Context) ([]Summary, error) { return out, nil } -func (m *manager) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) { +// Get returns one active project by id. +func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) { if err := validateProjectID(id); err != nil { return GetResult{}, err } - row, ok, err := m.store.Get(ctx, string(id)) + row, ok, err := m.store.GetProject(ctx, string(id)) if err != nil { return GetResult{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") } - if !ok { + if !ok || !row.ArchivedAt.IsZero() { return GetResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") } p := projectFromRow(row) return GetResult{Status: "ok", Project: &p}, nil } -func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { +// Add registers a local git repository as a project. +func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { path, err := normalizePath(in.Path) if err != nil { return Project{}, err @@ -90,7 +101,7 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { name = string(id) } - if existing, ok, err := m.store.FindByPath(ctx, path); err != nil { + if existing, ok, err := m.store.FindProjectByPath(ctx, path); err != nil { return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") } else if ok { return Project{}, conflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{ @@ -98,47 +109,33 @@ func (m *manager) Add(ctx context.Context, in AddInput) (Project, error) { "suggestedProjectId": string(m.suggestID(ctx, id)), }) } - if existing, ok, err := m.store.Get(ctx, string(id)); err != nil { + if existing, ok, err := m.store.GetProject(ctx, string(id)); err != nil { return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") - } else if ok && existing.Path != path { + } else if ok && existing.ArchivedAt.IsZero() && existing.Path != path { return Project{}, conflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{ "existingProjectId": existing.ID, "suggestedProjectId": string(m.suggestID(ctx, id)), }) } - row := Row{ + row := domain.ProjectRecord{ ID: string(id), Path: path, DisplayName: name, RegisteredAt: time.Now(), } - if err := m.store.Upsert(ctx, row); err != nil { + if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, err } return projectFromRow(row), nil } -func (m *manager) UpdateConfig(ctx context.Context, id domain.ProjectID, _ UpdateConfigInput) (Project, error) { - if err := validateProjectID(id); err != nil { - return Project{}, err - } - _, ok, err := m.store.Get(ctx, string(id)) - if err != nil { - return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") - } - if !ok { - return Project{}, notFound("PROJECT_NOT_FOUND", "Unknown project") - } - - return Project{}, notImplemented("PROJECT_CONFIG_NOT_IMPLEMENTED", "Project config patching is not available until config persistence is wired") -} - -func (m *manager) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) { +// Remove archives a project registration. +func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) { if err := validateProjectID(id); err != nil { return RemoveResult{}, err } - ok, err := m.store.Archive(ctx, string(id), time.Now()) + ok, err := m.store.ArchiveProject(ctx, string(id), time.Now()) if err != nil { return RemoveResult{}, internal("PROJECT_REMOVE_FAILED", "Failed to remove project") } @@ -148,36 +145,16 @@ func (m *manager) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult return RemoveResult{ProjectID: id, RemovedStorageDir: false}, nil } -func (m *manager) Repair(ctx context.Context, id domain.ProjectID) (Project, error) { - if err := validateProjectID(id); err != nil { - return Project{}, err - } - if _, ok, err := m.store.Get(ctx, string(id)); err != nil { - return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") - } else if !ok { - return Project{}, notFound("PROJECT_NOT_FOUND", "Unknown project") - } - return Project{}, badRequest("REPAIR_NOT_AVAILABLE", "Automatic repair is not available for this degraded config", nil) -} - -func (m *manager) Reload(ctx context.Context) (ReloadResult, error) { - projects, err := m.store.List(ctx) - if err != nil { - return ReloadResult{}, internal("RELOAD_FAILED", "Failed to reload projects") - } - return ReloadResult{Reloaded: true, ProjectCount: len(projects), DegradedCount: 0}, nil -} - -func (m *manager) suggestID(ctx context.Context, base domain.ProjectID) domain.ProjectID { +func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.ProjectID { for i := 1; ; i++ { candidate := domain.ProjectID(string(base) + strconv.Itoa(i)) - if _, ok, _ := m.store.Get(ctx, string(candidate)); !ok { + if _, ok, _ := m.store.GetProject(ctx, string(candidate)); !ok { return candidate } } } -func projectFromRow(row Row) Project { +func projectFromRow(row domain.ProjectRecord) Project { return Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), @@ -187,7 +164,7 @@ func projectFromRow(row Row) Project { } } -func displayName(row Row) string { +func displayName(row domain.ProjectRecord) string { if strings.TrimSpace(row.DisplayName) != "" { return row.DisplayName } diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go new file mode 100644 index 0000000000..d8ae22ec5a --- /dev/null +++ b/backend/internal/service/project/service_test.go @@ -0,0 +1,156 @@ +package project_test + +import ( + "context" + "errors" + "os/exec" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/service/project" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// newManager builds a Manager over a real, throwaway sqlite store (pure-Go +// driver, migrations run on Open) — no in-memory store. +func newManager(t *testing.T) project.Manager { + t.Helper() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + return project.New(store) +} + +// gitRepo creates a real git repository in a fresh temp dir and returns its path. +func gitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { + t.Fatalf("git unavailable: %v (%s)", err, out) + } + return dir +} + +func ptr(s string) *string { return &s } + +// wantCode asserts err is a *project.Error carrying the given machine code. +func wantCode(t *testing.T, err error, code string) { + t.Helper() + var e *project.Error + if !errors.As(err, &e) { + t.Fatalf("error = %v, want *project.Error", err) + } + if e.Code != code { + t.Fatalf("code = %q, want %q", e.Code, code) + } +} + +func TestManager_AddListGetRemove(t *testing.T) { + ctx := context.Background() + m := newManager(t) + repo := gitRepo(t) + + if got, err := m.List(ctx); err != nil || len(got) != 0 { + t.Fatalf("List() = %v, %v; want empty", got, err) + } + + proj, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao"), Name: ptr("Agent Orchestrator")}) + if err != nil { + t.Fatalf("Add: %v", err) + } + if proj.ID != "ao" || proj.Name != "Agent Orchestrator" || proj.Path != repo || proj.DefaultBranch != "main" { + t.Fatalf("Add returned %#v", proj) + } + + list, err := m.List(ctx) + if err != nil || len(list) != 1 || list[0].ID != "ao" { + t.Fatalf("List() = %v, %v; want [ao]", list, err) + } + + res, err := m.Get(ctx, "ao") + if err != nil { + t.Fatalf("Get: %v", err) + } + if res.Status != "ok" || res.Project == nil || res.Project.ID != "ao" { + t.Fatalf("Get = %#v", res) + } + + rm, err := m.Remove(ctx, "ao") + if err != nil { + t.Fatalf("Remove: %v", err) + } + if rm.ProjectID != "ao" || rm.RemovedStorageDir { + t.Fatalf("Remove = %#v", rm) + } + if list, _ := m.List(ctx); len(list) != 0 { + t.Fatalf("active list after remove = %d, want 0", len(list)) + } + _, err = m.Get(ctx, "ao") + wantCode(t, err, "PROJECT_NOT_FOUND") +} + +func TestManager_ReaddAfterRemove(t *testing.T) { + ctx := context.Background() + m := newManager(t) + repo := gitRepo(t) + + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil { + t.Fatalf("first Add: %v", err) + } + if _, err := m.Remove(ctx, "ao"); err != nil { + t.Fatalf("Remove: %v", err) + } + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao2")}); err != nil { + t.Fatalf("re-add same path after remove: %v", err) + } + + otherRepo := gitRepo(t) + if _, err := m.Remove(ctx, "ao2"); err != nil { + t.Fatalf("Remove ao2: %v", err) + } + if _, err := m.Add(ctx, project.AddInput{Path: otherRepo, ProjectID: ptr("ao2")}); err != nil { + t.Fatalf("re-add same id at different path after remove: %v", err) + } +} + +func TestManager_AddValidationAndConflicts(t *testing.T) { + ctx := context.Background() + m := newManager(t) + + _, err := m.Add(ctx, project.AddInput{Path: ""}) + wantCode(t, err, "PATH_REQUIRED") + + _, err = m.Add(ctx, project.AddInput{Path: t.TempDir()}) // exists but not a git repo + wantCode(t, err, "NOT_A_GIT_REPO") + + repoA, repoB := gitRepo(t), gitRepo(t) + if _, err := m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("shared")}); err != nil { + t.Fatalf("seed add: %v", err) + } + _, err = m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("other")}) + wantCode(t, err, "PATH_ALREADY_REGISTERED") + + _, err = m.Add(ctx, project.AddInput{Path: repoB, ProjectID: ptr("shared")}) + wantCode(t, err, "ID_ALREADY_REGISTERED") +} + +func TestManager_GetUpdateRemoveErrors(t *testing.T) { + ctx := context.Background() + m := newManager(t) + + _, err := m.Get(ctx, "nope") + wantCode(t, err, "PROJECT_NOT_FOUND") + + _, err = m.Get(ctx, domain.ProjectID("bad/id")) + wantCode(t, err, "INVALID_PROJECT_ID") + + _, err = m.Remove(ctx, "nope") + wantCode(t, err, "PROJECT_NOT_FOUND") + + repo := gitRepo(t) + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("p")}); err != nil { + t.Fatalf("seed: %v", err) + } +} diff --git a/backend/internal/service/project/store.go b/backend/internal/service/project/store.go new file mode 100644 index 0000000000..504179c89b --- /dev/null +++ b/backend/internal/service/project/store.go @@ -0,0 +1,17 @@ +package project + +import ( + "context" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// Store is the durable project persistence surface required by Service. +type Store interface { + ListProjects(ctx context.Context) ([]domain.ProjectRecord, error) + GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) + FindProjectByPath(ctx context.Context, path string) (domain.ProjectRecord, bool, error) + UpsertProject(ctx context.Context, row domain.ProjectRecord) error + ArchiveProject(ctx context.Context, id string, at time.Time) (bool, error) +} diff --git a/backend/internal/project/types.go b/backend/internal/service/project/types.go similarity index 54% rename from backend/internal/project/types.go rename to backend/internal/service/project/types.go index 9e1e8b944e..618500b489 100644 --- a/backend/internal/project/types.go +++ b/backend/internal/service/project/types.go @@ -2,17 +2,7 @@ package project import "github.com/aoagents/agent-orchestrator/backend/internal/domain" -// Project entities and the behaviour-config shapes they expose. These live in -// the project package (not domain/) because they are owned solely by the -// projects surface — only project identity (domain.ProjectID) is shared -// vocabulary with sessions/lifecycle/workspace, so that one type stays in -// domain. Keeping the entities, the Manager interface (project.go), and the -// transport DTOs (dto.go) together is the feature-package layout the backend -// is migrating toward. - -// Summary is the row shape returned by GET /api/v1/projects. ResolveError is -// set only for degraded projects, so the list can show them with a warning -// instead of dropping them silently. +// Summary is the row shape returned by GET /api/v1/projects. type Summary struct { ID domain.ProjectID `json:"id"` Name string `json:"name"` @@ -20,24 +10,19 @@ type Summary struct { ResolveError string `json:"resolveError,omitempty"` } -// Project is the full read-model returned by GET /api/v1/projects/{id} when the -// project resolves cleanly. It joins the registry identity fields with the -// project's behaviour config. +// Project is the full read-model returned by GET /api/v1/projects/{id}. type Project struct { ID domain.ProjectID `json:"id"` Name string `json:"name"` Path string `json:"path"` - Repo string `json:"repo"` // "owner/name" or "" + Repo string `json:"repo"` DefaultBranch string `json:"defaultBranch"` Agent string `json:"agent,omitempty"` Tracker *TrackerConfig `json:"tracker,omitempty"` SCM *SCMConfig `json:"scm,omitempty"` } -// Degraded is returned in place of Project when the project's config failed to -// load. The frontend uses ResolveError to render a recovery UI; the -// /projects/{id}/repair endpoint fixes a recoverable subset (e.g. legacy -// wrapped-config format). +// Degraded is returned in place of Project when project config failed to load. type Degraded struct { ID domain.ProjectID `json:"id"` Name string `json:"name"` @@ -45,18 +30,14 @@ type Degraded struct { ResolveError string `json:"resolveError"` } -// Behaviour-config shapes exposed by the projects API. Runtime selection and -// reaction rules are intentionally absent: the daemon has one runtime adapter and -// lifecycle owns agent nudges. - -// TrackerConfig mirrors TrackerConfigSchema. +// TrackerConfig mirrors tracker behaviour config exposed by the projects API. type TrackerConfig struct { Plugin string `json:"plugin,omitempty"` Package string `json:"package,omitempty"` Path string `json:"path,omitempty"` } -// SCMConfig mirrors SCMConfigSchema; Webhook nests its own optional block. +// SCMConfig mirrors SCM behaviour config exposed by the projects API. type SCMConfig struct { Plugin string `json:"plugin,omitempty"` Package string `json:"package,omitempty"` @@ -64,7 +45,7 @@ type SCMConfig struct { Webhook *SCMWebhookConfig `json:"webhook,omitempty"` } -// SCMWebhookConfig — pointer Enabled distinguishes unset from explicit false. +// SCMWebhookConfig describes SCM webhook settings. type SCMWebhookConfig struct { Enabled *bool `json:"enabled,omitempty"` Path string `json:"path,omitempty"` diff --git a/backend/internal/service/session.go b/backend/internal/service/session/service.go similarity index 73% rename from backend/internal/service/session.go rename to backend/internal/service/session/service.go index fae8d76bdd..fe2e63e7f3 100644 --- a/backend/internal/service/session.go +++ b/backend/internal/service/session/service.go @@ -1,4 +1,4 @@ -package service +package session import ( "context" @@ -9,37 +9,37 @@ import ( sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) -// SessionStore is the read-only persistence surface needed to assemble controller-facing session read models. -type SessionStore interface { +// Store is the read-only persistence surface needed to assemble controller-facing session read models. +type Store interface { GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) } -// SessionListFilter captures API-facing session list query filters. -type SessionListFilter struct { +// ListFilter captures API-facing session list query filters. +type ListFilter struct { ProjectID domain.ProjectID Active *bool OrchestratorOnly bool Fresh bool } -// Session is the controller-facing session service. It delegates command-side +// Service is the controller-facing session service. It delegates command-side // session operations to the internal sessionmanager.Manager and owns read-model // assembly, including user-facing display status derivation. -type Session struct { +type Service struct { manager *sessionmanager.Manager - store SessionStore + store Store } -// NewSession wires a controller-facing session service over an internal session Manager. -func NewSession(manager *sessionmanager.Manager, store SessionStore) *Session { - return &Session{manager: manager, store: store} +// New wires a controller-facing session service over an internal session Manager. +func New(manager *sessionmanager.Manager, store Store) *Service { + return &Service{manager: manager, store: store} } // Spawn creates a session and returns the API-facing read model. -func (s *Session) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { +func (s *Service) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { rec, err := s.manager.Spawn(ctx, cfg) if err != nil { return domain.Session{}, err @@ -48,7 +48,7 @@ func (s *Session) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess } // Restore relaunches a terminated session and returns the API-facing read model. -func (s *Session) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { +func (s *Service) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, err := s.manager.Restore(ctx, id) if err != nil { return domain.Session{}, err @@ -57,22 +57,22 @@ func (s *Session) Restore(ctx context.Context, id domain.SessionID) (domain.Sess } // Kill delegates terminal intent and teardown to the internal manager. -func (s *Session) Kill(ctx context.Context, id domain.SessionID) (bool, error) { +func (s *Service) Kill(ctx context.Context, id domain.SessionID) (bool, error) { return s.manager.Kill(ctx, id) } // Send delegates agent messaging to the internal manager. -func (s *Session) Send(ctx context.Context, id domain.SessionID, message string) error { +func (s *Service) Send(ctx context.Context, id domain.SessionID, message string) error { return s.manager.Send(ctx, id, message) } // Cleanup delegates terminal workspace cleanup to the internal manager. -func (s *Session) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) { +func (s *Service) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) { return s.manager.Cleanup(ctx, project) } // List returns sessions as enriched display models after applying API filters. -func (s *Session) List(ctx context.Context, filter SessionListFilter) ([]domain.Session, error) { +func (s *Service) List(ctx context.Context, filter ListFilter) ([]domain.Session, error) { recs, err := s.listRecords(ctx, filter.ProjectID) if err != nil { return nil, err @@ -91,7 +91,7 @@ func (s *Session) List(ctx context.Context, filter SessionListFilter) ([]domain. return out, nil } -func (s *Session) listRecords(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { +func (s *Service) listRecords(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { if project == "" { recs, err := s.store.ListAllSessions(ctx) if err != nil { @@ -106,7 +106,7 @@ func (s *Session) listRecords(ctx context.Context, project domain.ProjectID) ([] return recs, nil } -func matchesSessionFilter(rec domain.SessionRecord, filter SessionListFilter) bool { +func matchesSessionFilter(rec domain.SessionRecord, filter ListFilter) bool { if filter.Active != nil && rec.IsTerminated == *filter.Active { return false } @@ -120,7 +120,7 @@ func matchesSessionFilter(rec domain.SessionRecord, filter SessionListFilter) bo } // Get returns one session as an enriched display model, or sessionmanager.ErrNotFound if it is absent. -func (s *Session) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { +func (s *Service) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, ok, err := s.store.GetSession(ctx, id) if err != nil { return domain.Session{}, fmt.Errorf("get %s: %w", id, err) @@ -131,7 +131,7 @@ func (s *Session) Get(ctx context.Context, id domain.SessionID) (domain.Session, return s.toSession(ctx, rec) } -func (s *Session) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) { +func (s *Service) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) { pr, ok, err := s.store.GetDisplayPRFactsForSession(ctx, rec.ID) if err != nil { return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err) diff --git a/backend/internal/service/session_test.go b/backend/internal/service/session/service_test.go similarity index 54% rename from backend/internal/service/session_test.go rename to backend/internal/service/session/service_test.go index f092dc1957..25c9610f6e 100644 --- a/backend/internal/service/session_test.go +++ b/backend/internal/service/session/service_test.go @@ -1,4 +1,4 @@ -package service +package session import ( "context" @@ -8,29 +8,29 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -type fakeSessionStore struct { +type fakeStore struct { sessions map[domain.SessionID]domain.SessionRecord pr map[domain.SessionID]domain.PRFacts num int } -func newFakeSessionStore() *fakeSessionStore { - return &fakeSessionStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}} +func newFakeStore() *fakeStore { + return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}} } -func (f *fakeSessionStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { +func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { f.num++ rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num)) f.sessions[rec.ID] = rec return rec, nil } -func (f *fakeSessionStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { +func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { r, ok := f.sessions[id] return r, ok, nil } -func (f *fakeSessionStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) { +func (f *fakeStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) { var out []domain.SessionRecord for _, r := range f.sessions { if r.ProjectID == p { @@ -40,7 +40,7 @@ func (f *fakeSessionStore) ListSessions(_ context.Context, p domain.ProjectID) ( return out, nil } -func (f *fakeSessionStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) { +func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) { out := make([]domain.SessionRecord, 0, len(f.sessions)) for _, r := range f.sessions { out = append(out, r) @@ -48,17 +48,17 @@ func (f *fakeSessionStore) ListAllSessions(_ context.Context) ([]domain.SessionR return out, nil } -func (f *fakeSessionStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) { +func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) { pr, ok := f.pr[id] return pr, ok, nil } func TestSessionListDerivesStatusFromPRFacts(t *testing.T) { - st := newFakeSessionStore() + st := newFakeStore() st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}} st.pr["mer-1"] = domain.PRFacts{URL: "pr1", CI: domain.CIFailing} - list, err := (&Session{store: st}).List(context.Background(), SessionListFilter{ProjectID: "mer"}) + list, err := (&Service{store: st}).List(context.Background(), ListFilter{ProjectID: "mer"}) if err != nil { t.Fatal(err) } diff --git a/backend/internal/service/session_status.go b/backend/internal/service/session/status.go similarity index 98% rename from backend/internal/service/session_status.go rename to backend/internal/service/session/status.go index 801b7b191c..ee929852de 100644 --- a/backend/internal/service/session_status.go +++ b/backend/internal/service/session/status.go @@ -1,4 +1,4 @@ -package service +package session import "github.com/aoagents/agent-orchestrator/backend/internal/domain" diff --git a/backend/internal/service/session_status_test.go b/backend/internal/service/session/status_test.go similarity index 99% rename from backend/internal/service/session_status_test.go rename to backend/internal/service/session/status_test.go index b45125e123..3543214beb 100644 --- a/backend/internal/service/session_status_test.go +++ b/backend/internal/service/session/status_test.go @@ -1,4 +1,4 @@ -package service +package session import ( "testing" diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index be255c5bd2..89c99d1ea3 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -32,7 +32,7 @@ func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) const findProjectByPath = `-- name: FindProjectByPath :one SELECT id, path, repo_origin_url, display_name, registered_at, archived_at -FROM projects WHERE path = ? +FROM projects WHERE path = ? AND archived_at IS NULL ` func (q *Queries) FindProjectByPath(ctx context.Context, path string) (Project, error) { diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql index c5706035ef..3d41d0d55e 100644 --- a/backend/internal/storage/sqlite/queries/projects.sql +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -17,7 +17,7 @@ FROM projects WHERE archived_at IS NULL ORDER BY id; -- name: FindProjectByPath :one SELECT id, path, repo_origin_url, display_name, registered_at, archived_at -FROM projects WHERE path = ?; +FROM projects WHERE path = ? AND archived_at IS NULL; -- name: ArchiveProject :execrows -UPDATE projects SET archived_at = ? WHERE id = ?; +UPDATE projects SET archived_at = ? WHERE id = ? AND archived_at IS NULL; diff --git a/backend/internal/storage/sqlite/store/project_store.go b/backend/internal/storage/sqlite/store/project_store.go index 1d216d3e01..932205a89b 100644 --- a/backend/internal/storage/sqlite/store/project_store.go +++ b/backend/internal/storage/sqlite/store/project_store.go @@ -8,14 +8,11 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -var _ project.Store = (*Store)(nil) - -// Upsert inserts or replaces a registered project row. -func (s *Store) Upsert(ctx context.Context, r project.Row) error { +// UpsertProject inserts or replaces a registered project row. +func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error { s.writeMu.Lock() defer s.writeMu.Unlock() return s.qw.UpsertProject(ctx, gen.UpsertProjectParams{ @@ -28,45 +25,45 @@ func (s *Store) Upsert(ctx context.Context, r project.Row) error { }) } -// Get returns a project by id, active or archived. -func (s *Store) Get(ctx context.Context, id string) (project.Row, bool, error) { +// GetProject returns a project by id, active or archived. +func (s *Store) GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) { p, err := s.qr.GetProject(ctx, domain.ProjectID(id)) if errors.Is(err, sql.ErrNoRows) { - return project.Row{}, false, nil + return domain.ProjectRecord{}, false, nil } if err != nil { - return project.Row{}, false, fmt.Errorf("get project %s: %w", id, err) + return domain.ProjectRecord{}, false, fmt.Errorf("get project %s: %w", id, err) } return projectRowFromGen(p), true, nil } -// FindByPath returns a project registered at path, active or archived. -func (s *Store) FindByPath(ctx context.Context, path string) (project.Row, bool, error) { +// FindProjectByPath returns a project registered at path, active or archived. +func (s *Store) FindProjectByPath(ctx context.Context, path string) (domain.ProjectRecord, bool, error) { p, err := s.qr.FindProjectByPath(ctx, path) if errors.Is(err, sql.ErrNoRows) { - return project.Row{}, false, nil + return domain.ProjectRecord{}, false, nil } if err != nil { - return project.Row{}, false, fmt.Errorf("find project by path %s: %w", path, err) + return domain.ProjectRecord{}, false, fmt.Errorf("find project by path %s: %w", path, err) } return projectRowFromGen(p), true, nil } -// List returns active projects ordered by id. -func (s *Store) List(ctx context.Context) ([]project.Row, error) { +// ListProjects returns active projects ordered by id. +func (s *Store) ListProjects(ctx context.Context) ([]domain.ProjectRecord, error) { rows, err := s.qr.ListProjects(ctx) if err != nil { return nil, fmt.Errorf("list projects: %w", err) } - out := make([]project.Row, 0, len(rows)) + out := make([]domain.ProjectRecord, 0, len(rows)) for _, p := range rows { out = append(out, projectRowFromGen(p)) } return out, nil } -// Archive soft-deletes a project and reports whether a row was affected. -func (s *Store) Archive(ctx context.Context, id string, at time.Time) (bool, error) { +// ArchiveProject soft-deletes a project and reports whether a row was affected. +func (s *Store) ArchiveProject(ctx context.Context, id string, at time.Time) (bool, error) { s.writeMu.Lock() defer s.writeMu.Unlock() n, err := s.qw.ArchiveProject(ctx, gen.ArchiveProjectParams{ @@ -79,8 +76,8 @@ func (s *Store) Archive(ctx context.Context, id string, at time.Time) (bool, err return n > 0, nil } -func projectRowFromGen(p gen.Project) project.Row { - r := project.Row{ +func projectRowFromGen(p gen.Project) domain.ProjectRecord { + r := domain.ProjectRecord{ ID: string(p.ID), Path: p.Path, RepoOriginURL: p.RepoOriginURL, diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 9befe1c2c8..7731e5ca30 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -8,7 +8,6 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -24,7 +23,7 @@ func newTestStore(t *testing.T) *sqlite.Store { func seedProject(t *testing.T, s *sqlite.Store, id string) { t.Helper() - if err := s.Upsert(context.Background(), project.Row{ + if err := s.UpsertProject(context.Background(), domain.ProjectRecord{ ID: id, Path: "/tmp/" + id, RegisteredAt: time.Now().UTC().Truncate(time.Second), }); err != nil { t.Fatalf("seed project %s: %v", id, err) @@ -49,24 +48,24 @@ func TestProjectCRUDAndArchive(t *testing.T) { ctx := context.Background() seedProject(t, s, "mer") - got, ok, err := s.Get(ctx, "mer") + got, ok, err := s.GetProject(ctx, "mer") if err != nil || !ok { t.Fatalf("get: ok=%v err=%v", ok, err) } if got.ID != "mer" || got.Path != "/tmp/mer" { t.Fatalf("project = %+v", got) } - if list, _ := s.List(ctx); len(list) != 1 { + if list, _ := s.ListProjects(ctx); len(list) != 1 { t.Fatalf("active list = %d, want 1", len(list)) } // archive hides from the active list but still resolves by id. - if ok, err := s.Archive(ctx, "mer", time.Now().UTC()); err != nil || !ok { + if ok, err := s.ArchiveProject(ctx, "mer", time.Now().UTC()); err != nil || !ok { t.Fatalf("archive: ok=%v err=%v", ok, err) } - if list, _ := s.List(ctx); len(list) != 0 { + if list, _ := s.ListProjects(ctx); len(list) != 0 { t.Fatalf("after archive, active list = %d, want 0", len(list)) } - if _, ok, _ := s.Get(ctx, "mer"); !ok { + if _, ok, _ := s.GetProject(ctx, "mer"); !ok { t.Fatal("archived project must still resolve by id") } } From 3346c6cb6cdcedd5c4a12e695453cc6bf30aae71 Mon Sep 17 00:00:00 2001 From: yyovil Date: Tue, 2 Jun 2026 16:51:32 +0530 Subject: [PATCH 102/250] Add agent adapters and wire per-session agents into the session manager (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugin): add agents plugin (first iteration) Faithful copy of the agents plugin implementation from yyovil/better-ao (internal/plugin/ -> backend/internal/plugin/) plus its PRD (prds/plugins/agents/PRD.md), as a first-iteration proposal for review. Imports are left at their original github.com/yyovil/better-ao/... paths and are NOT yet reconciled to this repo's module; see PR description for the integration deltas (module path, missing internal/utils dependency). Co-authored-by: Claude * Move agent adapters under backend adapters * Keep daemon ports and session out of adapter move * Remove Better-AO naming from flake * Keep flake as dev shell only * Use goimports for local formatting * Wire session manager to per-session agent adapters Move the Agent port into internal/ports and have the claude-code and codex adapters implement it directly, alongside their workspace-local activity hooks and a manifest-keyed adapter registry. Rename RuntimeConfig.LaunchCommand to Argv and update the tmux and zellij runtimes to match. The session Manager now resolves a real agent adapter per session via a new ports.AgentResolver: from cfg.Harness on Spawn and the stored harness on Restore, so one daemon runs claude-code and codex sessions side by side. The daemon backs the resolver with the registry; AO_AGENT selects the default harness (default claude-code), validated at startup. Removes the temporary noopAgent stub. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(agent): point the agent contract at internal/ports/agent.go The Agent interface moved from internal/adapters/agent to internal/ports; update the PRD's Goal and Agent Contract sections (and the SessionInfo references) to match the code. Co-Authored-By: Claude Opus 4.8 (1M context) * Wire the session service into the daemon daemon.Run now builds the controller-facing session service — a session manager over the zellij runtime, a gitworktree workspace, the shared store + LCM, and the per-session agent resolver (AO_AGENT default, validated at startup) — and mounts it at httpd APIDeps.Sessions, so the session REST routes are backed by a real service. startLifecycle moves ahead of the HTTP server so both share one LCM. Co-Authored-By: Claude Opus 4.8 (1M context) * Address Greptile review: complete the live spawn path - Spawn and Restore now install workspace-local activity hooks (GetAgentHooks) and run the adapter's optional PreLaunch step before launch, via a shared prepareWorkspace helper. PreLaunch is how Claude Code records workspace trust, so its interactive "trust this folder?" dialog can't hang the headless pane; the spawned env now also carries AO_DATA_DIR so the installed hook commands find the store. - claudecode and codex hook/config writes are now atomic (temp + rename) instead of os.WriteFile, so a crash mid-write can't leave a partial file the agent fails to parse. - ensureWorkspaceTrusted serializes its read-modify-write under a package mutex, so concurrent spawns to different workspaces don't drop each other's ~/.claude.json trust entries. Co-Authored-By: Claude Opus 4.8 (1M context) * test(ports): pin MetadataKeyAgentSessionID to domain.SessionMetadata json tag The equality between ports.MetadataKeyAgentSessionID and the json tag on domain.SessionMetadata.AgentSessionID is a hand-maintained invariant; this test fails loudly if either side drifts. Co-Authored-By: Claude Opus 4.7 * refactor(adapters): use ports.MetadataKeyAgentSessionID in claudecode + codex The native session id metadata key is defined in ports for cross-package consumption; drop the duplicated literals in each adapter so the constant has one home. Co-Authored-By: Claude Opus 4.7 * test(codex): cover ensureCodexHooksFeatureEnabled TOML edge cases The helper is a string editor over config.toml; pin its content transformation for missing/empty files, existing [features] blocks, the no-op case, and the legacy codex_hooks=true migration paths. Co-Authored-By: Claude Opus 4.7 * docs(adapters): document Registry concurrency contract Registry registration runs at daemon boot before any goroutine calls Get, so the underlying map needs no lock; pin that contract in the doc comment so a future change doesn't quietly introduce a race. Co-Authored-By: Claude Opus 4.7 * style(codex): gofmt codex_test.go after constant rename The previous commit (7c5b2a9) replaced codexAgentSessionIDMetadataKey with ports.MetadataKeyAgentSessionID inside a map literal; the longer key threw off gofmt's column alignment on the adjacent codexTitleMetadataKey / codexSummaryMetadataKey lines. Caught by agent-ci's Check formatting step. Co-Authored-By: Claude Opus 4.7 Co-authored-by: harshitsinghbhandari --- .envrc | 1 + .gitignore | 3 + backend/.golangci.yml | 2 +- .../adapters/agent/claudecode/claudecode.go | 455 +++++++++++++++ .../agent/claudecode/claudecode_test.go | 539 ++++++++++++++++++ .../adapters/agent/claudecode/hooks.go | 360 ++++++++++++ .../internal/adapters/agent/codex/codex.go | 258 +++++++++ .../adapters/agent/codex/codex_test.go | 476 ++++++++++++++++ .../internal/adapters/agent/codex/hooks.go | 409 +++++++++++++ backend/internal/adapters/registry.go | 83 +++ .../adapters/runtime/zellij/commands.go | 40 +- .../adapters/runtime/zellij/zellij.go | 2 +- .../runtime/zellij/zellij_integration_test.go | 4 +- .../adapters/runtime/zellij/zellij_test.go | 16 +- backend/internal/config/config.go | 13 + backend/internal/daemon/daemon.go | 29 +- backend/internal/daemon/lifecycle_wiring.go | 120 +++- backend/internal/daemon/wiring_test.go | 64 +++ .../integration/lifecycle_sqlite_test.go | 29 +- backend/internal/ports/agent.go | 146 +++++ backend/internal/ports/agent_test.go | 24 + backend/internal/ports/outbound.go | 20 +- backend/internal/session_manager/manager.go | 134 ++++- .../internal/session_manager/manager_test.go | 29 +- .../terminal/session_integration_test.go | 2 +- docs/agent/README.md | 117 ++++ flake.lock | 61 ++ flake.nix | 41 ++ 28 files changed, 3406 insertions(+), 71 deletions(-) create mode 100644 .envrc create mode 100644 backend/internal/adapters/agent/claudecode/claudecode.go create mode 100644 backend/internal/adapters/agent/claudecode/claudecode_test.go create mode 100644 backend/internal/adapters/agent/claudecode/hooks.go create mode 100644 backend/internal/adapters/agent/codex/codex.go create mode 100644 backend/internal/adapters/agent/codex/codex_test.go create mode 100644 backend/internal/adapters/agent/codex/hooks.go create mode 100644 backend/internal/adapters/registry.go create mode 100644 backend/internal/ports/agent.go create mode 100644 backend/internal/ports/agent_test.go create mode 100644 docs/agent/README.md create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000000..3550a30f2d --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index c883e6e68a..85b143546d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Node / Electron node_modules/ +.pnpm/ dist/ out/ build/ @@ -9,6 +10,7 @@ yarn-debug.log* yarn-error.log* # Go +.go/ bin/ *.test *.out @@ -30,6 +32,7 @@ session-events.jsonl.* .ao/ # Environment +.direnv/ .env .env.* !.env.example diff --git a/backend/.golangci.yml b/backend/.golangci.yml index 438dd020c8..8cc8082fff 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -85,6 +85,7 @@ linters: excludes: - G104 # unchecked errors — errcheck owns this - G304 # file inclusion via variable — paths are config/run-file/worktree-derived, not user input + - G703 # path traversal via taint analysis — same as G304: binary-resolution and worktree-derived paths, not user input exclusions: generated: lax # skip sqlc/codegen ("Code generated ... DO NOT EDIT") @@ -107,7 +108,6 @@ linters: formatters: enable: - - gofmt - goimports settings: goimports: diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go new file mode 100644 index 0000000000..0c94359f89 --- /dev/null +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -0,0 +1,455 @@ +// Package claudecode implements the Claude Code agent adapter. +// +// It builds the argv to launch `claude` as an interactive session inside a +// session's worktree, installs worktree-local hooks that report normalized +// session metadata (native id, title, summary) back into AO's store, +// and supports resume: GetLaunchCommand pins a stable `--session-id` so +// GetRestoreCommand can rebuild `claude --resume `. SessionInfo reads the +// hook-captured metadata from the store — it does not parse transcripts. +// GetConfigSpec remains a no-op (no agent-specific config keys yet). +// +// Claude Code starts an interactive session by default (no -p/--print), which +// is exactly what AO wants: a live agent the user can attach to in the +// browser terminal or via `zellij attach`. The initial task prompt is passed +// as the positional argument; the orchestrator system prompt (if any) is +// appended to Claude's default system prompt so its built-in coding +// instructions are preserved. +package claudecode + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/google/uuid" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // adapterID is the registry id and the value users pass to + // `ao spawn --agent`. + adapterID = "claude-code" + + // Normalized session-metadata keys the Claude Code hooks persist into the + // AO session store and SessionInfo reads back. Shared vocabulary + // with the Codex adapter so the dashboard treats every agent uniformly. + // The native session id key lives in ports as MetadataKeyAgentSessionID + // because the Session Manager also reads it. + claudeTitleMetadataKey = "title" + claudeSummaryMetadataKey = "summary" +) + +// claudeSessionNamespace seeds the UUIDv5 derivation that maps an AO +// session id onto a stable Claude Code `--session-id`. A fixed namespace makes +// the mapping deterministic, so GetLaunchCommand (which pins --session-id at +// launch) and GetRestoreCommand (which recomputes it as a fallback for +// pre-hook sessions) agree without persisting anything. +var claudeSessionNamespace = uuid.MustParse("a1f0c3d2-7b54-4e96-8a2b-0d9e1f2a3b4c") + +// Plugin is the Claude Code agent adapter. It is safe for concurrent use; the +// binary path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Claude Code adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Claude Code", + Description: "Run Claude Code worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Claude Code exposes +// none yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start an interactive Claude Code +// session. Shape: +// +// claude [--session-id ] \ +// [--permission-mode ] \ +// [--append-system-prompt ] \ +// [-- ] +// +// --session-id pins Claude's native session UUID to a value derived from the +// AO session id, so the session is resumable later (see +// GetRestoreCommand) and its transcript is locatable (see SessionInfo) without +// a separate capture step. +// +// is acceptEdits, auto, or bypassPermissions. AO's "default" +// mode emits no --permission-mode flag, so Claude's TUI resolves the starting +// mode from ~/.claude/settings.json exactly as a normal launch. +// +// The prompt is passed after `--` so a prompt beginning with "-" is not +// mistaken for a flag. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.claudeBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + if cfg.SessionID != "" { + cmd = append(cmd, "--session-id", claudeSessionUUID(cfg.SessionID)) + } + appendPermissionFlags(&cmd, cfg.Permissions) + + systemPrompt, err := resolveSystemPrompt(cfg) + if err != nil { + return nil, err + } + if systemPrompt != "" { + // Append rather than replace: Claude Code's default system prompt + // carries its tool-use and coding instructions, which we want to + // keep. The orchestrator prompt layers on top. + cmd = append(cmd, "--append-system-prompt", systemPrompt) + } + + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Claude Code receives its prompt in the +// launch command itself. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// PreLaunch is an optional capability the spawn engine invokes (via type +// assertion) immediately before creating the session. Claude Code shows a +// blocking "do you trust this folder?" dialog the first time it runs in any +// directory. Every AO worktree is a fresh path, so without this the +// agent would hang at that prompt with no one to answer it. +// +// An AO worktree is derived from the repo the user is already running +// AO in, so it is inherently trusted. PreLaunch records that trust in +// ~/.claude.json before launch, additively and atomically, so it cannot +// clobber a concurrently-running Claude instance's config. +func (p *Plugin) PreLaunch(ctx context.Context, cfg ports.LaunchConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if cfg.WorkspacePath == "" { + return nil + } + cfgPath, err := claudeConfigPath() + if err != nil { + return err + } + return ensureWorkspaceTrusted(cfgPath, cfg.WorkspacePath) +} + +// GetRestoreCommand rebuilds the argv that continues an existing Claude Code +// session: `claude [--permission-mode ] --resume `. It +// prefers the hook-captured native session id from +// cfg.Session.Metadata["agentSessionId"]; for sessions created before hooks +// captured it, it falls back to the deterministic UUID AO pins via +// --session-id at launch. ok is false only when neither is available, so the +// caller fresh-spawns. The command re-applies the permission mode (resume +// otherwise reverts to the configured default) but not the prompt/system +// prompt, which the session already carries. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + + sessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if sessionID == "" && cfg.Session.ID != "" { + // Explicit fallback for pre-hook sessions: the id AO + // deterministically pinned via --session-id at launch. + sessionID = claudeSessionUUID(cfg.Session.ID) + } + if sessionID == "" { + return nil, false, nil + } + + binary, err := p.claudeBinary(ctx) + if err != nil { + return nil, false, err + } + cmd = make([]string, 0, 5) + cmd = append(cmd, binary) + appendPermissionFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--resume", sessionID) + return cmd, true, nil +} + +// SessionInfo surfaces the normalized session metadata that the Claude Code +// hooks persisted into AO's store: the native session id, the title (the +// first user prompt), and the summary (the final assistant message). It reads +// only from session.Metadata — never from transcript files — and returns +// ok=false when none of those fields are present. Metadata is intentionally nil: +// there is no Claude-specific field callers need beyond the normalized ones. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[claudeTitleMetadataKey], + Summary: session.Metadata[claudeSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// claudeSessionUUID maps an AO session id onto a stable Claude Code +// session UUID via UUIDv5 over a fixed namespace, so the same AO session +// always resolves to the same Claude session. +func claudeSessionUUID(aoSessionID string) string { + return uuid.NewSHA1(claudeSessionNamespace, []byte(aoSessionID)).String() +} + +// resolveSystemPrompt returns the system prompt text to append, preferring +// SystemPromptFile (read from disk) over an inline SystemPrompt. +func resolveSystemPrompt(cfg ports.LaunchConfig) (string, error) { + if cfg.SystemPromptFile != "" { + data, err := os.ReadFile(cfg.SystemPromptFile) + if err != nil { + return "", fmt.Errorf("claude-code: read system prompt file: %w", err) + } + return strings.TrimRight(string(data), "\n"), nil + } + return cfg.SystemPrompt, nil +} + +// appendPermissionFlags maps AO's permission modes onto Claude Code's +// --permission-mode values: +// - default → no flag. Claude's TUI resolves the starting mode +// from ~/.claude/settings.json (defaultMode), exactly as a normal launch. +// - accept-edits → --permission-mode acceptEdits (auto-accept edits + +// safe filesystem bash; still prompts for network/system bash, MCP, web) +// - auto → --permission-mode auto (classifier-gated +// auto-approval; auto-runs what a safety model deems safe) +// - bypass-permissions → --permission-mode bypassPermissions (skip all +// checks; equivalent to --dangerously-skip-permissions) +// +// Empty/unrecognized normalizes to default, so no flag is emitted. +func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's settings.json defaultMode. + case ports.PermissionModeAcceptEdits: + *cmd = append(*cmd, "--permission-mode", "acceptEdits") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--permission-mode", "auto") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--permission-mode", "bypassPermissions") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + // Empty or unrecognized: defer to settings.json (no flag). + return ports.PermissionModeDefault + } +} + +// ResolveClaudeBinary finds the `claude` binary, searching PATH then a few +// well-known install locations (the native installer's ~/.local/bin, npm +// global, Homebrew). Returns "claude" as a last resort so callers get a +// clear "command not found" rather than an empty argv. +func ResolveClaudeBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"claude.cmd", "claude.exe", "claude"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "claude.cmd"), + filepath.Join(appData, "npm", "claude.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + } + return "claude", nil + } + + if path, err := exec.LookPath("claude"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/claude", + "/opt/homebrew/bin/claude", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "claude"), + filepath.Join(home, ".npm", "bin", "claude"), + filepath.Join(home, ".claude", "local", "claude"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "claude", nil +} + +func (p *Plugin) claudeBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveClaudeBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +// claudeConfigPath returns the path to Claude Code's global config file, +// ~/.claude.json. +func claudeConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("claude-code: resolve home directory: %w", err) + } + return filepath.Join(home, ".claude.json"), nil +} + +// ensureWorkspaceTrusted records workspacePath as trusted in Claude Code's +// config so the interactive trust dialog does not block a spawned session. +// +// It is additive and concurrency-safe: it reads the existing config, sets +// only projects[workspacePath].hasTrustDialogAccepted = true (preserving the +// rest of the entry and every other project), and writes back via a +// temp-file + atomic rename. If the path is already trusted, it makes no +// write at all. A missing config file is treated as an empty one. +// claudeTrustMu serializes ensureWorkspaceTrusted within the process. Concurrent +// spawns to different workspaces otherwise read the same ~/.claude.json snapshot +// and the last rename drops the other's trust entry. +var claudeTrustMu sync.Mutex + +func ensureWorkspaceTrusted(configPath, workspacePath string) error { + claudeTrustMu.Lock() + defer claudeTrustMu.Unlock() + + root := map[string]any{} + data, err := os.ReadFile(configPath) + switch { + case err == nil: + if len(data) > 0 { + if err := json.Unmarshal(data, &root); err != nil { + return fmt.Errorf("claude-code: parse %s: %w", configPath, err) + } + } + case os.IsNotExist(err): + // Treat as empty config; we'll create it. + default: + return fmt.Errorf("claude-code: read %s: %w", configPath, err) + } + + projects, _ := root["projects"].(map[string]any) + if projects == nil { + projects = map[string]any{} + root["projects"] = projects + } + + entry, _ := projects[workspacePath].(map[string]any) + if entry == nil { + entry = map[string]any{} + projects[workspacePath] = entry + } + + if trusted, ok := entry["hasTrustDialogAccepted"].(bool); ok && trusted { + // Already trusted — no write needed, so no race window at all. + return nil + } + entry["hasTrustDialogAccepted"] = true + + out, err := json.MarshalIndent(root, "", " ") + if err != nil { + return fmt.Errorf("claude-code: encode %s: %w", configPath, err) + } + + // Atomic write: temp file in the same directory, then rename. Matches + // how Claude Code itself updates this file, so concurrent updates are + // last-writer-wins rather than corrupting. + dir := filepath.Dir(configPath) + tmp, err := os.CreateTemp(dir, ".claude.json.tmp-*") + if err != nil { + return fmt.Errorf("claude-code: create temp config: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once renamed + + if _, err := tmp.Write(out); err != nil { + _ = tmp.Close() + return fmt.Errorf("claude-code: write temp config: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("claude-code: close temp config: %w", err) + } + if err := os.Rename(tmpName, configPath); err != nil { + return fmt.Errorf("claude-code: replace config: %w", err) + } + return nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go new file mode 100644 index 0000000000..6fea03a9ae --- /dev/null +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -0,0 +1,539 @@ +package claudecode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/google/uuid" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGetLaunchCommandBypassWithPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-add a health check", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "claude", + "--permission-mode", "bypassPermissions", + "--", "-add a health check", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected string + }{ + {"default omits flag (defers to settings.json)", ports.PermissionModeDefault, nil, "--permission-mode"}, + {"accept-edits", ports.PermissionModeAcceptEdits, []string{"--permission-mode", "acceptEdits"}, ""}, + {"auto", ports.PermissionModeAuto, []string{"--permission-mode", "auto"}, ""}, + {"bypass-permissions", ports.PermissionModeBypassPermissions, []string{"--permission-mode", "bypassPermissions"}, ""}, + {"empty omits permission flags", "", nil, "--permission-mode"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + if tt.notExpected != "" && contains(cmd, tt.notExpected) { + t.Fatalf("command %#v unexpectedly contains %q", cmd, tt.notExpected) + } + }) + } +} + +func TestGetLaunchCommandAppendsSystemPromptFromFile(t *testing.T) { + dir := t.TempDir() + promptFile := filepath.Join(dir, "system.md") + if err := os.WriteFile(promptFile, []byte("You are an orchestrator.\n"), 0o644); err != nil { + t.Fatal(err) + } + + p := &Plugin{resolvedBinary: "claude"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPromptFile: promptFile, + Prompt: "do the thing", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "claude", + "--append-system-prompt", "You are an orchestrator.", + "--", "do the thing", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandInlineSystemPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPrompt: "inline instructions", + }) + if err != nil { + t.Fatal(err) + } + if !containsSubsequence(cmd, []string{"--append-system-prompt", "inline instructions"}) { + t.Fatalf("command %#v does not append inline system prompt", cmd) + } +} + +func TestGetLaunchCommandMissingSystemPromptFileErrors(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + _, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPromptFile: filepath.Join(t.TempDir(), "does-not-exist.md"), + }) + if err == nil { + t.Fatal("expected error for missing system prompt file") + } +} + +func TestGetLaunchCommandInjectsSessionID(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SessionID: "e0tt49", + Prompt: "do the thing", + }) + if err != nil { + t.Fatal(err) + } + wantUUID := claudeSessionUUID("e0tt49") + if !containsSubsequence(cmd, []string{"--session-id", wantUUID}) { + t.Fatalf("command %#v missing --session-id %q", cmd, wantUUID) + } + + // No SessionID → no --session-id flag. + cmd, err = p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Prompt: "x"}) + if err != nil { + t.Fatal(err) + } + if contains(cmd, "--session-id") { + t.Fatalf("command %#v unexpectedly contains --session-id", cmd) + } +} + +func TestClaudeSessionUUIDDeterministicAndUnique(t *testing.T) { + a1 := claudeSessionUUID("alpha") + a2 := claudeSessionUUID("alpha") + b := claudeSessionUUID("beta") + if a1 != a2 { + t.Fatalf("derivation not deterministic: %q != %q", a1, a2) + } + if a1 == b { + t.Fatalf("distinct ids collided: both %q", a1) + } + if _, err := uuid.Parse(a1); err != nil { + t.Fatalf("derived value is not a valid UUID: %q (%v)", a1, err) + } +} + +func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + workspace := t.TempDir() + settingsDir := filepath.Join(workspace, ".claude") + if err := os.MkdirAll(settingsDir, 0o755); err != nil { + t.Fatal(err) + } + settingsPath := filepath.Join(settingsDir, "settings.local.json") + // Pre-seed a user's own Stop hook + an unrelated setting; both must survive. + existing := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"my own stop hook","timeout":5}]}]},"permissions":{"defaultMode":"plan"}}` + if err := os.WriteFile(settingsPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + if err := p.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + // A second install must not duplicate AO hook commands. + if err := p.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatal(err) + } + var config struct { + Hooks map[string][]claudeMatcherGroup `json:"hooks"` + Permissions json.RawMessage `json:"permissions"` + } + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + if config.Hooks == nil { + t.Fatalf("hooks object missing: %s", data) + } + + // Every managed command is installed exactly once under its event. + for _, spec := range claudeManagedHooks { + if got := countClaudeHookCommand(config.Hooks[spec.Event], spec.Command); got != 1 { + t.Fatalf("%s command %q count = %d, want 1", spec.Event, spec.Command, got) + } + } + // Existing user hook preserved. + if countClaudeHookCommand(config.Hooks["Stop"], "my own stop hook") != 1 { + t.Fatalf("existing Stop hook not preserved: %#v", config.Hooks["Stop"]) + } + // Unrelated settings preserved. + if len(config.Permissions) == 0 { + t.Fatalf("unrelated settings clobbered: %s", data) + } + // SessionStart carries the required matcher; UserPromptSubmit omits it. + if m := matcherForCommand(config.Hooks["SessionStart"], "ao hooks claude-code session-start"); m == nil || *m != "startup" { + t.Fatalf("SessionStart matcher = %v, want startup", m) + } + if m := matcherForCommand(config.Hooks["UserPromptSubmit"], "ao hooks claude-code user-prompt-submit"); m != nil { + t.Fatalf("UserPromptSubmit matcher = %v, want none", m) + } +} + +func TestUninstallHooksRemovesClaudeHooks(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + workspace := t.TempDir() + settingsPath := filepath.Join(workspace, ".claude", "settings.local.json") + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own Stop hook + an unrelated setting; both must survive. + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"my own stop hook","timeout":5}]}]},"permissions":{"defaultMode":"plan"}}` + if err := os.WriteFile(settingsPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := p.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := p.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := p.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := p.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatal(err) + } + var config struct { + Hooks map[string][]claudeMatcherGroup `json:"hooks"` + Permissions json.RawMessage `json:"permissions"` + } + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + // No managed command survives; the SessionStart/UserPromptSubmit events, + // which held only AO hooks, are removed entirely. + for _, spec := range claudeManagedHooks { + if got := countClaudeHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 { + t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got) + } + } + // The user's own Stop hook and unrelated settings are preserved. + if countClaudeHookCommand(config.Hooks["Stop"], "my own stop hook") != 1 { + t.Fatalf("user Stop hook not preserved: %#v", config.Hooks["Stop"]) + } + if len(config.Permissions) == 0 { + t.Fatalf("unrelated settings clobbered: %s", data) + } + + // Uninstall is idempotent: a second call is a clean no-op. + if err := p.UninstallHooks(ctx, workspace); err != nil { + t.Fatalf("second uninstall: %v", err) + } +} + +func TestUninstallHooksNoSettingsFile(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + workspace := t.TempDir() + if err := p.UninstallHooks(context.Background(), workspace); err != nil { + t.Fatalf("uninstall with no settings file: %v", err) + } + if installed, err := p.AreHooksInstalled(context.Background(), workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled = (%v, %v), want (false, nil)", installed, err) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + info, ok, err := (&Plugin{resolvedBinary: "claude"}).SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "claude-native-1", + claudeTitleMetadataKey: "Fix login redirect", + claudeSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil || !ok { + t.Fatalf("SessionInfo = (ok=%v, err=%v), want ok", ok, err) + } + if info.AgentSessionID != "claude-native-1" { + t.Fatalf("AgentSessionID = %q", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for Claude", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + info, ok, err := (&Plugin{resolvedBinary: "claude"}).SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +// countClaudeHookCommand counts how many hook entries under one event register +// the given command — used to prove no duplicate AO hooks. +func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int { + count := 0 + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + count++ + } + } + } + return count +} + +// matcherForCommand returns the matcher on the group that registers the given +// command (nil if the group has no matcher). +func matcherForCommand(groups []claudeMatcherGroup, command string) *string { + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + return group.Matcher + } + } + } + return nil +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Session: ports.SessionRef{ + ID: "sess-r", + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "claude-native-1"}, + }, + }) + if err != nil || !ok { + t.Fatalf("restore = (ok=%v, err=%v), want ok", ok, err) + } + // The hook-captured native id wins over the derived fallback. + want := []string{"claude", "--permission-mode", "bypassPermissions", "--resume", "claude-native-1"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFallsBackToDerivedUUID(t *testing.T) { + // No agentSessionId captured (pre-hook session) → derive deterministically + // from the AO session id, the explicit fallback. + cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Session: ports.SessionRef{ID: "sess-r"}, + }) + if err != nil || !ok { + t.Fatalf("restore = (ok=%v, err=%v), want ok", ok, err) + } + want := []string{"claude", "--permission-mode", "bypassPermissions", "--resume", claudeSessionUUID("sess-r")} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutSessionID(t *testing.T) { + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty ref", ports.SessionRef{}}, + {"blank agent session, no id", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(), + ports.RestoreConfig{Permissions: ports.PermissionModeBypassPermissions, Session: tc.ref}) + if err != nil || ok || cmd != nil { + t.Fatalf("restore = (%#v, %v, %v), want (nil,false,nil)", cmd, ok, err) + } + }) + } +} + +func TestManifestID(t *testing.T) { + if got := New().Manifest().ID; got != "claude-code" { + t.Fatalf("manifest id = %q, want claude-code", got) + } +} + +func TestEnsureWorkspaceTrustedCreatesEntry(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, ".claude.json") + // Seed an existing config with another project + a top-level key, to + // prove we preserve unrelated state. + seed := `{"userID":"abc","projects":{"/existing/proj":{"hasTrustDialogAccepted":true,"lastCost":1.5}}}` + if err := os.WriteFile(cfgPath, []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + + work := "/Users/me/.ao/worktrees/01ABC" + if err := ensureWorkspaceTrusted(cfgPath, work); err != nil { + t.Fatalf("ensureWorkspaceTrusted: %v", err) + } + + root := readJSON(t, cfgPath) + projects := root["projects"].(map[string]any) + + // New entry trusted. + newEntry := projects[work].(map[string]any) + if newEntry["hasTrustDialogAccepted"] != true { + t.Fatalf("new entry not trusted: %#v", newEntry) + } + // Existing project preserved (including its other fields). + existing := projects["/existing/proj"].(map[string]any) + if existing["hasTrustDialogAccepted"] != true || existing["lastCost"].(float64) != 1.5 { + t.Fatalf("existing project clobbered: %#v", existing) + } + // Top-level key preserved. + if root["userID"] != "abc" { + t.Fatalf("top-level key clobbered: %#v", root["userID"]) + } +} + +func TestEnsureWorkspaceTrustedIsIdempotentAndNoWriteWhenAlreadyTrusted(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, ".claude.json") + work := "/w" + if err := os.WriteFile(cfgPath, []byte(`{"projects":{"/w":{"hasTrustDialogAccepted":true}}}`), 0o600); err != nil { + t.Fatal(err) + } + info1, err := os.Stat(cfgPath) + if err != nil { + t.Fatal(err) + } + + if err := ensureWorkspaceTrusted(cfgPath, work); err != nil { + t.Fatalf("ensureWorkspaceTrusted: %v", err) + } + + // Already trusted → no rewrite → mtime unchanged. + info2, err := os.Stat(cfgPath) + if err != nil { + t.Fatal(err) + } + if !info1.ModTime().Equal(info2.ModTime()) { + t.Fatal("expected no rewrite when already trusted") + } +} + +func TestEnsureWorkspaceTrustedCreatesMissingConfig(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, ".claude.json") // does not exist yet + work := "/fresh/worktree" + + if err := ensureWorkspaceTrusted(cfgPath, work); err != nil { + t.Fatalf("ensureWorkspaceTrusted: %v", err) + } + + root := readJSON(t, cfgPath) + projects := root["projects"].(map[string]any) + entry := projects[work].(map[string]any) + if entry["hasTrustDialogAccepted"] != true { + t.Fatalf("entry not trusted in freshly-created config: %#v", entry) + } +} + +func readJSON(t *testing.T, path string) map[string]any { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + return m +} + +func contains(values []string, needle string) bool { + for _, v := range values { + if v == needle { + return true + } + } + return false +} + +func containsSubsequence(values, needle []string) bool { + if len(needle) == 0 { + return true + } + for start := 0; start+len(needle) <= len(values); start++ { + ok := true + for i, w := range needle { + if values[start+i] != w { + ok = false + break + } + } + if ok { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/claudecode/hooks.go b/backend/internal/adapters/agent/claudecode/hooks.go new file mode 100644 index 0000000000..56c45c1f04 --- /dev/null +++ b/backend/internal/adapters/agent/claudecode/hooks.go @@ -0,0 +1,360 @@ +package claudecode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + claudeSettingsDirName = ".claude" + claudeSettingsFileName = "settings.local.json" + + // claudeHookCommandPrefix identifies the hook commands AO owns. Every + // managed command starts with it, so install can skip duplicates and + // uninstall can recognize AO entries by prefix without an embedded + // template to diff against. + claudeHookCommandPrefix = "ao hooks claude-code " + claudeHookTimeout = 30 +) + +type claudeMatcherGroup struct { + // Matcher is a pointer so it round-trips exactly: SessionStart requires a + // real matcher ("startup"); UserPromptSubmit/Stop omit it (Claude ignores + // matcher for those events). omitempty drops a nil matcher on write. + Matcher *string `json:"matcher,omitempty"` + Hooks []claudeHookEntry `json:"hooks"` +} + +type claudeHookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// claudeHookSpec describes one hook AO installs, defined in code rather +// than read from an embedded settings file. +type claudeHookSpec struct { + Event string + Matcher *string + Command string +} + +// claudeStartupMatcher is referenced by pointer so SessionStart serializes with +// its required "startup" matcher. +var claudeStartupMatcher = "startup" + +// claudeManagedHooks is the source of truth for the hooks AO installs: +// SessionStart (under the "startup" matcher), UserPromptSubmit, and Stop. Each +// reports normalized session metadata back into AO's store. +var claudeManagedHooks = []claudeHookSpec{ + {Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"}, + {Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"}, + {Event: "Stop", Command: claudeHookCommandPrefix + "stop"}, +} + +// GetAgentHooks installs AO's Claude Code hooks into the worktree-local +// .claude/settings.local.json file (the per-session local settings, not the +// shared .claude/settings.json). The hooks (SessionStart, UserPromptSubmit, +// Stop) report normalized session metadata back into AO's store. Existing +// hooks and unrelated settings are preserved, and duplicate AO commands +// are not appended, so the install is idempotent. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("claude-code.GetAgentHooks: WorkspacePath is required") + } + + settingsPath := claudeSettingsPath(cfg.WorkspacePath) + topLevel, rawHooks, err := readClaudeSettings(settingsPath) + if err != nil { + return fmt.Errorf("claude-code.GetAgentHooks: %w", err) + } + + for event, specs := range groupClaudeHooksByEvent() { + var existingGroups []claudeMatcherGroup + if err := parseClaudeHookType(rawHooks, event, &existingGroups); err != nil { + return fmt.Errorf("claude-code.GetAgentHooks: %w", err) + } + for _, spec := range specs { + if !claudeHookCommandExists(existingGroups, spec.Command) { + entry := claudeHookEntry{Type: "command", Command: spec.Command, Timeout: claudeHookTimeout} + existingGroups = addClaudeHook(existingGroups, entry, spec.Matcher) + } + } + if err := marshalClaudeHookType(rawHooks, event, existingGroups); err != nil { + return fmt.Errorf("claude-code.GetAgentHooks: %w", err) + } + } + + if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("claude-code.GetAgentHooks: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Claude Code hooks from the workspace-local +// .claude/settings.local.json file, leaving user-defined hooks and unrelated +// settings untouched. A missing settings file is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("claude-code.UninstallHooks: workspacePath is required") + } + + settingsPath := claudeSettingsPath(workspacePath) + if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readClaudeSettings(settingsPath) + if err != nil { + return fmt.Errorf("claude-code.UninstallHooks: %w", err) + } + + for _, event := range claudeManagedEvents() { + var groups []claudeMatcherGroup + if err := parseClaudeHookType(rawHooks, event, &groups); err != nil { + return fmt.Errorf("claude-code.UninstallHooks: %w", err) + } + groups = removeClaudeManagedHooks(groups) + if err := marshalClaudeHookType(rawHooks, event, groups); err != nil { + return fmt.Errorf("claude-code.UninstallHooks: %w", err) + } + } + + if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("claude-code.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Claude Code hook is present in +// the workspace-local settings file. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("claude-code.AreHooksInstalled: workspacePath is required") + } + + settingsPath := claudeSettingsPath(workspacePath) + if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readClaudeSettings(settingsPath) + if err != nil { + return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err) + } + + for _, event := range claudeManagedEvents() { + var groups []claudeMatcherGroup + if err := parseClaudeHookType(rawHooks, event, &groups); err != nil { + return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err) + } + for _, group := range groups { + for _, hook := range group.Hooks { + if isClaudeManagedHook(hook.Command) { + return true, nil + } + } + } + } + return false, nil +} + +func claudeSettingsPath(workspacePath string) string { + return filepath.Join(workspacePath, claudeSettingsDirName, claudeSettingsFileName) +} + +// readClaudeSettings loads the settings file into a top-level raw map plus the +// decoded "hooks" sub-map, preserving every key AO doesn't manage. A +// missing or empty file yields empty maps. +func readClaudeSettings(settingsPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(settingsPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", settingsPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", settingsPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", settingsPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeClaudeSettings folds rawHooks back into topLevel and writes the file. An +// empty hooks map drops the "hooks" key entirely. +func writeClaudeSettings(settingsPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { + return fmt.Errorf("create settings dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", settingsPath, err) + } + data = append(data, '\n') + if err := atomicWriteFile(settingsPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", settingsPath, err) + } + return nil +} + +// atomicWriteFile writes data to path via a temp file in the same directory +// followed by a rename, so a crash or signal mid-write can't leave a truncated +// or empty file that Claude Code then fails to parse (silently disabling hooks). +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once renamed + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// groupClaudeHooksByEvent groups the managed hook specs by their Claude event so +// each event's settings array is rewritten once. +func groupClaudeHooksByEvent() map[string][]claudeHookSpec { + byEvent := map[string][]claudeHookSpec{} + for _, spec := range claudeManagedHooks { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +// claudeManagedEvents returns the distinct Claude events AO manages, in +// the order they first appear in claudeManagedHooks. +func claudeManagedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(claudeManagedHooks)) + for _, spec := range claudeManagedHooks { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +func isClaudeManagedHook(command string) bool { + return strings.HasPrefix(command, claudeHookCommandPrefix) +} + +// removeClaudeManagedHooks strips AO hook entries from every group, +// dropping any group left without hooks so the event array doesn't accumulate +// empty matcher objects. +func removeClaudeManagedHooks(groups []claudeMatcherGroup) []claudeMatcherGroup { + result := make([]claudeMatcherGroup, 0, len(groups)) + for _, group := range groups { + kept := make([]claudeHookEntry, 0, len(group.Hooks)) + for _, hook := range group.Hooks { + if !isClaudeManagedHook(hook.Command) { + kept = append(kept, hook) + } + } + if len(kept) > 0 { + group.Hooks = kept + result = append(result, group) + } + } + return result +} + +func parseClaudeHookType(rawHooks map[string]json.RawMessage, event string, target *[]claudeMatcherGroup) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalClaudeHookType(rawHooks map[string]json.RawMessage, event string, groups []claudeMatcherGroup) error { + if len(groups) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func claudeHookCommandExists(groups []claudeMatcherGroup, command string) bool { + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +// addClaudeHook appends hook to an existing group with the same matcher (so a +// SessionStart hook lands under its "startup" matcher), creating that group if +// none matches. +func addClaudeHook(groups []claudeMatcherGroup, hook claudeHookEntry, matcher *string) []claudeMatcherGroup { + for i, group := range groups { + if matchersEqual(group.Matcher, matcher) { + groups[i].Hooks = append(groups[i].Hooks, hook) + return groups + } + } + return append(groups, claudeMatcherGroup{Matcher: matcher, Hooks: []claudeHookEntry{hook}}) +} + +func matchersEqual(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} diff --git a/backend/internal/adapters/agent/codex/codex.go b/backend/internal/adapters/agent/codex/codex.go new file mode 100644 index 0000000000..874bef03f1 --- /dev/null +++ b/backend/internal/adapters/agent/codex/codex.go @@ -0,0 +1,258 @@ +// Package codex implements the Codex agent adapter: launching new sessions, +// resuming hook-tracked sessions, installing workspace-local hooks, and reading +// hook-derived session info. +// +// AO-managed sessions derive native session identity and display +// metadata from Codex hooks instead of transcript/cache scans. +package codex + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + codexTitleMetadataKey = "title" + codexSummaryMetadataKey = "summary" +) + +// Plugin is the Codex agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Codex adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: "codex", + Name: "Codex", + Description: "Run Codex worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Codex exposes none yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new Codex session, applying the +// no-update-check and approval flags, optional system-prompt instructions, and +// the initial prompt (passed after `--` so a leading "-" is not read as a flag). +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.codexBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + appendNoUpdateCheckFlag(&cmd) + appendApprovalFlags(&cmd, cfg.Permissions) + + if cfg.SystemPromptFile != "" { + cmd = append(cmd, "-c", "model_instructions_file="+cfg.SystemPromptFile) + } else if cfg.SystemPrompt != "" { + cmd = append(cmd, "-c", "developer_instructions="+cfg.SystemPrompt) + } + + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Codex receives its prompt in the +// launch command itself. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Codex +// session: `codex resume `. ok is false when the hook-derived +// native session id has not landed yet, so callers can fall back to fresh +// launch behavior. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.codexBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 8) + cmd = append(cmd, binary, "resume") + appendNoUpdateCheckFlag(&cmd) + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Codex hook-derived metadata. Metadata is intentionally +// nil for Codex: callers get the normalized fields directly. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[codexTitleMetadataKey], + Summary: session.Metadata[codexSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveCodexBinary returns the path to the codex binary on this machine, +// searching PATH then a handful of well-known install locations +// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback +// so callers see a clear "command not found" rather than an empty argv. +func ResolveCodexBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"codex.cmd", "codex.exe", "codex"} { + path, err := exec.LookPath(name) + if err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "codex.cmd"), + filepath.Join(appData, "npm", "codex.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "codex.exe")) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "codex", nil + } + + if path, err := exec.LookPath("codex"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/codex", + "/opt/homebrew/bin/codex", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".cargo", "bin", "codex"), + filepath.Join(home, ".npm", "bin", "codex"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "codex", nil +} + +func (p *Plugin) codexBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveCodexBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func appendNoUpdateCheckFlag(cmd *[]string) { + *cmd = append(*cmd, "-c", "check_for_update_on_startup=false") +} + +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's Codex config/default behavior. + case ports.PermissionModeAcceptEdits: + *cmd = append(*cmd, "--ask-for-approval", "on-request") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--ask-for-approval", "on-request", "-c", `approvals_reviewer="auto_review"`) + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--dangerously-bypass-approvals-and-sandbox") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/codex/codex_test.go b/backend/internal/adapters/agent/codex/codex_test.go new file mode 100644 index 0000000000..6547720a39 --- /dev/null +++ b/backend/internal/adapters/agent/codex/codex_test.go @@ -0,0 +1,476 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "codex"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + SystemPromptFile: filepath.Join("tmp", "prompt with spaces.md"), + SystemPrompt: "ignored", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "codex", + "-c", "check_for_update_on_startup=false", + "--dangerously-bypass-approvals-and-sandbox", + "-c", "model_instructions_file=" + filepath.Join("tmp", "prompt with spaces.md"), + "--", "-fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: "--ask-for-approval", + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: []string{"--ask-for-approval", "on-request"}, + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: []string{"--ask-for-approval", "on-request", "-c", `approvals_reviewer="auto_review"`}, + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"--dangerously-bypass-approvals-and-sandbox"}, + }, + { + name: "empty", + permission: "", + notExpected: "--ask-for-approval", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "codex"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + if tt.notExpected != "" && contains(cmd, tt.notExpected) { + t.Fatalf("command %#v contains %q", cmd, tt.notExpected) + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "codex"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "codex"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestGetAgentHooksInstallsCodexHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "codex"} + workspace := t.TempDir() + hooksDir := filepath.Join(workspace, ".codex") + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + t.Fatal(err) + } + hooksPath := filepath.Join(hooksDir, "hooks.json") + existing := `{"hooks":{"Stop":[{"matcher":null,"hooks":[{"type":"command","command":"custom stop hook","timeout":3}]}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + // A second install must not duplicate AO hook commands. + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var config codexHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + if config.Hooks == nil { + t.Fatalf("hooks config missing hooks object: %#v", config) + } + for _, spec := range codexManagedHooks { + entries := config.Hooks[spec.Event] + if count := countCodexHookCommand(entries, spec.Command); count != 1 { + t.Fatalf("%s command count = %d, want 1 in %#v", spec.Event, count, entries) + } + } + stopEntries := config.Hooks["Stop"] + if countCodexHookCommand(stopEntries, "custom stop hook") != 1 { + t.Fatalf("existing Stop hook was not preserved: %#v", stopEntries) + } + + configData, err := os.ReadFile(filepath.Join(workspace, ".codex", "config.toml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(configData), codexHooksFeatureLine) { + t.Fatalf("config.toml missing hooks feature flag: %s", configData) + } +} + +func TestUninstallHooksRemovesCodexHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "codex"} + workspace := t.TempDir() + hooksPath := filepath.Join(workspace, ".codex", "hooks.json") + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own Stop hook; it must survive uninstall. + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"hooks":{"Stop":[{"matcher":null,"hooks":[{"type":"command","command":"custom stop hook","timeout":3}]}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var config codexHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + for _, spec := range codexManagedHooks { + if got := countCodexHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 { + t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got) + } + } + if countCodexHookCommand(config.Hooks["Stop"], "custom stop hook") != 1 { + t.Fatalf("user Stop hook not preserved: %#v", config.Hooks["Stop"]) + } + + // The shared hooks feature flag in config.toml is left in place — it enables + // every Codex hook, not just AO's. + configData, err := os.ReadFile(filepath.Join(workspace, ".codex", "config.toml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(configData), codexHooksFeatureLine) { + t.Fatalf("config.toml hooks feature flag removed by uninstall: %s", configData) + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "codex"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "thread-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "codex", + "resume", + "-c", "check_for_update_on_startup=false", + "--ask-for-approval", "on-request", + "-c", `approvals_reviewer="auto_review"`, + "thread-123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "codex"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "codex"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "thread-123", + codexTitleMetadataKey: "Fix login redirect", + codexSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "thread-123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for Codex", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "codex"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func TestEnsureCodexHooksFeatureEnabledEdgeCases(t *testing.T) { + tests := []struct { + name string + seed *string // nil means do not create config.toml + wantHas []string + wantMiss []string + }{ + { + name: "missing config.toml is created with features block", + seed: nil, + wantHas: []string{"[features]", codexHooksFeatureLine}, + }, + { + name: "empty config.toml is populated with features block", + seed: strPtr(""), + wantHas: []string{"[features]", codexHooksFeatureLine}, + }, + { + name: "existing features block without hooks gains hooks=true", + seed: strPtr("[features]\nother = true\n"), + wantHas: []string{"[features]", codexHooksFeatureLine, "other = true"}, + }, + { + name: "hooks=true already present is a no-op", + seed: strPtr("[features]\nhooks = true\n"), + wantHas: []string{"[features]", codexHooksFeatureLine}, + wantMiss: []string{codexLegacyHookFeatureLine}, + }, + { + name: "legacy codex_hooks=true is replaced with hooks=true", + seed: strPtr("[features]\ncodex_hooks = true\n"), + wantHas: []string{"[features]", codexHooksFeatureLine}, + wantMiss: []string{codexLegacyHookFeatureLine}, + }, + { + name: "both hooks=true and legacy line keep only the new line", + seed: strPtr("[features]\nhooks = true\ncodex_hooks = true\n"), + wantHas: []string{"[features]", codexHooksFeatureLine}, + wantMiss: []string{codexLegacyHookFeatureLine}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workspace := t.TempDir() + configDir := filepath.Join(workspace, codexHooksDirName) + configPath := filepath.Join(configDir, codexConfigFileName) + if tt.seed != nil { + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(*tt.seed), 0o600); err != nil { + t.Fatal(err) + } + } + + // No-op check: snapshot the file content before and after for + // the cases the helper documents as no-ops. + var before []byte + if tt.seed != nil && strings.Contains(*tt.seed, codexHooksFeatureLine) && !strings.Contains(*tt.seed, codexLegacyHookFeatureLine) { + before = []byte(*tt.seed) + } + + if err := ensureCodexHooksFeatureEnabled(workspace); err != nil { + t.Fatalf("ensureCodexHooksFeatureEnabled: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read %s: %v", configPath, err) + } + got := string(data) + for _, want := range tt.wantHas { + if !strings.Contains(got, want) { + t.Fatalf("config.toml missing %q\n--- got ---\n%s", want, got) + } + } + for _, miss := range tt.wantMiss { + if strings.Contains(got, miss) { + t.Fatalf("config.toml unexpectedly contains %q\n--- got ---\n%s", miss, got) + } + } + if before != nil && string(data) != string(before) { + t.Fatalf("expected no-op, content changed\n--- before ---\n%s\n--- after ---\n%s", before, data) + } + }) + } +} + +func strPtr(s string) *string { return &s } + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func containsSubsequence(values []string, needle []string) bool { + if len(needle) == 0 { + return true + } + + for start := range values { + if start+len(needle) > len(values) { + return false + } + ok := true + for offset, want := range needle { + if values[start+offset] != want { + ok = false + break + } + } + if ok { + return true + } + } + + return false +} + +func countCodexHookCommand(entries []codexMatcherGroup, command string) int { + count := 0 + for _, entry := range entries { + for _, hook := range entry.Hooks { + if hook.Command == command { + count++ + } + } + } + return count +} diff --git a/backend/internal/adapters/agent/codex/hooks.go b/backend/internal/adapters/agent/codex/hooks.go new file mode 100644 index 0000000000..9d19609ac8 --- /dev/null +++ b/backend/internal/adapters/agent/codex/hooks.go @@ -0,0 +1,409 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + codexHooksDirName = ".codex" + codexHooksFileName = "hooks.json" + + codexConfigFileName = "config.toml" + codexHooksFeatureLine = "hooks = true" + codexLegacyHookFeatureLine = "codex_hooks = true" + + // codexHookCommandPrefix identifies the hook commands AO owns, so + // install skips duplicates and uninstall recognizes AO entries by + // prefix without an embedded template to diff against. + codexHookCommandPrefix = "ao hooks codex " + codexHookTimeout = 30 +) + +// codexHookFile is the on-disk shape of .codex/hooks.json. It is used by tests +// to decode the written file. +type codexHookFile struct { + Hooks map[string][]codexMatcherGroup `json:"hooks"` +} + +type codexMatcherGroup struct { + Matcher *string `json:"matcher"` + Hooks []codexHookEntry `json:"hooks"` +} + +type codexHookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// codexHookSpec describes one hook AO installs, defined in code rather +// than read from an embedded hooks file. +type codexHookSpec struct { + Event string + Command string +} + +// codexManagedHooks is the source of truth for the hooks AO installs. +// Codex groups every hook under the nil matcher. +var codexManagedHooks = []codexHookSpec{ + {Event: "SessionStart", Command: codexHookCommandPrefix + "session-start"}, + {Event: "UserPromptSubmit", Command: codexHookCommandPrefix + "user-prompt-submit"}, + {Event: "Stop", Command: codexHookCommandPrefix + "stop"}, +} + +// GetAgentHooks installs AO's Codex hooks into the worktree-local +// .codex/hooks.json file. Existing hook entries are preserved and duplicate +// AO commands are not appended. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("codex.GetAgentHooks: WorkspacePath is required") + } + + hooksPath := codexHooksPath(cfg.WorkspacePath) + topLevel, rawHooks, err := readCodexHooks(hooksPath) + if err != nil { + return fmt.Errorf("codex.GetAgentHooks: %w", err) + } + + for event, specs := range groupCodexHooksByEvent() { + var existingGroups []codexMatcherGroup + if err := parseCodexHookType(rawHooks, event, &existingGroups); err != nil { + return fmt.Errorf("codex.GetAgentHooks: %w", err) + } + for _, spec := range specs { + if !codexHookCommandExists(existingGroups, spec.Command) { + entry := codexHookEntry{Type: "command", Command: spec.Command, Timeout: codexHookTimeout} + existingGroups = addCodexHook(existingGroups, entry) + } + } + if err := marshalCodexHookType(rawHooks, event, existingGroups); err != nil { + return fmt.Errorf("codex.GetAgentHooks: %w", err) + } + } + + if err := writeCodexHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("codex.GetAgentHooks: %w", err) + } + + if err := ensureCodexHooksFeatureEnabled(cfg.WorkspacePath); err != nil { + return fmt.Errorf("codex.GetAgentHooks: enable hooks feature: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Codex hooks from the workspace-local +// .codex/hooks.json file, leaving user-defined hooks untouched. A missing file +// is a no-op. The .codex/config.toml `hooks = true` feature flag is left in +// place because it enables every Codex hook, not just AO's. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("codex.UninstallHooks: workspacePath is required") + } + + hooksPath := codexHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readCodexHooks(hooksPath) + if err != nil { + return fmt.Errorf("codex.UninstallHooks: %w", err) + } + + for _, event := range codexManagedEvents() { + var groups []codexMatcherGroup + if err := parseCodexHookType(rawHooks, event, &groups); err != nil { + return fmt.Errorf("codex.UninstallHooks: %w", err) + } + groups = removeCodexManagedHooks(groups) + if err := marshalCodexHookType(rawHooks, event, groups); err != nil { + return fmt.Errorf("codex.UninstallHooks: %w", err) + } + } + + if err := writeCodexHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("codex.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Codex hook is present in the +// workspace-local hooks file. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("codex.AreHooksInstalled: workspacePath is required") + } + + hooksPath := codexHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readCodexHooks(hooksPath) + if err != nil { + return false, fmt.Errorf("codex.AreHooksInstalled: %w", err) + } + + for _, event := range codexManagedEvents() { + var groups []codexMatcherGroup + if err := parseCodexHookType(rawHooks, event, &groups); err != nil { + return false, fmt.Errorf("codex.AreHooksInstalled: %w", err) + } + for _, group := range groups { + for _, hook := range group.Hooks { + if isCodexManagedHook(hook.Command) { + return true, nil + } + } + } + } + return false, nil +} + +func codexHooksPath(workspacePath string) string { + return filepath.Join(workspacePath, codexHooksDirName, codexHooksFileName) +} + +// readCodexHooks loads the hooks file into a top-level raw map plus the decoded +// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty +// file yields empty maps. +func readCodexHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeCodexHooks folds rawHooks back into topLevel and writes the file. An +// empty hooks map drops the "hooks" key entirely. +func writeCodexHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("create hook dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", hooksPath, err) + } + data = append(data, '\n') + if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", hooksPath, err) + } + return nil +} + +// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- +// write can't leave a truncated/empty file that Codex then fails to parse. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// groupCodexHooksByEvent groups the managed hook specs by their Codex event so +// each event's array is rewritten once. +func groupCodexHooksByEvent() map[string][]codexHookSpec { + byEvent := map[string][]codexHookSpec{} + for _, spec := range codexManagedHooks { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +// codexManagedEvents returns the distinct Codex events AO manages, in the +// order they first appear in codexManagedHooks. +func codexManagedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(codexManagedHooks)) + for _, spec := range codexManagedHooks { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +func isCodexManagedHook(command string) bool { + return strings.HasPrefix(command, codexHookCommandPrefix) +} + +// removeCodexManagedHooks strips AO hook entries from every group, +// dropping any group left without hooks. +func removeCodexManagedHooks(groups []codexMatcherGroup) []codexMatcherGroup { + result := make([]codexMatcherGroup, 0, len(groups)) + for _, group := range groups { + kept := make([]codexHookEntry, 0, len(group.Hooks)) + for _, hook := range group.Hooks { + if !isCodexManagedHook(hook.Command) { + kept = append(kept, hook) + } + } + if len(kept) > 0 { + group.Hooks = kept + result = append(result, group) + } + } + return result +} + +func parseCodexHookType(rawHooks map[string]json.RawMessage, event string, target *[]codexMatcherGroup) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalCodexHookType(rawHooks map[string]json.RawMessage, event string, groups []codexMatcherGroup) error { + if len(groups) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func codexHookCommandExists(groups []codexMatcherGroup, command string) bool { + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +func addCodexHook(groups []codexMatcherGroup, hook codexHookEntry) []codexMatcherGroup { + for i, group := range groups { + if group.Matcher == nil { + groups[i].Hooks = append(groups[i].Hooks, hook) + return groups + } + } + return append(groups, codexMatcherGroup{ + Matcher: nil, + Hooks: []codexHookEntry{hook}, + }) +} + +func ensureCodexHooksFeatureEnabled(workspacePath string) error { + configPath := filepath.Join(workspacePath, codexHooksDirName, codexConfigFileName) + data, err := os.ReadFile(configPath) //nolint:gosec // path built from caller-owned workspace dir + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read config.toml: %w", err) + } + + content := string(data) + hasNew := containsCodexFeatureLine(content, codexHooksFeatureLine) + hasLegacy := containsCodexFeatureLine(content, codexLegacyHookFeatureLine) + switch { + case hasNew && hasLegacy: + content = stripCodexLegacyHookFeatureLine(content) + case hasNew: + return nil + case hasLegacy: + content = strings.Replace(content, codexLegacyHookFeatureLine, codexHooksFeatureLine, 1) + case strings.Contains(content, "[features]"): + content = strings.Replace(content, "[features]", "[features]\n"+codexHooksFeatureLine, 1) + default: + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += "\n[features]\n" + codexHooksFeatureLine + "\n" + } + + if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { + return fmt.Errorf("create .codex directory: %w", err) + } + if err := atomicWriteFile(configPath, []byte(content), 0o600); err != nil { + return fmt.Errorf("write config.toml: %w", err) + } + return nil +} + +func containsCodexFeatureLine(content, line string) bool { + for raw := range strings.SplitSeq(content, "\n") { + if strings.TrimSpace(raw) == line { + return true + } + } + return false +} + +func stripCodexLegacyHookFeatureLine(content string) string { + idx := strings.Index(content, codexLegacyHookFeatureLine) + if idx < 0 { + return content + } + end := idx + len(codexLegacyHookFeatureLine) + if end < len(content) && content[end] == '\n' { + end++ + } + return content[:idx] + content[end:] +} diff --git a/backend/internal/adapters/registry.go b/backend/internal/adapters/registry.go new file mode 100644 index 0000000000..284e36f1f2 --- /dev/null +++ b/backend/internal/adapters/registry.go @@ -0,0 +1,83 @@ +package adapters + +import ( + "fmt" + "sort" +) + +// Capability identifies a feature an adapter provides, such as running an +// agent or backing an issue tracker. +type Capability string + +// Capabilities an adapter can advertise in its Manifest. +const ( + CapabilityAgent Capability = "agent" + CapabilityIssueTracker Capability = "issue-tracker" +) + +// Manifest is an adapter's self-description: its id, human-facing name and +// description, version, and the capabilities it provides. +type Manifest struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Capabilities []Capability `json:"capabilities"` +} + +// Adapter is the minimal contract every registered adapter satisfies. +type Adapter interface { + Manifest() Manifest +} + +// Registry holds registered adapters keyed by their manifest id. +// +// Registry is not safe for concurrent registration: every Register call is +// expected at daemon boot, before any goroutine calls Get. Concurrent +// Register and Get would race on the underlying map. +type Registry struct { + adapters map[string]Adapter +} + +// NewRegistry returns an empty Registry ready to Register adapters. +func NewRegistry() *Registry { + return &Registry{ + adapters: make(map[string]Adapter), + } +} + +// Register adds adapter under its manifest id. It returns an error when the id +// is empty or already registered. +func (r *Registry) Register(adapter Adapter) error { + manifest := adapter.Manifest() + if manifest.ID == "" { + return fmt.Errorf("adapter id is required") + } + if _, exists := r.adapters[manifest.ID]; exists { + return fmt.Errorf("adapter %q is already registered", manifest.ID) + } + + r.adapters[manifest.ID] = adapter + return nil +} + +// Get returns the registered adapter with the given id, or nil and false +// when no such adapter exists. +func (r *Registry) Get(id string) (Adapter, bool) { + p, ok := r.adapters[id] + return p, ok +} + +// Manifests returns every registered adapter's manifest, sorted by id. +func (r *Registry) Manifests() []Manifest { + manifests := make([]Manifest, 0, len(r.adapters)) + for _, adapter := range r.adapters { + manifests = append(manifests, adapter.Manifest()) + } + + sort.Slice(manifests, func(i, j int) bool { + return manifests[i].ID < manifests[j].ID + }) + + return manifests +} diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go index 1ecb9c31c2..20446ddece 100644 --- a/backend/internal/adapters/runtime/zellij/commands.go +++ b/backend/internal/adapters/runtime/zellij/commands.go @@ -128,7 +128,7 @@ func wrapLaunchCommandUnix(cfg ports.RuntimeConfig, shellPath string) string { b.WriteString(shellQuote(path)) b.WriteString("; ") } - b.WriteString(cfg.LaunchCommand) + b.WriteString(quoteArgvUnix(cfg.Argv)) b.WriteString("; exec ") b.WriteString(shellQuote(shellPath)) b.WriteString(" -i") @@ -157,7 +157,7 @@ func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig) string { b.WriteString(psQuote(path)) b.WriteString("; ") } - b.WriteString(cfg.LaunchCommand) + b.WriteString(quoteArgvPowerShell(cfg.Argv)) return b.String() } @@ -183,7 +183,7 @@ func wrapLaunchCommandCmd(cfg ports.RuntimeConfig) string { b.WriteString(cmdQuote(path)) b.WriteString("\" && ") } - b.WriteString(cfg.LaunchCommand) + b.WriteString(quoteArgvCmd(cfg.Argv)) return b.String() } @@ -233,6 +233,40 @@ func cmdQuote(s string) string { return strings.ReplaceAll(s, "\"", "\"\"") } +// quoteArgvUnix renders argv as a POSIX-shell command, single-quoting each +// argument so a value with spaces stays one word under `sh -lc`. +func quoteArgvUnix(argv []string) string { + parts := make([]string, len(argv)) + for i, a := range argv { + parts[i] = shellQuote(a) + } + return strings.Join(parts, " ") +} + +// quoteArgvPowerShell renders argv for `powershell -Command`. The call operator +// `&` is required so a quoted first token is invoked as a command rather than +// echoed as a string literal. +func quoteArgvPowerShell(argv []string) string { + if len(argv) == 0 { + return "" + } + parts := make([]string, len(argv)) + for i, a := range argv { + parts[i] = psQuote(a) + } + return "& " + strings.Join(parts, " ") +} + +// quoteArgvCmd renders argv for cmd.exe, wrapping each argument in double quotes +// (doubling any embedded quote) so spaces don't split a single argument. +func quoteArgvCmd(argv []string) string { + parts := make([]string, len(argv)) + for i, a := range argv { + parts[i] = "\"" + strings.ReplaceAll(a, "\"", "\"\"") + "\"" + } + return strings.Join(parts, " ") +} + func kdlQuote(s string) string { return strconv.Quote(s) } diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go index 71acb635cb..f30a21ecf1 100644 --- a/backend/internal/adapters/runtime/zellij/zellij.go +++ b/backend/internal/adapters/runtime/zellij/zellij.go @@ -112,7 +112,7 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru if cfg.WorkspacePath == "" { return ports.RuntimeHandle{}, errors.New("zellij runtime: workspace path is required") } - if cfg.LaunchCommand == "" { + if len(cfg.Argv) == 0 { return ports.RuntimeHandle{}, errors.New("zellij runtime: launch command is required") } if err := validateEnvKeys(cfg.Env); err != nil { diff --git a/backend/internal/adapters/runtime/zellij/zellij_integration_test.go b/backend/internal/adapters/runtime/zellij/zellij_integration_test.go index fcc57eaac0..fdbd261a69 100644 --- a/backend/internal/adapters/runtime/zellij/zellij_integration_test.go +++ b/backend/internal/adapters/runtime/zellij/zellij_integration_test.go @@ -30,7 +30,7 @@ func TestRuntimeIntegration(t *testing.T) { h, err := r.Create(ctx, ports.RuntimeConfig{ SessionID: "ao_itest_zj", WorkspacePath: t.TempDir(), - LaunchCommand: "printf ready-$AO_SESSION_ID\\n", + Argv: []string{"sh", "-c", "printf ready-$AO_SESSION_ID\\n"}, Env: map[string]string{"AO_SESSION_ID": id}, }) if err != nil { @@ -96,7 +96,7 @@ func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) { h, err := r.Create(ctx, ports.RuntimeConfig{ SessionID: "ao_zj_exact_long", WorkspacePath: t.TempDir(), - LaunchCommand: "printf ready\\n", + Argv: []string{"printf", "ready\\n"}, }) if err != nil { t.Fatalf("Create: %v", err) diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go index 3f0dc1437d..c5ef9b1c1e 100644 --- a/backend/internal/adapters/runtime/zellij/zellij_test.go +++ b/backend/internal/adapters/runtime/zellij/zellij_test.go @@ -102,7 +102,7 @@ func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) { } defer func() { getenv = oldGetenv }() - got := buildLayout(ports.RuntimeConfig{WorkspacePath: "/tmp/ws", LaunchCommand: "ao run", Env: map[string]string{ + got := buildLayout(ports.RuntimeConfig{WorkspacePath: "/tmp/ws", Argv: []string{"ao", "run"}, Env: map[string]string{ "AO_SESSION_ID": "sess-1", "ODD": "can't", "PATH": "/custom/bin:/usr/bin", @@ -114,7 +114,7 @@ func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) { "export AO_SESSION_ID='sess-1';", "export ODD='can'\\\\''t';", "export PATH='/custom/bin:/usr/bin';", - "ao run; exec '/bin/zsh' -i", + "'ao' 'run'; exec '/bin/zsh' -i", } { if !strings.Contains(got, want) { t.Fatalf("layout missing %q in %q", want, got) @@ -132,7 +132,7 @@ func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) { } defer func() { getenv = oldGetenv }() - got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, LaunchCommand: "Write-Host ready", Env: map[string]string{ + got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, Argv: []string{"Write-Host", "ready"}, Env: map[string]string{ "AO_SESSION_ID": "sess-1", }}, `C:\Program Files\PowerShell\7\pwsh.exe`) @@ -141,7 +141,7 @@ func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) { `args "-NoLogo" "-NoProfile" "-NoExit" "-Command"`, "$env:AO_SESSION_ID = 'sess-1';", "$env:PATH = ", - "Write-Host ready", + "& 'Write-Host' 'ready'", } { if !strings.Contains(got, want) { t.Fatalf("powershell layout missing %q in %q", want, got) @@ -156,7 +156,7 @@ func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) { } defer func() { getenv = oldGetenv }() - got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, LaunchCommand: "echo ready", Env: map[string]string{ + got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, Argv: []string{"echo", "ready"}, Env: map[string]string{ "AO_SESSION_ID": "sess-1", }}, `C:\Windows\System32\cmd.exe`) @@ -164,7 +164,7 @@ func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) { `pane command="C:\\Windows\\System32\\cmd.exe" name="agent"`, `args "/D" "/S" "/K"`, `AO_SESSION_ID=sess-1`, - "echo ready", + `\"echo\" \"ready\"`, } { if !strings.Contains(got, want) { t.Fatalf("cmd layout missing %q in %q", want, got) @@ -178,7 +178,7 @@ func TestCreateRejectsInvalidEnvKeys(t *testing.T) { _, err := r.Create(context.Background(), ports.RuntimeConfig{ SessionID: "sess-1", WorkspacePath: "/tmp/ws", - LaunchCommand: "echo ready", + Argv: []string{"echo", "ready"}, Env: map[string]string{"BAD KEY": "x"}, }) if err == nil || !strings.Contains(err.Error(), "invalid env key") { @@ -194,7 +194,7 @@ func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { handle, err := r.Create(context.Background(), ports.RuntimeConfig{ SessionID: "sess-1", WorkspacePath: "/tmp/ws", - LaunchCommand: "echo ready", + Argv: []string{"echo", "ready"}, Env: map[string]string{"AO_SESSION_ID": "sess-1"}, }) if err != nil { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 529e370714..beb7b789a2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -28,6 +28,9 @@ const ( // DefaultShutdownTimeout is the hard cap on graceful shutdown. After this // the process exits even if connections are still draining. DefaultShutdownTimeout = 10 * time.Second + // DefaultAgent is the agent adapter id the daemon wires when AO_AGENT is + // unset. It matches the claude-code adapter's manifest id. + DefaultAgent = "claude-code" ) // Config is the fully-resolved daemon configuration. It is immutable once @@ -47,6 +50,10 @@ type Config struct { // DataDir is the directory holding durable SQLite state: DB and WAL files. // It is created on first use by the storage layer. DataDir string + // Agent is the id of the agent adapter the daemon wires into the Session + // Manager (see DefaultAgent). Selected by AO_AGENT; startSession fails fast + // if no adapter with this id is registered. + Agent string } // Addr returns the host:port the HTTP server binds. It uses net.JoinHostPort so @@ -66,6 +73,7 @@ func (c Config) Addr() string { // AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s) // AO_RUN_FILE running.json path (default /running.json) // AO_DATA_DIR durable state dir (default /data) +// AO_AGENT agent adapter id (default claude-code) // // The bind host is not configurable: the daemon is loopback-only by design. func Load() (Config, error) { @@ -74,6 +82,7 @@ func Load() (Config, error) { Port: DefaultPort, RequestTimeout: DefaultRequestTimeout, ShutdownTimeout: DefaultShutdownTimeout, + Agent: DefaultAgent, } if raw := os.Getenv("AO_PORT"); raw != "" { @@ -103,6 +112,10 @@ func Load() (Config, error) { cfg.ShutdownTimeout = d } + if raw := os.Getenv("AO_AGENT"); raw != "" { + cfg.Agent = raw + } + runFile, err := resolveRunFilePath() if err != nil { return Config{}, err diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 59926922e8..3b75e4fb0d 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -66,19 +66,36 @@ func Run() error { termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) defer termMgr.Close() - srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: projectsvc.New(store)}) + // Bring up the Lifecycle Manager and the reaper first: it makes the session + // lifecycle write path live (reducer write -> store -> DB trigger -> + // change_log -> poller -> broadcaster) and gives startSession the shared LCM. + lcStack := startLifecycle(ctx, store, runtimeAdapter, log) + + // Wire the controller-facing session service over the same store + LCM, the + // zellij runtime, a gitworktree workspace, and the per-session agent resolver + // (AO_AGENT default, validated here), then mount it on the API. + sessionSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, log) if err != nil { stop() + lcStack.Stop() if cdcErr := cdcPipe.Stop(); cdcErr != nil { log.Error("cdc pipeline shutdown", "err", cdcErr) } - return err + return fmt.Errorf("wire session service: %w", err) } - // Bring up the Lifecycle Manager and the reaper. This makes the session - // lifecycle write path live end-to-end: reducer write -> store -> DB trigger - // -> change_log -> poller -> broadcaster. - lcStack := startLifecycle(ctx, store, runtimeAdapter, log) + srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ + Projects: projectsvc.New(store), + Sessions: sessionSvc, + }) + if err != nil { + stop() + lcStack.Stop() + if cdcErr := cdcPipe.Stop(); cdcErr != nil { + log.Error("cdc pipeline shutdown", "err", cdcErr) + } + return err + } runErr := srv.Run(ctx) diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 5c04002da3..34685670fb 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -2,17 +2,31 @@ package daemon import ( "context" + "fmt" "log/slog" + "path/filepath" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) // lifecycleStack owns the runtime reaper goroutine started with the lifecycle // reducer. The reducer itself is only used for wiring observations into storage. type lifecycleStack struct { + // LCM is the Lifecycle Manager (the canonical write path). It is exposed so + // startSession can share the same reducer the reaper drives, rather than + // standing up a second store+LCM pair that would diverge under writes. + LCM *lifecycle.Manager reaperDone <-chan struct{} } @@ -21,9 +35,113 @@ type lifecycleStack struct { func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runtime, logger *slog.Logger) *lifecycleStack { lcm := lifecycle.New(store, nil) rp := reaper.New(lcm, store, runtime, reaper.Config{Logger: logger}) - return &lifecycleStack{reaperDone: rp.Start(ctx)} + return &lifecycleStack{LCM: lcm, reaperDone: rp.Start(ctx)} } // Stop waits for the reaper goroutine to exit. The caller must cancel the ctx // passed to startLifecycle before calling Stop. func (l *lifecycleStack) Stop() { <-l.reaperDone } + +// noopMessenger is a stub ports.AgentMessenger: durable writes and notifications +// work without it; only live agent nudges are absent until the runtime/agent +// nudge path is wired. +type noopMessenger struct{} + +func (noopMessenger) Send(context.Context, domain.SessionID, string) error { return nil } + +// startSession builds the controller-facing session service: a session manager +// over the real zellij runtime, a per-session gitworktree workspace, the shared +// store + LCM, and the per-session agent resolver (AO_AGENT default). The +// Messenger is a stub until the live agent-nudge path lands. The returned +// service is mounted at httpd APIDeps.Sessions. +func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, log *slog.Logger) (*sessionsvc.Service, error) { + agents, err := buildAgentResolver(cfg.Agent, log) + if err != nil { + return nil, err + } + ws, err := gitworktree.New(gitworktree.Options{ + // Per-session worktrees live under the data dir, so a single AO_DATA_DIR + // override moves all durable per-user state together. + ManagedRoot: filepath.Join(cfg.DataDir, "worktrees"), + // An empty resolver fails every project lookup with a clear + // "no repo configured for project" error until the projects table feeds + // repo paths in — better than silently misrouting spawns. + RepoResolver: gitworktree.StaticRepoResolver{}, + }) + if err != nil { + return nil, fmt.Errorf("session workspace: %w", err) + } + mgr := sessionmanager.New(sessionmanager.Deps{ + Runtime: runtime, + Agents: agents, + Workspace: ws, + Store: store, + Messenger: noopMessenger{}, + Lifecycle: lcm, + DataDir: cfg.DataDir, + }) + return sessionsvc.New(mgr, store), nil +} + +// buildAgentRegistry returns a registry populated with the agent adapters the +// daemon ships, keyed by manifest id. Registration only fails on an +// empty/duplicate id — a programmer error, not a runtime condition. +func buildAgentRegistry() (*adapters.Registry, error) { + reg := adapters.NewRegistry() + for _, a := range []adapters.Adapter{claudecode.New(), codex.New()} { + if err := reg.Register(a); err != nil { + return nil, fmt.Errorf("register agent adapter %q: %w", a.Manifest().ID, err) + } + } + return reg, nil +} + +// agentRegistry adapts the generic adapter Registry to ports.AgentResolver: it +// maps a session's harness onto the registered adapter of the same id and +// asserts that adapter drives an agent. An empty harness falls back to the +// daemon's configured default (AO_AGENT), so a spawn that names no harness still +// gets a real agent. +type agentRegistry struct { + reg *adapters.Registry + defaultHarness domain.AgentHarness +} + +var _ ports.AgentResolver = agentRegistry{} + +func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) { + if harness == "" { + harness = a.defaultHarness + } + adapter, ok := a.reg.Get(string(harness)) + if !ok { + return nil, false + } + agent, ok := adapter.(ports.Agent) + return agent, ok +} + +// buildAgentResolver constructs the per-session agent resolver the Session +// Manager consumes (sessionmanager.Deps.Agents): a registry of the shipped +// adapters plus the configured default harness. It fails fast if the default +// does not resolve, so a typo'd AO_AGENT surfaces at startup. The session lane +// plugs this in when it mounts the controller-facing session service at the +// httpd APIDeps.Sessions slot. +func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResolver, error) { + if defaultAgent == "" { + defaultAgent = config.DefaultAgent + } + reg, err := buildAgentRegistry() + if err != nil { + return nil, err + } + resolver := agentRegistry{reg: reg, defaultHarness: domain.AgentHarness(defaultAgent)} + if _, ok := resolver.Agent(""); !ok { + return nil, fmt.Errorf("configured default agent %q is not a registered adapter", defaultAgent) + } + ids := make([]string, 0) + for _, mf := range reg.Manifests() { + ids = append(ids, mf.ID) + } + log.Info("built per-session agent resolver", "default", defaultAgent, "registered", ids) + return resolver, nil +} diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index d8c7a610c6..33ba2839f7 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -2,11 +2,16 @@ package daemon import ( "context" + "io" + "log/slog" "sync" "testing" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -68,3 +73,62 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { t.Fatalf("expected a change_log event for %s to reach the broadcaster, got %d events", rec.ID, len(got)) } } + +// TestWiring_AgentResolverResolvesRealAdapters asserts buildAgentResolver wires a +// real registry-backed per-session resolver: each harness resolves to the +// matching registered adapter, an empty harness falls back to the AO_AGENT +// default, and an unknown harness misses. +func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + resolver, err := buildAgentResolver("", log) // empty default → claude-code + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + harness domain.AgentHarness + wantID string + }{ + {domain.HarnessClaudeCode, "claude-code"}, + {domain.HarnessCodex, "codex"}, + {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default + } { + agent, ok := resolver.Agent(tc.harness) + if !ok { + t.Fatalf("resolver has no agent for harness %q", tc.harness) + } + described, ok := agent.(adapters.Adapter) + if !ok { + t.Fatalf("agent for harness %q is %T, not a registered adapters.Adapter", tc.harness, agent) + } + if got := described.Manifest().ID; got != tc.wantID { + t.Fatalf("harness %q resolved to adapter %q, want %q", tc.harness, got, tc.wantID) + } + } + if _, ok := resolver.Agent("definitely-not-an-agent"); ok { + t.Fatal("unknown harness resolved to an agent; want a miss") + } +} + +// TestWiring_StartSessionBuildsSessionService asserts the daemon's startSession +// constructs a real controller-facing session service end to end (resolver + +// gitworktree workspace + session manager over the shared store/LCM), which is +// what gets mounted at httpd APIDeps.Sessions. +func TestWiring_StartSessionBuildsSessionService(t *testing.T) { + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + lcm := lifecycle.New(store, nil) + cfg := config.Config{DataDir: t.TempDir()} + + svc, err := startSession(cfg, zellij.New(zellij.Options{}), store, lcm, log) + if err != nil { + t.Fatalf("startSession: %v", err) + } + if svc == nil { + t.Fatal("startSession returned nil session service") + } +} diff --git a/backend/internal/integration/lifecycle_sqlite_test.go b/backend/internal/integration/lifecycle_sqlite_test.go index 4a103f453d..2c835c9d23 100644 --- a/backend/internal/integration/lifecycle_sqlite_test.go +++ b/backend/internal/integration/lifecycle_sqlite_test.go @@ -26,11 +26,30 @@ func (s *stubRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error type stubAgent struct{} -func (stubAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" } -func (stubAgent) GetEnvironment(ports.AgentConfig) map[string]string { - return map[string]string{"X": "1"} +func (stubAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, nil } -func (stubAgent) GetRestoreCommand(id string) string { return "resume " + id } +func (stubAgent) GetLaunchCommand(context.Context, ports.LaunchConfig) ([]string, error) { + return []string{"launch"}, nil +} +func (stubAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + return ports.PromptDeliveryInCommand, nil +} +func (stubAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { return nil } +func (stubAgent) GetRestoreCommand(_ context.Context, cfg ports.RestoreConfig) ([]string, bool, error) { + if id := cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]; id != "" { + return []string{"resume", id}, true, nil + } + return nil, false, nil +} +func (stubAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) { + return ports.SessionInfo{}, false, nil +} + +// stubAgents resolves every harness to the same stubAgent. +type stubAgents struct{} + +func (stubAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return stubAgent{}, true } type stubWorkspace struct{ destroyed int } @@ -78,7 +97,7 @@ func newStack(t *testing.T) *stack { prm := prsvc.New(prsvc.Deps{Writer: store, Lifecycle: lcm}) rt := &stubRuntime{} ws := &stubWorkspace{} - mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm}) + mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agents: stubAgents{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm}) sm := sessionsvc.New(mgr, store) return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg} } diff --git a/backend/internal/ports/agent.go b/backend/internal/ports/agent.go new file mode 100644 index 0000000000..6cee911bed --- /dev/null +++ b/backend/internal/ports/agent.go @@ -0,0 +1,146 @@ +package ports + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// Agent is the contract every CLI coding agent adapter (claude-code, codex, …) +// must satisfy. It supplies the argv and process configuration the Session +// Manager needs to launch, restore, and read back a native agent session. +type Agent interface { + // GetConfigSpec describes the agent-specific config keys AO can + // expose to users in the AO config. + GetConfigSpec(ctx context.Context) (ConfigSpec, error) + + // GetLaunchCommand builds the argv AO should run to start this agent. + GetLaunchCommand(ctx context.Context, cfg LaunchConfig) (cmd []string, err error) + + // GetPromptDeliveryStrategy tells AO whether the prompt is included in + // the launch command or must be sent after the agent process starts. + GetPromptDeliveryStrategy(ctx context.Context, cfg LaunchConfig) (PromptDeliveryStrategy, error) + + // GetAgentHooks installs or merges AO hooks into the agent's + // native workspace-local hook config. It must preserve user-defined hooks. + GetAgentHooks(ctx context.Context, cfg WorkspaceHookConfig) error + + // GetRestoreCommand builds an argv that continues an existing native agent + // session. ok=false means no existing native session can be continued. + GetRestoreCommand(ctx context.Context, cfg RestoreConfig) (cmd []string, ok bool, err error) + + // SessionInfo reads agent-owned session metadata such as native session id, + // display title, or summary. ok=false means no info is available. + SessionInfo(ctx context.Context, session SessionRef) (info SessionInfo, ok bool, err error) +} + +// AgentResolver maps a session's harness onto the Agent adapter that drives it, +// so the Session Manager can spawn (and restore) a different agent per session +// without depending on the concrete adapter registry. ok=false means no adapter +// is registered for that harness. +type AgentResolver interface { + Agent(harness domain.AgentHarness) (Agent, bool) +} + +// MetadataKeyAgentSessionID is the SessionRef.Metadata key that carries an +// agent's native session id. It matches the json tag on +// domain.SessionMetadata.AgentSessionID and the key the adapters read, so the +// Session Manager can bridge its typed metadata onto a SessionRef without +// either side hard-coding the other's vocabulary. +const MetadataKeyAgentSessionID = "agentSessionId" + +// AgentConfig holds values loaded from the selected agent's config section. +// Agent adapters own validation for their custom keys. +type AgentConfig map[string]any + +// ConfigSpec describes the agent-specific config keys AO can expose to users. +type ConfigSpec struct { + Fields []ConfigField +} + +// ConfigField describes one user-facing agent config key. +type ConfigField struct { + Key string + Type ConfigFieldType + Description string + Required bool + Default any + Enum []string +} + +// ConfigFieldType is the primitive value kind AO expects for a field. +type ConfigFieldType string + +// The primitive value kinds a ConfigField can declare. +const ( + ConfigFieldString ConfigFieldType = "string" + ConfigFieldBool ConfigFieldType = "bool" + ConfigFieldNumber ConfigFieldType = "number" + ConfigFieldStringList ConfigFieldType = "string_list" + ConfigFieldEnum ConfigFieldType = "enum" +) + +// LaunchConfig carries inputs needed to build a new agent launch command. +type LaunchConfig struct { + Config AgentConfig + IssueID string + Permissions PermissionMode + Prompt string + SessionID string + SystemPrompt string + SystemPromptFile string + WorkspacePath string +} + +// WorkspaceHookConfig carries inputs needed to install workspace-local agent hooks. +type WorkspaceHookConfig struct { + Config AgentConfig + DataDir string + SessionID string + WorkspacePath string +} + +// RestoreConfig carries inputs needed to continue an existing native agent session. +type RestoreConfig struct { + Config AgentConfig + Permissions PermissionMode + Session SessionRef +} + +// SessionRef identifies an AO session whose agent-owned metadata may be read. +type SessionRef struct { + ID string + Metadata map[string]string + WorkspacePath string +} + +// SessionInfo contains agent-owned session metadata. +type SessionInfo struct { + AgentSessionID string + Metadata map[string]string + Title string + Summary string +} + +// PermissionMode controls how much review an agent requires before acting. +type PermissionMode string + +// The permission modes adapters map onto their agent's native approval flags. +const ( + // PermissionModeDefault is special: adapters emit no flag for it so the + // agent resolves its starting mode from the user's own config (e.g. + // Claude's TUI reading ~/.claude/settings.json defaultMode). + PermissionModeDefault PermissionMode = "default" + PermissionModeAcceptEdits PermissionMode = "accept-edits" + PermissionModeAuto PermissionMode = "auto" + PermissionModeBypassPermissions PermissionMode = "bypass-permissions" +) + +// PromptDeliveryStrategy describes how AO should deliver the initial prompt. +type PromptDeliveryStrategy string + +// How the orchestrator hands the initial prompt to a freshly launched agent. +const ( + PromptDeliveryInCommand PromptDeliveryStrategy = "in_command" + PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start" +) diff --git a/backend/internal/ports/agent_test.go b/backend/internal/ports/agent_test.go new file mode 100644 index 0000000000..d6f8386445 --- /dev/null +++ b/backend/internal/ports/agent_test.go @@ -0,0 +1,24 @@ +package ports_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// TestMetadataKeyAgentSessionIDMatchesDomainJSONTag pins the hand-maintained +// invariant documented on ports.MetadataKeyAgentSessionID: a silent rename on +// either side would break session restore. +func TestMetadataKeyAgentSessionIDMatchesDomainJSONTag(t *testing.T) { + field, ok := reflect.TypeOf(domain.SessionMetadata{}).FieldByName("AgentSessionID") + if !ok { + t.Fatalf("domain.SessionMetadata has no AgentSessionID field") + } + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if name != ports.MetadataKeyAgentSessionID { + t.Fatalf("json tag %q != ports.MetadataKeyAgentSessionID %q", name, ports.MetadataKeyAgentSessionID) + } +} diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 765785c466..2614f5bbfb 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -31,10 +31,13 @@ type Runtime interface { } // RuntimeConfig is the spec for launching a session's process in a Runtime. +// Argv is the agent's launch command as discrete arguments; each Runtime +// shell-quotes it for its own shell, so the command survives args with spaces +// (e.g. a prompt) without the caller guessing the target shell's quoting. type RuntimeConfig struct { SessionID domain.SessionID WorkspacePath string - LaunchCommand string + Argv []string Env map[string]string } @@ -44,20 +47,7 @@ type RuntimeHandle struct { ID string } -// Agent is the AI coding tool driving a session (claude-code, codex, …): it -// supplies the launch/restore commands and the process environment. -type Agent interface { - GetLaunchCommand(cfg AgentConfig) string - GetEnvironment(cfg AgentConfig) map[string]string - GetRestoreCommand(agentSessionID string) string -} - -// AgentConfig is the per-session input to an Agent's command and environment. -type AgentConfig struct { - SessionID domain.SessionID - WorkspacePath string - Prompt string -} +// The Agent port and its supporting types live in agent.go. // Workspace is the isolated checkout an agent works in (a git worktree or clone). type Workspace interface { diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 18c21c5c45..d1cc1007a0 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -24,6 +24,8 @@ const ( EnvSessionID = "AO_SESSION_ID" EnvProjectID = "AO_PROJECT_ID" EnvIssueID = "AO_ISSUE_ID" + // EnvDataDir tells a spawned agent's AO hook commands where the store lives. + EnvDataDir = "AO_DATA_DIR" ) type lifecycleRecorder interface { @@ -47,23 +49,27 @@ type Store interface { // the outbound ports. User-facing read-model assembly lives in the service package. type Manager struct { runtime runtimeController - agent ports.Agent + agents ports.AgentResolver workspace ports.Workspace store Store messenger ports.AgentMessenger lcm lifecycleRecorder + dataDir string clock func() time.Time } // Deps are the collaborators a Session Manager needs; New wires them together. type Deps struct { Runtime runtimeController - Agent ports.Agent + Agents ports.AgentResolver Workspace ports.Workspace Store Store Messenger ports.AgentMessenger Lifecycle lifecycleRecorder - Clock func() time.Time + // DataDir is exported to spawned agents as AO_DATA_DIR so their hook + // commands can open the same store. + DataDir string + Clock func() time.Time } // New builds a Session Manager from its dependencies, defaulting the clock to @@ -71,11 +77,12 @@ type Deps struct { func New(d Deps) *Manager { m := &Manager{ runtime: d.Runtime, - agent: d.Agent, + agents: d.Agents, workspace: d.Workspace, store: d.Store, messenger: d.Messenger, lcm: d.Lifecycle, + dataDir: d.DataDir, clock: d.Clock, } if m.clock == nil { @@ -100,12 +107,34 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err) } - agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: buildPrompt(cfg)} + prompt := buildPrompt(cfg) + agent, ok := m.agents.Agent(cfg.Harness) + if !ok { + _ = m.workspace.Destroy(ctx, ws) + m.markSpawnFailedTerminated(ctx, id) + return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness) + } + if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil { + _ = m.workspace.Destroy(ctx, ws) + m.markSpawnFailedTerminated(ctx, id) + return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) + } + argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ + SessionID: string(id), + WorkspacePath: ws.Path, + Prompt: prompt, + IssueID: string(cfg.IssueID), + }) + if err != nil { + _ = m.workspace.Destroy(ctx, ws) + m.markSpawnFailedTerminated(ctx, id) + return domain.SessionRecord{}, fmt.Errorf("spawn %s: launch command: %w", id, err) + } handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ SessionID: id, WorkspacePath: ws.Path, - LaunchCommand: m.agent.GetLaunchCommand(agentCfg), - Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, cfg.ProjectID, cfg.IssueID), + Argv: argv, + Env: spawnEnv(id, cfg.ProjectID, cfg.IssueID, m.dataDir), }) if err != nil { _ = m.workspace.Destroy(ctx, ws) @@ -113,7 +142,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err) } - metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, Prompt: agentCfg.Prompt} + metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, Prompt: prompt} if err := m.lcm.MarkSpawned(ctx, id, metadata); err != nil { _ = m.runtime.Destroy(ctx, handle) _ = m.workspace.Destroy(ctx, ws) @@ -180,16 +209,22 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess if err != nil { return domain.SessionRecord{}, fmt.Errorf("restore %s: workspace: %w", id, err) } - agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: meta.Prompt} - launch := m.agent.GetRestoreCommand(meta.AgentSessionID) - if meta.AgentSessionID == "" { - launch = m.agent.GetLaunchCommand(agentCfg) + agent, ok := m.agents.Agent(rec.Harness) + if !ok { + return domain.SessionRecord{}, fmt.Errorf("restore %s: no agent adapter for harness %q", id, rec.Harness) + } + if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil { + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) + } + argv, err := restoreArgv(ctx, agent, id, ws.Path, meta) + if err != nil { + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) } handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ SessionID: id, WorkspacePath: ws.Path, - LaunchCommand: launch, - Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, rec.ProjectID, rec.IssueID), + Argv: argv, + Env: spawnEnv(id, rec.ProjectID, rec.IssueID, m.dataDir), }) if err != nil { return domain.SessionRecord{}, fmt.Errorf("restore %s: runtime: %w", id, err) @@ -275,15 +310,68 @@ func buildPrompt(cfg ports.SpawnConfig) string { } } -func spawnEnv(base map[string]string, id domain.SessionID, project domain.ProjectID, issue domain.IssueID) map[string]string { - env := make(map[string]string, len(base)+3) - for k, v := range base { - env[k] = v - } - env[EnvSessionID] = string(id) - env[EnvProjectID] = string(project) - env[EnvIssueID] = string(issue) - return env +func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir string) map[string]string { + return map[string]string{ + EnvSessionID: string(id), + EnvProjectID: string(project), + EnvIssueID: string(issue), + EnvDataDir: dataDir, + } +} + +// preLauncher is an optional Agent capability: a step the manager runs before +// launch. Claude Code implements it to record workspace trust in ~/.claude.json +// so its interactive "do you trust this folder?" dialog can't block the headless +// pane. Adapters that don't need it simply omit the method. +type preLauncher interface { + PreLaunch(ctx context.Context, cfg ports.LaunchConfig) error +} + +// prepareWorkspace runs the per-session pre-launch steps before the runtime +// starts the agent: installing the workspace-local activity hooks (so early +// startup hooks can update the already-created session row), then any optional +// PreLaunch step. Shared by Spawn and Restore. +func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string) error { + if err := agent.GetAgentHooks(ctx, ports.WorkspaceHookConfig{ + SessionID: string(id), + WorkspacePath: workspacePath, + DataDir: m.dataDir, + }); err != nil { + return fmt.Errorf("install hooks: %w", err) + } + if pl, ok := agent.(preLauncher); ok { + if err := pl.PreLaunch(ctx, ports.LaunchConfig{SessionID: string(id), WorkspacePath: workspacePath}); err != nil { + return fmt.Errorf("pre-launch: %w", err) + } + } + return nil +} + +// restoreArgv builds the argv to relaunch a torn-down session: the agent's +// native resume command when it can continue the session, else a fresh launch. +// The agent signals via ok=false (e.g. no native session id captured yet). +func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string, meta domain.SessionMetadata) ([]string, error) { + ref := ports.SessionRef{ + ID: string(id), + WorkspacePath: workspacePath, + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: meta.AgentSessionID}, + } + cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref}) + if err != nil { + return nil, fmt.Errorf("restore command: %w", err) + } + if ok { + return cmd, nil + } + argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ + SessionID: string(id), + WorkspacePath: workspacePath, + Prompt: meta.Prompt, + }) + if err != nil { + return nil, fmt.Errorf("launch command: %w", err) + } + return argv, nil } func runtimeHandle(meta domain.SessionMetadata) ports.RuntimeHandle { diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 22ef787551..74bf7bbcfd 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -97,11 +97,30 @@ func (r *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { r.de type fakeAgent struct{} -func (fakeAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" } -func (fakeAgent) GetEnvironment(ports.AgentConfig) map[string]string { - return map[string]string{"X": "1"} +func (fakeAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, nil } -func (fakeAgent) GetRestoreCommand(id string) string { return "resume " + id } +func (fakeAgent) GetLaunchCommand(context.Context, ports.LaunchConfig) ([]string, error) { + return []string{"launch"}, nil +} +func (fakeAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + return ports.PromptDeliveryInCommand, nil +} +func (fakeAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { return nil } +func (fakeAgent) GetRestoreCommand(_ context.Context, cfg ports.RestoreConfig) ([]string, bool, error) { + if id := cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]; id != "" { + return []string{"resume", id}, true, nil + } + return nil, false, nil +} +func (fakeAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) { + return ports.SessionInfo{}, false, nil +} + +// fakeAgents resolves every harness to the same fakeAgent. +type fakeAgents struct{} + +func (fakeAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return fakeAgent{}, true } type fakeWorkspace struct { destroyErr error @@ -130,7 +149,7 @@ func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) { st := newFakeStore() rt := &fakeRuntime{} ws := &fakeWorkspace{} - m := New(Deps{Runtime: rt, Agent: fakeAgent{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}}) + m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}}) return m, st, rt, ws } func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) { diff --git a/backend/internal/terminal/session_integration_test.go b/backend/internal/terminal/session_integration_test.go index 1c9fceafdb..78b611e943 100644 --- a/backend/internal/terminal/session_integration_test.go +++ b/backend/internal/terminal/session_integration_test.go @@ -34,7 +34,7 @@ func TestSessionStreamsRealZellijPane(t *testing.T) { handle, err := rt.Create(context.Background(), ports.RuntimeConfig{ SessionID: domain.SessionID(name), WorkspacePath: t.TempDir(), - LaunchCommand: "printf AO_READY\n", + Argv: []string{"printf", "AO_READY\\n"}, }) if err != nil { t.Fatalf("Create: %v", err) diff --git a/docs/agent/README.md b/docs/agent/README.md new file mode 100644 index 0000000000..e17aa79272 --- /dev/null +++ b/docs/agent/README.md @@ -0,0 +1,117 @@ +# Agent Adapter PRD + +## Goal + +Agent adapters let AO run and observe different CLI coding agents without hardcoding agent-specific behavior into the spawn engine. Every CLI coding agent must implement the contract in `backend/internal/ports/agent.go`. + +The important current slice is hook-derived session info. AO should know a running worker's native agent session id, title, and summary from agent hooks installed in the per-session worktree, not from scanning agent transcript/cache files. + +## Current Decisions + +- AO only needs to derive session info for AO-managed sessions. +- Hook installation happens at worktree/session creation time. +- `SessionInfo` reads normalized metadata persisted in AO's session store. +- `SessionInfo` must not infer display info by reading agent transcript/cache files. +- `SummaryIsFallback` is removed from `ports.SessionInfo`. +- `TranscriptPath` is removed from `ports.SessionInfo`. +- `Title` and `Summary` are both first-class fields. +- `Title` is derived from the user prompt hook. +- `Summary` is derived from the stop/final assistant hook. +- Agent adapter `Metadata` should stay nil/empty unless an adapter has a real extra field that does not belong in the normalized contract. + +## Agent Contract + +The shared contract lives in `backend/internal/ports/agent.go`. + +Required adapter behavior: + +- `GetConfigSpec` describes user-facing agent config. +- `GetLaunchCommand` builds the native agent command. +- `GetPromptDeliveryStrategy` says whether the prompt is passed in argv or sent after launch. +- `GetAgentHooks` installs or merges AO hooks into the agent's workspace-local hook config. +- `GetRestoreCommand` builds a native resume command when restore is supported. +- `SessionInfo` returns normalized metadata: + - `AgentSessionID` + - `Title` + - `Summary` + - optional adapter-specific `Metadata` + +Implementation layout: + +- Agent-specific hook installation should live beside the agent adapter in `backend/internal/adapters/agent//hooks.go`; the hook commands are defined in code, not embedded template files. +- Launch, restore, and session-info behavior can stay in the main agent implementation unless the file grows enough to justify another split. + +## Metadata Keys + +Hook callbacks persist these normalized keys in the session metadata JSON blob: + +- `agentSessionId`: native agent session id. +- `title`: display title, derived from the first user prompt hook for the session. +- `summary`: display summary, derived from the final assistant message exposed to the stop hook. + +The original spawn prompt may remain in metadata as `prompt` for launch/debug fallback, but `title` is the preferred display title once hook metadata lands. + +## Hook Methodology + +Agent adapters install hooks into the worktree-local config owned by the native agent. + +Hook callbacks run through hidden AO CLI commands: + +```text +ao hooks +``` + +The callback: + +1. Reads the native hook JSON payload from stdin. +2. Reads the AO session id from `AO_SESSION_ID`. +3. Opens the AO SQLite store (`ao.db`) in the data dir — `AO_DATA_DIR`, default `/agent-orchestrator/data`. +4. Merges normalized metadata into the matching session row. +5. Publishes `session.updated` when metadata changed. +6. Prints `{}` and exits 0 for successful no-op cases, including non-AO sessions or missing rows. + +The spawn engine inserts the AO session row before launching the durability provider so early startup hooks can update an existing row. If launch fails after insertion, spawn deletes the row during rollback. + +## Restore Boundary + +Session display info and native restore are separate concerns. + +Some agents may still need transcript-derived or deterministic native ids for `GetRestoreCommand` until restore is redesigned for that agent. Do not remove restore support just because `SessionInfo` stops reading transcripts. + +For `SessionInfo`, transcript/cache files are not an acceptable source of title or summary. + +## UI And Events + +The workspace adapter prefers: + +- `metadata.title` as session title. +- `metadata.summary` as session description. +- `metadata.prompt` only as fallback. + +Hook metadata changes publish `session.updated`. The frontend listens to `session.created`, `session.terminated`, and `session.updated` and invalidates the workspace query. + + +## Acceptance Criteria + +Agent adapter behavior: + +- Agent hook installation preserves user hooks and deduplicates AO hooks. +- Hook callbacks persist native session id, title, and summary. +- `SessionInfo` returns normalized fields from persisted metadata. +- `SessionInfo` does not read transcripts or caches for title/summary. +- Adapter-specific metadata stays nil/empty unless a concrete feature requires it. + +Engine and UI: + +- Spawn installs hooks before launching the native agent. +- The session row exists before launch so hooks can merge metadata. +- Launch failure after row insertion deletes the row. +- Metadata updates publish `session.updated`. +- The dashboard refreshes title/summary without a manual reload. + +Verification: + +```sh +(cd backend && go test ./...) +(cd frontend && npm run typecheck) +``` diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..0cf6e3d997 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1780030872, + "narHash": "sha256-u6WU/yd/o8iYQrHX3RAwO1hYa3LkoSL+WNQD0rJfJZQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e9a7635a57597d9754eccebdfc7045e6c8600e6b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..d99c93815f --- /dev/null +++ b/flake.nix @@ -0,0 +1,41 @@ +{ + description = "agent-orchestrator development shell"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + go = pkgs.go_1_25; + in + { + devShells.default = pkgs.mkShell { + buildInputs = [ + go + pkgs.gotools + pkgs.nodejs_22 + pkgs.pnpm_10 + pkgs.just + ]; + + shellHook = '' + export GOROOT="${go}/share/go" + export GOPATH="$PWD/.go" + export GOBIN="$GOPATH/bin" + export PNPM_HOME="$PWD/.pnpm" + export PATH="$GOBIN:$PNPM_HOME:$PATH" + ''; + }; + } + ); +} From 57bb63701d199201e1cf461fc0fa6bc8f154c48d Mon Sep 17 00:00:00 2001 From: yyovil Date: Tue, 2 Jun 2026 18:39:13 +0530 Subject: [PATCH 103/250] Add `ao spawn` + `ao project add` (spawn a real worker end-to-end) (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add `ao spawn` and `ao project add`; resolve project repos for worktrees Make a registered project spawnable end-to-end from the CLI: - DB-backed RepoResolver: the daemon resolves a project's on-disk repo path from the projects table (replacing the empty StaticRepoResolver that failed every lookup), so a session's worktree is cut from the right repo. - session_manager defaults an empty spawn branch to ao/ — a fresh, unique branch per session, since gitworktree can't reuse a branch already checked out elsewhere (e.g. main). - `ao project add --path `: register a local git repo (POST /api/v1/projects). - `ao spawn --project [--harness] [--branch] [--prompt] [--issue]`: spawn a worker session (POST /api/v1/sessions); harness defaults to the daemon's AO_AGENT. - Shared postJSON daemon client (reads the run-file for the port, surfaces the API error envelope). Stacked on #65, which lands the agent-adapter + session-manager wiring this depends on. Co-Authored-By: Claude Opus 4.8 (1M context) * Address Copilot review on #77 - `ao spawn` no longer prints a branch the sessions API doesn't return (session metadata is json:"-"), so the output is no longer misleading. - Unregistered/archived/no-path projects now surface a 400 PROJECT_NOT_RESOLVABLE with an actionable message instead of a generic 500: a new sessionmanager.ErrProjectNotResolvable sentinel the resolver wraps and writeSessionError maps. - postJSON reuses the injected Deps.HTTPClient (cloned, with a longer timeout) instead of a fresh client, keeping HTTP behaviour stubbable. - postJSON treats a stale run-file (dead PID) as "not running" via ProcessAlive, matching its docstring. Co-Authored-By: Claude Opus 4.8 (1M context) * Assert the project-not-resolvable sentinel in the resolver test Greptile review: harden TestProjectRepoResolver to verify the unregistered -project error wraps ErrProjectNotResolvable, so a future regression in the sentinel wrapping (which the HTTP 400 mapping relies on) is caught. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix ao spawn 500 on long session ids (zellij socket-path overflow) Root cause: the daemon built the zellij runtime with an empty SocketDir, so zellij fell back to its $TMPDIR-based default (long on macOS). That left almost none of the ~103-byte unix-socket-path budget for the session name, so a long session id (e.g. "aoagents-agent-orchestrator-1", derived from a long project id) was rejected by zellij with "session name must be less than 0 characters". runtime.Create failed, the spawn 500'd, and the worktree was rolled back (leaving an orphan ao/ branch). - New zellij.DefaultSocketDir(): a short, stable per-user socket dir (/tmp/ao-zellij-); the daemon uses it (and MkdirAll's it). - ao spawn's attach hint now prefixes ZELLIJ_SOCKET_DIR so it stays copy-pasteable against the daemon's socket dir. - Regression test guards that the socket dir leaves >= 48 bytes for the session name within the 103-byte limit. Verified: ao spawn against a long-id project now succeeds (session live, worktree created) where it previously 500'd. Co-Authored-By: Claude Opus 4.8 (1M context) * test(cli): guard CLI/daemon DTO drift with an e2e round-trip The CLI keeps its own request structs (spawnRequest, addProjectRequest) separate from the daemon's canonical DTOs (controllers.SpawnSessionRequest, project.AddInput). Nothing verified the JSON field names agreed, so a renamed tag on either side would compile but break at runtime. Drive `ao spawn` and `ao project add` through the real httpd router and controllers (fakes only at the service layer) over a real loopback round trip via postJSON, asserting each field decodes into the right SpawnConfig/AddInput field. Runs in the normal test lane (no extra ports/processes). Co-Authored-By: Claude Opus 4.7 * fix(cli,daemon): address review findings on ao spawn - spawn: print the sanitised zellij session name (zellij.SessionName) in the attach hint; a long/non-conforming session id is registered under a different name, so the raw id sent users to a missing session. - client: surface the daemon error envelope's requestId so a failed command can be correlated with daemon logs. - daemon: don't swallow the zellij socket-dir MkdirAll error — log it, since a failure otherwise surfaces later as an opaque socket-bind error on every spawn. - project: reject an embedded ".." in a project id up front; it passed the id pattern but yielded an invalid branch (ao/a..b-1) and an opaque 500 at spawn. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in> --- .../adapters/runtime/zellij/zellij.go | 27 ++- .../adapters/runtime/zellij/zellij_test.go | 25 ++ backend/internal/cli/client.go | 97 ++++++++ backend/internal/cli/client_test.go | 25 ++ backend/internal/cli/dto_drift_e2e_test.go | 215 ++++++++++++++++++ backend/internal/cli/project.go | 71 ++++++ backend/internal/cli/root.go | 2 + backend/internal/cli/spawn.go | 88 +++++++ backend/internal/cli/spawn_test.go | 37 +++ backend/internal/daemon/daemon.go | 12 +- backend/internal/daemon/lifecycle_wiring.go | 33 ++- backend/internal/daemon/wiring_test.go | 55 +++++ .../internal/httpd/controllers/sessions.go | 2 + backend/internal/service/project/service.go | 5 +- .../internal/service/project/service_test.go | 5 + backend/internal/session_manager/manager.go | 12 +- .../internal/session_manager/manager_test.go | 23 ++ 17 files changed, 724 insertions(+), 10 deletions(-) create mode 100644 backend/internal/cli/client.go create mode 100644 backend/internal/cli/client_test.go create mode 100644 backend/internal/cli/dto_drift_e2e_test.go create mode 100644 backend/internal/cli/project.go create mode 100644 backend/internal/cli/spawn.go create mode 100644 backend/internal/cli/spawn_test.go diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go index f30a21ecf1..536ad28a77 100644 --- a/backend/internal/adapters/runtime/zellij/zellij.go +++ b/backend/internal/adapters/runtime/zellij/zellij.go @@ -58,6 +58,19 @@ type Runtime struct { var _ ports.Runtime = (*Runtime)(nil) +// DefaultSocketDir returns a short, stable ZELLIJ_SOCKET_DIR for AO's daemon. +// zellij's own default lives under $TMPDIR (long on macOS), which leaves almost +// none of the ~103-byte unix-socket-path budget for the session name — a long +// session id then fails with "session name must be less than 0 characters". A +// short dir restores ample budget. Empty on Windows, where zellij is not used. +// Pure: callers that run zellij should MkdirAll the result. +func DefaultSocketDir() string { + if runtime.GOOS == "windows" { + return "" + } + return "/tmp/ao-zellij-" + strconv.Itoa(os.Getuid()) +} + type runner interface { Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) } @@ -330,10 +343,18 @@ func zellijSessionName(id domain.SessionID) (string, error) { if raw == "" { return "", errors.New("zellij runtime: session id is required") } - if sessionIDPattern.MatchString(raw) && len(raw) <= 48 { - return raw, nil + return SessionName(raw), nil +} + +// SessionName returns the zellij session name the runtime registers for a given +// session id — applying the same sanitisation Create does. Callers that print an +// attach hint (e.g. `ao spawn`) must use this rather than the raw id, since a +// long or non-conforming id maps to a different, sanitised session name. +func SessionName(id string) string { + if sessionIDPattern.MatchString(id) && len(id) <= 48 { + return id } - return sanitizedSessionName(raw), nil + return sanitizedSessionName(id) } func sanitizedSessionName(raw string) string { diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go index c5ef9b1c1e..22e2e50929 100644 --- a/backend/internal/adapters/runtime/zellij/zellij_test.go +++ b/backend/internal/adapters/runtime/zellij/zellij_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -59,6 +60,30 @@ func TestZellijSessionNameSanitizesIssueRefs(t *testing.T) { } } +// SessionName must return the exact name Create registers a session under, so +// callers that print an attach hint (e.g. `ao spawn`) reference the real +// session. A short, conforming id passes through; a long one is sanitised to a +// different name — printing the raw id there would send users to a missing +// session. +func TestSessionNameMatchesCreateNaming(t *testing.T) { + short := "myproj-1" + if got := SessionName(short); got != short { + t.Fatalf("SessionName(%q) = %q, want it unchanged", short, got) + } + + long := domain.SessionID(strings.Repeat("x", 60) + "-1") + viaCreate, err := zellijSessionName(long) + if err != nil { + t.Fatalf("zellijSessionName: %v", err) + } + if got := SessionName(string(long)); got != viaCreate { + t.Fatalf("SessionName = %q, but Create uses %q", got, viaCreate) + } + if SessionName(string(long)) == string(long) { + t.Fatal("expected a long id to be sanitised to a different name") + } +} + func TestValidateSessionAndPaneID(t *testing.T) { for _, id := range []string{"sess-1", "S_2", "abc123"} { if err := validateSessionID(id); err != nil { diff --git a/backend/internal/cli/client.go b/backend/internal/cli/client.go new file mode 100644 index 0000000000..e6de41fe6f --- /dev/null +++ b/backend/internal/cli/client.go @@ -0,0 +1,97 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" +) + +// commandTimeout bounds a mutating daemon call. Spawns do real work (git +// worktree add, zellij launch, hook install), so it is generous compared to the +// status probe timeout. +const commandTimeout = 2 * time.Minute + +// apiError is the subset of the daemon's JSON error envelope the CLI surfaces. +// RequestID is surfaced so a failed command can be correlated with daemon logs. +type apiError struct { + Message string `json:"message"` + Code string `json:"code"` + RequestID string `json:"requestId"` +} + +// String renders the envelope for the user: " () [request ]", +// omitting whichever parts the daemon left empty. +func (e apiError) String() string { + msg := e.Message + if e.Code != "" { + msg = fmt.Sprintf("%s (%s)", msg, e.Code) + } + if e.RequestID != "" { + msg = fmt.Sprintf("%s [request %s]", msg, e.RequestID) + } + return msg +} + +// postJSON sends body as JSON to POST /api/v1/ on the running daemon and +// decodes a 2xx response into out (out may be nil). A non-2xx response becomes +// an error built from the API error envelope. A missing run-file or a stale one +// (dead PID) yields a clear "not running" message rather than a +// connection-refused dump. +func (c *commandContext) postJSON(ctx context.Context, path string, body, out any) error { + cfg, err := config.Load() + if err != nil { + return err + } + info, err := runfile.Read(cfg.RunFilePath) + if err != nil { + return err + } + if info == nil { + return fmt.Errorf("AO daemon is not running — start it with `ao start`") + } + if !c.deps.ProcessAlive(info.PID) { + return fmt.Errorf("AO daemon is not running (stale run-file at %s) — start it with `ao start`", cfg.RunFilePath) + } + + payload, err := json.Marshal(body) + if err != nil { + return err + } + url := fmt.Sprintf("http://%s:%d/api/v1/%s", config.LoopbackHost, info.Port, path) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + // Reuse the injected client's transport (keeps it stubbable in tests) but + // give mutating calls far more headroom than the 2s status-probe timeout. + client := *c.deps.HTTPClient + client.Timeout = commandTimeout + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("call daemon: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var e apiError + _ = json.NewDecoder(resp.Body).Decode(&e) + if e.Message == "" { + return fmt.Errorf("daemon returned HTTP %d", resp.StatusCode) + } + return fmt.Errorf("%s", e.String()) + } + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} diff --git a/backend/internal/cli/client_test.go b/backend/internal/cli/client_test.go new file mode 100644 index 0000000000..eb07dee34d --- /dev/null +++ b/backend/internal/cli/client_test.go @@ -0,0 +1,25 @@ +package cli + +import "testing" + +// TestAPIErrorString covers how the CLI renders the daemon's error envelope, +// including the requestId it now surfaces for log correlation. +func TestAPIErrorString(t *testing.T) { + cases := []struct { + name string + in apiError + want string + }{ + {"message only", apiError{Message: "boom"}, "boom"}, + {"message and code", apiError{Message: "boom", Code: "X"}, "boom (X)"}, + {"with request id", apiError{Message: "boom", Code: "X", RequestID: "req-1"}, "boom (X) [request req-1]"}, + {"message and request id", apiError{Message: "boom", RequestID: "req-1"}, "boom [request req-1]"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.in.String(); got != tc.want { + t.Fatalf("String() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go new file mode 100644 index 0000000000..07c10a6563 --- /dev/null +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -0,0 +1,215 @@ +package cli + +// dto_drift_e2e_test.go is the DTO-drift guard for the `ao spawn` and +// `ao project add` commands. The CLI defines its OWN request structs +// (spawnRequest in spawn.go, addProjectRequest in project.go) that are separate +// copies of the daemon's canonical request DTOs (controllers.SpawnSessionRequest +// and project.AddInput). Nothing else verifies the two sides agree on JSON field +// names — a renamed `json:"..."` tag on either side compiles fine but silently +// breaks at runtime. +// +// This test stands up the REAL daemon HTTP router + REAL controllers (with fakes +// only BELOW the controller, at the service layer) and drives the actual CLI +// commands through the actual postJSON client over a real loopback HTTP round +// trip. If the CLI's JSON field names diverge from what the controllers decode, +// the captured values are wrong/empty and the subtests fail. +// +// (This lives in a separate file from the build-tagged e2e_test.go so it runs in +// the normal `go test ./...` lane — it binds no extra ports beyond httptest and +// spawns no processes.) + +import ( + "bytes" + "context" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" +) + +// fakeSessionService captures the ports.SpawnConfig the controller decodes from +// the CLI's request body. Every other method is a no-op so it satisfies the +// controllers.SessionService interface. +type fakeSessionService struct { + spawned ports.SpawnConfig +} + +var _ controllers.SessionService = (*fakeSessionService)(nil) + +func (f *fakeSessionService) List(context.Context, sessionsvc.ListFilter) ([]domain.Session, error) { + return nil, nil +} + +func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) { + f.spawned = cfg + return domain.Session{ + SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-1")}, + Status: domain.StatusIdle, + }, nil +} + +func (f *fakeSessionService) Get(context.Context, domain.SessionID) (domain.Session, error) { + return domain.Session{}, nil +} + +func (f *fakeSessionService) Restore(context.Context, domain.SessionID) (domain.Session, error) { + return domain.Session{}, nil +} + +func (f *fakeSessionService) Kill(context.Context, domain.SessionID) (bool, error) { + return false, nil +} + +func (f *fakeSessionService) Send(context.Context, domain.SessionID, string) error { + return nil +} + +// fakeProjectManager captures the project.AddInput the controller decodes from +// the CLI's request body. Every other method is a no-op so it satisfies the +// projectsvc.Manager interface. +type fakeProjectManager struct { + added projectsvc.AddInput +} + +var _ projectsvc.Manager = (*fakeProjectManager)(nil) + +func (f *fakeProjectManager) List(context.Context) ([]projectsvc.Summary, error) { + return nil, nil +} + +func (f *fakeProjectManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) { + return projectsvc.GetResult{}, nil +} + +func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (projectsvc.Project, error) { + f.added = in + id := domain.ProjectID("demo") + if in.ProjectID != nil { + id = domain.ProjectID(*in.ProjectID) + } + return projectsvc.Project{ID: id, Path: in.Path}, nil +} + +func (f *fakeProjectManager) Remove(context.Context, domain.ProjectID) (projectsvc.RemoveResult, error) { + return projectsvc.RemoveResult{}, nil +} + +// startDriftTestDaemon stands up the real router+controllers backed by the +// supplied fakes and points the CLI's run-file at it. The CLI discovers the +// server purely via AO_RUN_FILE + the run-file port, so this is a genuine +// loopback round trip through postJSON. +func startDriftTestDaemon(t *testing.T, sessions controllers.SessionService, projects projectsvc.Manager) { + t.Helper() + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + router := httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ + Sessions: sessions, + Projects: projects, + }) + srv := httptest.NewServer(router) + t.Cleanup(srv.Close) + + port := srv.Listener.Addr().(*net.TCPAddr).Port + + rfPath := filepath.Join(t.TempDir(), "running.json") + t.Setenv("AO_RUN_FILE", rfPath) + if err := runfile.Write(rfPath, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Now()}); err != nil { + t.Fatalf("write run-file: %v", err) + } +} + +func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) { + t.Run("spawn", func(t *testing.T) { + sessions := &fakeSessionService{} + startDriftTestDaemon(t, sessions, &fakeProjectManager{}) + + var out bytes.Buffer + root := NewRootCommand(Deps{ + Out: &out, + Err: &out, + HTTPClient: &http.Client{}, + ProcessAlive: func(int) bool { return true }, + }) + root.SetArgs([]string{ + "spawn", + "--project", "mer", + "--harness", "codex", + "--branch", "feat/x", + "--prompt", "hi", + "--issue", "ISS-1", + }) + if err := root.Execute(); err != nil { + t.Fatalf("spawn execute: %v\noutput: %s", err, out.String()) + } + + got := sessions.spawned + if got.ProjectID != "mer" { + t.Errorf("ProjectID = %q, want %q (CLI json:\"projectId\" vs SpawnSessionRequest)", got.ProjectID, "mer") + } + if got.Harness != "codex" { + t.Errorf("Harness = %q, want %q", got.Harness, "codex") + } + if got.Branch != "feat/x" { + t.Errorf("Branch = %q, want %q", got.Branch, "feat/x") + } + if got.Prompt != "hi" { + t.Errorf("Prompt = %q, want %q", got.Prompt, "hi") + } + if got.IssueID != "ISS-1" { + t.Errorf("IssueID = %q, want %q", got.IssueID, "ISS-1") + } + if !bytes.Contains(out.Bytes(), []byte("spawned session")) { + t.Errorf("output missing %q; got: %s", "spawned session", out.String()) + } + }) + + t.Run("project add", func(t *testing.T) { + projects := &fakeProjectManager{} + startDriftTestDaemon(t, &fakeSessionService{}, projects) + + var out bytes.Buffer + root := NewRootCommand(Deps{ + Out: &out, + Err: &out, + HTTPClient: &http.Client{}, + ProcessAlive: func(int) bool { return true }, + }) + root.SetArgs([]string{ + "project", "add", + "--path", "/repo/mer", + "--id", "demo", + "--name", "Demo", + }) + if err := root.Execute(); err != nil { + t.Fatalf("project add execute: %v\noutput: %s", err, out.String()) + } + + got := projects.added + if got.Path != "/repo/mer" { + t.Errorf("Path = %q, want %q", got.Path, "/repo/mer") + } + if got.ProjectID == nil || *got.ProjectID != "demo" { + t.Errorf("ProjectID = %v, want %q (CLI json:\"projectId\" vs AddInput)", got.ProjectID, "demo") + } + if got.Name == nil || *got.Name != "Demo" { + t.Errorf("Name = %v, want %q", got.Name, "Demo") + } + if !bytes.Contains(out.Bytes(), []byte("registered project")) { + t.Errorf("output missing %q; got: %s", "registered project", out.String()) + } + }) +} diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go new file mode 100644 index 0000000000..e25d290661 --- /dev/null +++ b/backend/internal/cli/project.go @@ -0,0 +1,71 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +type projectAddOptions struct { + path string + id string + name string +} + +// addProjectRequest mirrors the daemon's project AddInput body for +// POST /api/v1/projects. projectId and name are optional (pointers omit them). +type addProjectRequest struct { + Path string `json:"path"` + ProjectID *string `json:"projectId,omitempty"` + Name *string `json:"name,omitempty"` +} + +type projectResult struct { + Project struct { + ID string `json:"id"` + Path string `json:"path"` + } `json:"project"` +} + +func newProjectCommand(ctx *commandContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "project", + Short: "Manage projects", + } + cmd.AddCommand(newProjectAddCommand(ctx)) + return cmd +} + +func newProjectAddCommand(ctx *commandContext) *cobra.Command { + var opts projectAddOptions + cmd := &cobra.Command{ + Use: "add", + Short: "Register a local git repo as a project", + Long: "Register a local git repo as a project so sessions can be spawned in it.\n\n" + + "The path must be an existing git repository on disk.", + Args: noArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.path == "" { + return usageError{fmt.Errorf("--path is required")} + } + req := addProjectRequest{Path: opts.path} + if opts.id != "" { + req.ProjectID = &opts.id + } + if opts.name != "" { + req.Name = &opts.name + } + var res projectResult + if err := ctx.postJSON(cmd.Context(), "projects", req, &res); err != nil { + return err + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "registered project %s at %s\n", res.Project.ID, res.Project.Path) + return err + }, + } + f := cmd.Flags() + f.StringVar(&opts.path, "path", "", "Absolute path to the local git repo (required)") + f.StringVar(&opts.id, "id", "", "Project id (default: derived by the daemon from the path)") + f.StringVar(&opts.name, "name", "", "Display name") + return cmd +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index ce0157389c..293d431fb1 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -147,6 +147,8 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newStopCommand(ctx)) root.AddCommand(newStatusCommand(ctx)) root.AddCommand(newDoctorCommand(ctx)) + root.AddCommand(newSpawnCommand(ctx)) + root.AddCommand(newProjectCommand(ctx)) root.AddCommand(newCompletionCommand()) root.AddCommand(newVersionCommand()) diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go new file mode 100644 index 0000000000..534fff78fe --- /dev/null +++ b/backend/internal/cli/spawn.go @@ -0,0 +1,88 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" +) + +type spawnOptions struct { + project string + harness string + branch string + prompt string + issue string + rules string +} + +// spawnRequest mirrors the daemon's SpawnSessionRequest body for +// POST /api/v1/sessions. The CLI keeps its own copy so it need not import httpd. +type spawnRequest struct { + ProjectID string `json:"projectId"` + IssueID string `json:"issueId,omitempty"` + Harness string `json:"harness,omitempty"` + Branch string `json:"branch,omitempty"` + Prompt string `json:"prompt,omitempty"` + AgentRules string `json:"agentRules,omitempty"` +} + +type spawnResult struct { + Session struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"session"` +} + +func newSpawnCommand(ctx *commandContext) *cobra.Command { + var opts spawnOptions + cmd := &cobra.Command{ + Use: "spawn", + Short: "Spawn a worker agent session in a registered project", + Long: "Spawn a worker agent session in a registered project.\n\n" + + "The session runs the chosen agent (default: the daemon's AO_AGENT) in a\n" + + "fresh git worktree. Register the project first with `ao project add`.", + Args: noArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.project == "" { + return usageError{fmt.Errorf("--project is required")} + } + req := spawnRequest{ + ProjectID: opts.project, + IssueID: opts.issue, + Harness: opts.harness, + Branch: opts.branch, + Prompt: opts.prompt, + AgentRules: opts.rules, + } + var res spawnResult + if err := ctx.postJSON(cmd.Context(), "sessions", req, &res); err != nil { + return err + } + out := cmd.OutOrStdout() + if _, err := fmt.Fprintf(out, "spawned session %s (%s)\n", res.Session.ID, res.Session.Status); err != nil { + return err + } + // The daemon runs zellij under a short, non-default socket dir (see + // zellij.DefaultSocketDir), so a plain `zellij attach` wouldn't find + // the session — prefix the env so the hint is copy-pasteable. Use the + // sanitised name zellij actually registers (zellij.SessionName): a long + // session id maps to a different name than the raw id. + attach := fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID)) + if dir := zellij.DefaultSocketDir(); dir != "" { + attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach) + } + _, err := fmt.Fprintf(out, "attach with: %s\n", attach) + return err + }, + } + f := cmd.Flags() + f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)") + f.StringVar(&opts.harness, "harness", "", "Agent harness: claude-code, codex, … (default: the daemon's AO_AGENT)") + f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/)") + f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent") + f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session") + f.StringVar(&opts.rules, "rules", "", "Agent rules appended to the prompt") + return cmd +} diff --git a/backend/internal/cli/spawn_test.go b/backend/internal/cli/spawn_test.go new file mode 100644 index 0000000000..baca6e3ad5 --- /dev/null +++ b/backend/internal/cli/spawn_test.go @@ -0,0 +1,37 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +// TestSpawnCommand_RequiresProject asserts `ao spawn` rejects a missing +// --project before touching the network, so it fails fast without a daemon. +func TestSpawnCommand_RequiresProject(t *testing.T) { + var out, errb bytes.Buffer + root := NewRootCommand(Deps{Out: &out, Err: &errb}) + root.SetArgs([]string{"spawn"}) + err := root.Execute() + if err == nil { + t.Fatal("expected an error when --project is missing") + } + if !strings.Contains(err.Error(), "--project is required") { + t.Fatalf("error = %v, want it to mention --project is required", err) + } +} + +// TestProjectAddCommand_RequiresPath asserts `ao project add` rejects a missing +// --path before touching the network. +func TestProjectAddCommand_RequiresPath(t *testing.T) { + var out, errb bytes.Buffer + root := NewRootCommand(Deps{Out: &out, Err: &errb}) + root.SetArgs([]string{"project", "add"}) + err := root.Execute() + if err == nil { + t.Fatal("expected an error when --path is missing") + } + if !strings.Contains(err.Error(), "--path is required") { + t.Fatalf("error = %v, want it to mention --path is required", err) + } +} diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 3b75e4fb0d..d38fa4ea17 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -62,7 +62,17 @@ func Run() error { // liveness; the CDC broadcaster feeds the session-state channel. The manager // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow // through the CDC change_log — only session-state events do. - runtimeAdapter := zellij.New(zellij.Options{}) + // zellij's default socket dir is too long on macOS for long session ids + // (see zellij.DefaultSocketDir); use a short, stable one and ensure it exists. + zellijSocketDir := zellij.DefaultSocketDir() + if zellijSocketDir != "" { + if err := os.MkdirAll(zellijSocketDir, 0o700); err != nil { + // Don't abort startup, but surface it: every spawn's zellij session + // would otherwise fail later with an opaque socket-bind error. + log.Warn("could not create zellij socket dir; spawns may fail", "dir", zellijSocketDir, "error", err) + } + } + runtimeAdapter := zellij.New(zellij.Options{SocketDir: zellijSocketDir}) termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) defer termMgr.Close() diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 34685670fb..51232299d0 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -63,10 +63,10 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, // Per-session worktrees live under the data dir, so a single AO_DATA_DIR // override moves all durable per-user state together. ManagedRoot: filepath.Join(cfg.DataDir, "worktrees"), - // An empty resolver fails every project lookup with a clear - // "no repo configured for project" error until the projects table feeds - // repo paths in — better than silently misrouting spawns. - RepoResolver: gitworktree.StaticRepoResolver{}, + // Resolve each project's source repo from the projects table, so a + // session spawned for a registered project materialises its worktree off + // that repo. Unregistered projects fail loudly. + RepoResolver: projectRepoResolver{store: store}, }) if err != nil { return nil, fmt.Errorf("session workspace: %w", err) @@ -145,3 +145,28 @@ func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResol log.Info("built per-session agent resolver", "default", defaultAgent, "registered", ids) return resolver, nil } + +// projectRepoResolver resolves a project's on-disk repo path from the projects +// table so gitworktree can materialise per-session worktrees off it. It replaces +// the empty StaticRepoResolver the daemon used before (which failed every +// lookup), turning a registered project into a spawnable one. +type projectRepoResolver struct{ store *sqlite.Store } + +var _ gitworktree.RepoResolver = projectRepoResolver{} + +func (r projectRepoResolver) RepoPath(projectID domain.ProjectID) (string, error) { + rec, ok, err := r.store.GetProject(context.Background(), string(projectID)) + if err != nil { + return "", fmt.Errorf("look up project %q: %w", projectID, err) + } + if !ok { + return "", fmt.Errorf("no project registered with id %q — add one with `ao project add`: %w", projectID, sessionmanager.ErrProjectNotResolvable) + } + if !rec.ArchivedAt.IsZero() { + return "", fmt.Errorf("project %q is archived: %w", projectID, sessionmanager.ErrProjectNotResolvable) + } + if rec.Path == "" { + return "", fmt.Errorf("project %q has no repo path on record: %w", projectID, sessionmanager.ErrProjectNotResolvable) + } + return rec.Path, nil +} diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 33ba2839f7..d15f9deef4 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "errors" "io" "log/slog" "sync" @@ -15,6 +16,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -132,3 +134,56 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { t.Fatal("startSession returned nil session service") } } + +// TestProjectRepoResolver_ResolvesRegisteredProject asserts the DB-backed repo +// resolver turns a registered project into its on-disk repo path (so spawns +// materialise a worktree), and fails loudly for an unregistered project. +func TestProjectRepoResolver_ResolvesRegisteredProject(t *testing.T) { + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + ctx := context.Background() + if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil { + t.Fatal(err) + } + + r := projectRepoResolver{store: store} + got, err := r.RepoPath("mer") + if err != nil { + t.Fatalf("RepoPath(mer): %v", err) + } + if got != "/repo/mer" { + t.Fatalf("RepoPath(mer) = %q, want /repo/mer", got) + } + _, err = r.RepoPath("nope") + if err == nil { + t.Fatal("expected an error for an unregistered project") + } + // Guard the sentinel wrapping so the HTTP 400 mapping can't silently regress. + if !errors.Is(err, sessionmanager.ErrProjectNotResolvable) { + t.Fatalf("unregistered-project error should wrap ErrProjectNotResolvable, got %v", err) + } +} + +// TestDaemonZellijSocketDir_LeavesBudgetForSessionNames guards the fix for the +// zellij "session name must be less than 0 characters" spawn failure: the +// daemon's socket dir must be short enough that a max-length (48-char) session +// name still fits the ~103-byte unix-domain-socket-path budget. zellij's long +// $TMPDIR default (the bug) would fail this. +func TestDaemonZellijSocketDir_LeavesBudgetForSessionNames(t *testing.T) { + dir := zellij.DefaultSocketDir() + if dir == "" { + t.Skip("zellij not used on this platform") + } + const ( + unixSocketPathMax = 103 // sun_path budget zellij enforces on macOS + zellijOverhead = 24 // zellij's version subdir + separators (generous) + maxSessionName = 48 // zellijSessionName's cap + ) + if budget := unixSocketPathMax - len(dir) - zellijOverhead; budget < maxSessionName { + t.Fatalf("zellij socket dir %q too long: %d bytes left for the session name, need >= %d", dir, budget, maxSessionName) + } +} diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index c97f388ad3..bc88fd7216 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -253,6 +253,8 @@ func writeSessionError(w http.ResponseWriter, r *http.Request, err error) { envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_NOT_RESTORABLE", "Session is not restorable", nil) case errors.Is(err, sessionmanager.ErrIncompleteHandle): envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_INCOMPLETE_HANDLE", "Session is missing runtime or workspace handles", nil) + case errors.Is(err, sessionmanager.ErrProjectNotResolvable): + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil) default: envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "SESSION_OPERATION_FAILED", "Session operation failed", nil) } diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index 0eb213abe8..89cf5a10ba 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -228,7 +228,10 @@ var projectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) func validateProjectID(id domain.ProjectID) error { raw := string(id) - if raw == "" || raw == "." || raw == ".." || strings.ContainsAny(raw, `/\`) || !projectIDPattern.MatchString(raw) { + // Reject any "." run: a "." prefix fails the pattern, but an embedded ".." + // (e.g. "a..b") passes it yet yields a branch like "ao/a..b-1" that git's + // check-ref-format rejects — surfacing as an opaque 500 at spawn time. + if raw == "" || raw == "." || strings.Contains(raw, "..") || strings.ContainsAny(raw, `/\`) || !projectIDPattern.MatchString(raw) { return badRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil) } return nil diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index d8ae22ec5a..03dedfad89 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -125,6 +125,11 @@ func TestManager_AddValidationAndConflicts(t *testing.T) { _, err = m.Add(ctx, project.AddInput{Path: t.TempDir()}) // exists but not a git repo wantCode(t, err, "NOT_A_GIT_REPO") + // An embedded ".." passes the id pattern but would yield an invalid git + // branch (ao/a..b-1) at spawn time; reject it up front as a clear 400. + _, err = m.Add(ctx, project.AddInput{Path: gitRepo(t), ProjectID: ptr("a..b")}) + wantCode(t, err, "INVALID_PROJECT_ID") + repoA, repoB := gitRepo(t), gitRepo(t) if _, err := m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("shared")}); err != nil { t.Fatalf("seed add: %v", err) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index d1cc1007a0..fcf4277f7d 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -17,6 +17,9 @@ var ( ErrNotFound = errors.New("session: not found") ErrNotRestorable = errors.New("session: not restorable (not terminal)") ErrIncompleteHandle = errors.New("session: incomplete teardown handle") + // ErrProjectNotResolvable means the spawn's project has no usable repo + // (unregistered, archived, or missing a path). The API maps it to a 400. + ErrProjectNotResolvable = errors.New("session: project repo not resolvable") ) // Env vars a spawned process reads to learn who it is. @@ -101,7 +104,14 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess } id := rec.ID - ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ProjectID: cfg.ProjectID, SessionID: id, Branch: cfg.Branch}) + branch := cfg.Branch + if branch == "" { + // A fresh, unique branch per session: gitworktree can't add a worktree on + // a branch already checked out elsewhere (e.g. main), so default to one + // derived from the assigned session id. + branch = "ao/" + string(id) + } + ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ProjectID: cfg.ProjectID, SessionID: id, Branch: branch}) if err != nil { m.markSpawnFailedTerminated(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err) diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 74bf7bbcfd..78900d4738 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -245,3 +245,26 @@ func TestCleanup_ReclaimsTerminalWorkspaces(t *testing.T) { t.Fatal("live workspace must not be destroyed") } } + +func TestSpawn_DefaultsBranchFromSessionID(t *testing.T) { + m, st, _, _ := newManager() + s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + if err != nil { + t.Fatal(err) + } + // An empty SpawnConfig.Branch defaults to a unique per-session branch. + if got := st.sessions[s.ID].Metadata.Branch; got != "ao/mer-1" { + t.Fatalf("default branch = %q, want ao/mer-1", got) + } +} + +func TestSpawn_KeepsExplicitBranch(t *testing.T) { + m, st, _, _ := newManager() + s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "feature/x"}) + if err != nil { + t.Fatal(err) + } + if got := st.sessions[s.ID].Metadata.Branch; got != "feature/x" { + t.Fatalf("explicit branch = %q, want feature/x", got) + } +} From 5435246c9a54c04421b636a6ce33efefaefc3929 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 2 Jun 2026 20:02:47 +0530 Subject: [PATCH 104/250] feat(cli): add minimal ao send (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(messenger): ao send + live zellij pane ping (live agent nudges) Replace the daemon's noopMessenger stub with a composite AgentMessenger that writes a durable inbox file (primary) and types a live pointer into the running zellij pane (best-effort secondary), plus the `ao send` CLI that drives the existing POST /api/v1/sessions/{id}/send route. - composite: fans Send to inbox then panep, pinning one timestamp so both derive the same filename; a secondary failure is logged at WARN and swallowed (the file is on disk), a primary failure aborts the call. - inbox: writes /.ao/inbox/_.md. - panep: types "new message at .ao/inbox/" + Enter via a new narrow zellij WriteChars seam (RuntimePaneWriter), kept off ports.Runtime. - wiring: newSessionMessenger composes inbox+panep over the shared store; startSession takes the messenger instead of the noop stub. Carries across @aa-43's work from PR #74 (staging), adapted to main's post-#65/#77 daemon wiring shape. Closes #79 Co-Authored-By: Claude Opus 4.7 * fix(inbox): use O_EXCL so a filename collision errors instead of clobbering os.WriteFile opens with O_CREATE|O_WRONLY|O_TRUNC, which silently overwrites an existing file. The doc comment already stated the intent ("we do not retry on EEXIST"), but O_TRUNC never yields EEXIST — two identical messages sent on the same composite-pinned nanosecond would produce the same filename and the second Send would silently lose the first message. Switch to O_CREATE|O_EXCL|O_WRONLY so a collision surfaces as an error; O_EXCL also refuses to follow a symlink at the final path component. Add a regression test. Addresses greptile review on PR #83. Co-Authored-By: Claude Opus 4.7 * fix(inbox): remove the freshly-created file when write or close fails The O_EXCL switch creates the inbox file before writing its body; if WriteString or Close then fails, the empty/partial .md was left on disk and the agent's next inbox scan would pick up a truncated ghost message. Remove the file on those error paths. O_EXCL guarantees the file did not exist before this call, so the cleanup can only delete our own partial write, never a legitimate earlier message. Addresses greptile review on PR #83. Co-Authored-By: Claude Opus 4.7 * fix(messenger): reduce ao send to live pane delivery * fix(send): preserve messages and map lookup errors * fix(send): reject terminated sessions --- backend/internal/cli/root.go | 1 + backend/internal/cli/send.go | 53 +++++ backend/internal/cli/send_test.go | 220 ++++++++++++++++++ backend/internal/daemon/daemon.go | 11 +- backend/internal/daemon/lifecycle_wiring.go | 54 ++++- backend/internal/daemon/wiring_test.go | 120 +++++++++- .../internal/httpd/controllers/sessions.go | 2 + backend/internal/session_manager/manager.go | 1 + 8 files changed, 446 insertions(+), 16 deletions(-) create mode 100644 backend/internal/cli/send.go create mode 100644 backend/internal/cli/send_test.go diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 293d431fb1..280cd7b1ae 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -148,6 +148,7 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newStatusCommand(ctx)) root.AddCommand(newDoctorCommand(ctx)) root.AddCommand(newSpawnCommand(ctx)) + root.AddCommand(newSendCommand(ctx)) root.AddCommand(newProjectCommand(ctx)) root.AddCommand(newCompletionCommand()) root.AddCommand(newVersionCommand()) diff --git a/backend/internal/cli/send.go b/backend/internal/cli/send.go new file mode 100644 index 0000000000..e1ecbcd567 --- /dev/null +++ b/backend/internal/cli/send.go @@ -0,0 +1,53 @@ +package cli + +import ( + "context" + "errors" + "net/url" + "strings" + + "github.com/spf13/cobra" +) + +type sendOptions struct { + session string + message string +} + +// sendAPIRequest mirrors the daemon's SendSessionMessageRequest body for +// POST /api/v1/sessions/{id}/send. The CLI keeps its own copy so it need not +// import httpd. +type sendAPIRequest struct { + Message string `json:"message"` +} + +func newSendCommand(ctx *commandContext) *cobra.Command { + var opts sendOptions + cmd := &cobra.Command{ + Use: "send", + Short: "Send a message to a running agent session", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return ctx.sendMessage(cmd.Context(), opts) + }, + } + cmd.Flags().StringVar(&opts.session, "session", "", "Session id (required)") + cmd.Flags().StringVar(&opts.message, "message", "", "Message body (required)") + return cmd +} + +func (c *commandContext) sendMessage(ctx context.Context, opts sendOptions) error { + if strings.TrimSpace(opts.message) == "" { + return usageError{errors.New("usage: --message is required")} + } + message := opts.message + session := strings.TrimSpace(opts.session) + if session == "" { + return usageError{errors.New("usage: --session is required")} + } + + // PathEscape: session ids are already "-"/digit safe, but may later come + // from sanitized issue refs; keep the URL well-formed regardless. + path := "sessions/" + url.PathEscape(session) + "/send" + return c.postJSON(ctx, path, sendAPIRequest{Message: message}, nil) +} diff --git a/backend/internal/cli/send_test.go b/backend/internal/cli/send_test.go new file mode 100644 index 0000000000..a68293ba62 --- /dev/null +++ b/backend/internal/cli/send_test.go @@ -0,0 +1,220 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" +) + +// sendServer wires an httptest server expecting POST /api/v1/sessions/{id}/send +// and captures the request body and path the CLI hit. +type sendCapture struct { + body string + path string +} + +// writeRunFileFor points the CLI's run-file at srv so postJSON dials the test +// server. It mirrors the run-file convention the other CLI tests use. +func writeRunFileFor(t *testing.T, cfg testConfig, srv *httptest.Server) { + t.Helper() + if err := runfile.Write(cfg.runFile, runfile.Info{ + PID: os.Getpid(), Port: serverPort(t, srv.URL), StartedAt: time.Unix(100, 0).UTC(), + }); err != nil { + t.Fatalf("write run-file: %v", err) + } +} + +func sendServer(t *testing.T, status int, respBody string) (*httptest.Server, *sendCapture) { + t.Helper() + capture := &sendCapture{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + if !strings.HasPrefix(r.URL.Path, "/api/v1/sessions/") || !strings.HasSuffix(r.URL.Path, "/send") { + http.NotFound(w, r) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + capture.body = string(body) + capture.path = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, respBody) + })) + t.Cleanup(srv.Close) + return srv, capture +} + +func TestSend_Success(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := sendServer(t, http.StatusOK, + `{"ok":true,"sessionId":"demo-1","message":"hello agent"}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "send", "--session", "demo-1", "--message", "hello agent") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.path != "/api/v1/sessions/demo-1/send" { + t.Errorf("path = %q, want /api/v1/sessions/demo-1/send", capture.path) + } + var req struct { + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(capture.body), &req); err != nil { + t.Fatalf("decode body: %v\nbody=%s", err, capture.body) + } + if req.Message != "hello agent" { + t.Errorf("captured message = %q, want %q", req.Message, "hello agent") + } +} + +func TestSend_PreservesMessageWhitespace(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := sendServer(t, http.StatusOK, `{"ok":true,"sessionId":"demo-1","message":"hi"}`) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "send", "--session", "demo-1", "--message", " hi ") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var req struct { + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(capture.body), &req); err != nil { + t.Fatalf("decode body: %v\nbody=%s", err, capture.body) + } + if req.Message != " hi " { + t.Errorf("server received %q, want preserved whitespace", req.Message) + } +} + +func TestSend_EmptyMessageIsUsageError(t *testing.T) { + setConfigEnv(t) + _, _, err := executeCLI(t, Deps{}, "send", "--session", "demo-1", "--message", " ") + if err == nil { + t.Fatal("expected usage error for empty message") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2", got) + } + if !strings.Contains(err.Error(), "--message is required") { + t.Fatalf("error missing usage message: %v", err) + } +} + +func TestSend_MissingSessionIsUsageError(t *testing.T) { + setConfigEnv(t) + _, _, err := executeCLI(t, Deps{}, "send", "--message", "hi") + if err == nil { + t.Fatal("expected usage error for missing --session") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2", got) + } +} + +func TestSend_ServerBadRequestExits1(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := sendServer(t, http.StatusBadRequest, + `{"error":"bad_request","code":"MESSAGE_REQUIRED","message":"Message is required"}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "send", "--session", "demo-1", "--message", "hi") + if err == nil { + t.Fatal("expected runtime error from 400") + } + if got := ExitCode(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } + if !strings.Contains(err.Error(), "MESSAGE_REQUIRED") && !strings.Contains(errOut, "MESSAGE_REQUIRED") { + t.Fatalf("error did not surface the server error envelope: %v\nstderr=%s", err, errOut) + } +} + +func TestSend_ServerNotFoundExits1(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := sendServer(t, http.StatusNotFound, + `{"error":"not_found","code":"SESSION_NOT_FOUND","message":"Unknown session"}`) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "send", "--session", "missing", "--message", "hi") + if err == nil { + t.Fatal("expected runtime error from 404") + } + if got := ExitCode(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } +} + +func TestSend_ServerInternalErrorExits1(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := sendServer(t, http.StatusInternalServerError, + `{"error":"internal","code":"SESSION_OPERATION_FAILED","message":"Session operation failed"}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "send", "--session", "demo-1", "--message", "hi") + if err == nil { + t.Fatal("expected runtime error from 500") + } + if got := ExitCode(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } + // Regression guard: a future change that swallows the API envelope and + // prints only "daemon returned HTTP 500" would silently hide what the + // daemon was trying to tell the operator. + if !strings.Contains(err.Error(), "SESSION_OPERATION_FAILED") && !strings.Contains(errOut, "SESSION_OPERATION_FAILED") { + t.Fatalf("error did not surface the server error envelope: %v\nstderr=%s", err, errOut) + } +} + +func TestSend_DaemonNotRunningExits1(t *testing.T) { + setConfigEnv(t) + _, _, err := executeCLI(t, Deps{}, "send", "--session", "demo-1", "--message", "hi") + if err == nil { + t.Fatal("expected error when daemon is not running") + } + if got := ExitCode(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } +} + +func TestSend_NetworkErrorExits1(t *testing.T) { + cfg := setConfigEnv(t) + // Start and immediately close a server so the run-file points at a closed port. + srv, _ := sendServer(t, http.StatusOK, "{}") + writeRunFileFor(t, cfg, srv) + srv.Close() + + _, _, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "send", "--session", "demo-1", "--message", "hi") + if err == nil { + t.Fatal("expected runtime error from network failure") + } + if got := ExitCode(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } +} diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index d38fa4ea17..97a2ab20ac 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -81,10 +81,15 @@ func Run() error { // change_log -> poller -> broadcaster) and gives startSession the shared LCM. lcStack := startLifecycle(ctx, store, runtimeAdapter, log) + // The agent messenger sends validated user input to the session's live + // zellij pane. Keep this path small until durable inbox semantics are needed. + messenger := newSessionMessenger(store, runtimeAdapter, log) + // Wire the controller-facing session service over the same store + LCM, the - // zellij runtime, a gitworktree workspace, and the per-session agent resolver - // (AO_AGENT default, validated here), then mount it on the API. - sessionSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, log) + // zellij runtime, a gitworktree workspace, the per-session agent resolver + // (AO_AGENT default, validated here), and the agent messenger, then mount it + // on the API. + sessionSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, log) if err != nil { stop() lcStack.Stop() diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 51232299d0..69aae57404 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -42,19 +42,11 @@ func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runt // passed to startLifecycle before calling Stop. func (l *lifecycleStack) Stop() { <-l.reaperDone } -// noopMessenger is a stub ports.AgentMessenger: durable writes and notifications -// work without it; only live agent nudges are absent until the runtime/agent -// nudge path is wired. -type noopMessenger struct{} - -func (noopMessenger) Send(context.Context, domain.SessionID, string) error { return nil } - // startSession builds the controller-facing session service: a session manager // over the real zellij runtime, a per-session gitworktree workspace, the shared -// store + LCM, and the per-session agent resolver (AO_AGENT default). The -// Messenger is a stub until the live agent-nudge path lands. The returned -// service is mounted at httpd APIDeps.Sessions. -func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, log *slog.Logger) (*sessionsvc.Service, error) { +// store + LCM, the per-session agent resolver (AO_AGENT default), and the +// agent messenger. The returned service is mounted at httpd APIDeps.Sessions. +func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, log *slog.Logger) (*sessionsvc.Service, error) { agents, err := buildAgentResolver(cfg.Agent, log) if err != nil { return nil, err @@ -76,13 +68,51 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, Agents: agents, Workspace: ws, Store: store, - Messenger: noopMessenger{}, + Messenger: messenger, Lifecycle: lcm, DataDir: cfg.DataDir, }) return sessionsvc.New(mgr, store), nil } +// runtimeMessageSender is the narrow part of the concrete runtime needed by +// ao send. zellij.Runtime already implements this via SendMessage. +type runtimeMessageSender interface { + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error +} + +// runtimeMessenger sends the user's message directly to the session's live +// runtime pane. The HTTP controller has already validated and sanitized the +// message body; this adapter only resolves the stored runtime handle. +type runtimeMessenger struct { + store *sqlite.Store + runtime runtimeMessageSender +} + +func (m runtimeMessenger) Send(ctx context.Context, id domain.SessionID, message string) error { + rec, ok, err := m.store.GetSession(ctx, id) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("session %s: %w", id, sessionmanager.ErrNotFound) + } + if rec.IsTerminated { + return fmt.Errorf("session %s: %w", id, sessionmanager.ErrTerminated) + } + handleID := rec.Metadata.RuntimeHandleID + if handleID == "" { + return fmt.Errorf("session %s: %w", id, sessionmanager.ErrIncompleteHandle) + } + return m.runtime.SendMessage(ctx, ports.RuntimeHandle{ID: handleID}, message) +} + +// newSessionMessenger assembles the per-daemon agent messenger. For now, ao +// send is intentionally minimal: submit the message to the live runtime pane. +func newSessionMessenger(store *sqlite.Store, runtime runtimeMessageSender, _ *slog.Logger) ports.AgentMessenger { + return runtimeMessenger{store: store, runtime: runtime} +} + // buildAgentRegistry returns a registry populated with the agent adapters the // daemon ships, keyed by manifest id. Registration only fails on an // empty/duplicate id — a programmer error, not a runtime condition. diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index d15f9deef4..0350c3737e 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -126,7 +126,9 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { lcm := lifecycle.New(store, nil) cfg := config.Config{DataDir: t.TempDir()} - svc, err := startSession(cfg, zellij.New(zellij.Options{}), store, lcm, log) + runtime := zellij.New(zellij.Options{}) + messenger := newSessionMessenger(store, runtime, log) + svc, err := startSession(cfg, runtime, store, lcm, messenger, log) if err != nil { t.Fatalf("startSession: %v", err) } @@ -135,6 +137,122 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { } } +type captureRuntimeSender struct { + handle ports.RuntimeHandle + message string +} + +func (c *captureRuntimeSender) SendMessage(_ context.Context, handle ports.RuntimeHandle, message string) error { + c.handle = handle + c.message = message + return nil +} + +// TestWiring_SessionMessengerSendsToRuntimePane asserts the daemon wires ao +// send to the live runtime pane and resolves the handle from the shared store. +func TestWiring_SessionMessengerSendsToRuntimePane(t *testing.T) { + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + runtime := &captureRuntimeSender{} + messenger := newSessionMessenger(store, runtime, nil) + + ctx := context.Background() + if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "p", Path: "/repo/p", RegisteredAt: time.Now()}); err != nil { + t.Fatal(err) + } + rec, err := store.CreateSession(ctx, domain.SessionRecord{ + ProjectID: "p", Kind: domain.KindWorker, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}, + Metadata: domain.SessionMetadata{RuntimeHandleID: "ao-1/terminal_0"}, + }) + if err != nil { + t.Fatal(err) + } + if err := messenger.Send(ctx, rec.ID, "hello agent"); err != nil { + t.Fatalf("messenger.Send: %v", err) + } + if runtime.handle.ID != "ao-1/terminal_0" { + t.Fatalf("handle = %q, want ao-1/terminal_0", runtime.handle.ID) + } + if runtime.message != "hello agent" { + t.Fatalf("message = %q, want hello agent", runtime.message) + } +} + +func TestWiring_SessionMessengerWrapsLookupErrors(t *testing.T) { + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + messenger := newSessionMessenger(store, &captureRuntimeSender{}, nil) + err = messenger.Send(context.Background(), "missing", "hello") + if !errors.Is(err, sessionmanager.ErrNotFound) { + t.Fatalf("missing session should wrap ErrNotFound, got %v", err) + } +} + +func TestWiring_SessionMessengerRequiresRuntimeHandle(t *testing.T) { + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + ctx := context.Background() + if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "p", Path: "/repo/p", RegisteredAt: time.Now()}); err != nil { + t.Fatal(err) + } + rec, err := store.CreateSession(ctx, domain.SessionRecord{ + ProjectID: "p", Kind: domain.KindWorker, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}, + }) + if err != nil { + t.Fatal(err) + } + messenger := newSessionMessenger(store, &captureRuntimeSender{}, nil) + err = messenger.Send(ctx, rec.ID, "hello") + if !errors.Is(err, sessionmanager.ErrIncompleteHandle) { + t.Fatalf("missing runtime handle should wrap ErrIncompleteHandle, got %v", err) + } +} + +func TestWiring_SessionMessengerRejectsTerminatedSession(t *testing.T) { + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + ctx := context.Background() + if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "p", Path: "/repo/p", RegisteredAt: time.Now()}); err != nil { + t.Fatal(err) + } + rec, err := store.CreateSession(ctx, domain.SessionRecord{ + ProjectID: "p", Kind: domain.KindWorker, + IsTerminated: true, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}, + Metadata: domain.SessionMetadata{RuntimeHandleID: "ao-1/terminal_0"}, + }) + if err != nil { + t.Fatal(err) + } + runtime := &captureRuntimeSender{} + messenger := newSessionMessenger(store, runtime, nil) + err = messenger.Send(ctx, rec.ID, "hello") + if !errors.Is(err, sessionmanager.ErrTerminated) { + t.Fatalf("terminated session should wrap ErrTerminated, got %v", err) + } + if runtime.handle.ID != "" || runtime.message != "" { + t.Fatalf("runtime should not be called for terminated sessions, got handle=%q message=%q", runtime.handle.ID, runtime.message) + } +} + // TestProjectRepoResolver_ResolvesRegisteredProject asserts the DB-backed repo // resolver turns a registered project into its on-disk repo path (so spawns // materialise a worktree), and fails loudly for an unregistered project. diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index bc88fd7216..632d31f44c 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -251,6 +251,8 @@ func writeSessionError(w http.ResponseWriter, r *http.Request, err error) { envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "SESSION_NOT_FOUND", "Unknown session", nil) case errors.Is(err, sessionmanager.ErrNotRestorable): envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_NOT_RESTORABLE", "Session is not restorable", nil) + case errors.Is(err, sessionmanager.ErrTerminated): + envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_TERMINATED", "Session is terminated", nil) case errors.Is(err, sessionmanager.ErrIncompleteHandle): envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_INCOMPLETE_HANDLE", "Session is missing runtime or workspace handles", nil) case errors.Is(err, sessionmanager.ErrProjectNotResolvable): diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index fcf4277f7d..7c8d823319 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -16,6 +16,7 @@ import ( var ( ErrNotFound = errors.New("session: not found") ErrNotRestorable = errors.New("session: not restorable (not terminal)") + ErrTerminated = errors.New("session: terminated") ErrIncompleteHandle = errors.New("session: incomplete teardown handle") // ErrProjectNotResolvable means the spawn's project has no usable repo // (unregistered, archived, or missing a path). The API maps it to a 400. From 90580174392f88b606e3ac06c212e23d0fb14c20 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 2 Jun 2026 21:39:21 +0530 Subject: [PATCH 105/250] fix: prefix ao send messages with sender session (#85) * fix: prefix ao send messages with sender session * feat: add orchestrator-aware spawn prompts * fix(session): return first active orchestrator --- backend/internal/cli/send.go | 4 ++ backend/internal/cli/send_test.go | 51 ++++++++++++++ backend/internal/session_manager/manager.go | 70 ++++++++++++++++++- .../internal/session_manager/manager_test.go | 57 +++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) diff --git a/backend/internal/cli/send.go b/backend/internal/cli/send.go index e1ecbcd567..d57945a24a 100644 --- a/backend/internal/cli/send.go +++ b/backend/internal/cli/send.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/url" + "os" "strings" "github.com/spf13/cobra" @@ -41,6 +42,9 @@ func (c *commandContext) sendMessage(ctx context.Context, opts sendOptions) erro return usageError{errors.New("usage: --message is required")} } message := opts.message + if sender := strings.TrimSpace(os.Getenv("AO_SESSION_ID")); sender != "" { + message = "[from " + sender + "] " + message + } session := strings.TrimSpace(opts.session) if session == "" { return usageError{errors.New("usage: --session is required")} diff --git a/backend/internal/cli/send_test.go b/backend/internal/cli/send_test.go index a68293ba62..3a23906523 100644 --- a/backend/internal/cli/send_test.go +++ b/backend/internal/cli/send_test.go @@ -58,6 +58,7 @@ func sendServer(t *testing.T, status int, respBody string) (*httptest.Server, *s } func TestSend_Success(t *testing.T) { + t.Setenv("AO_SESSION_ID", "") cfg := setConfigEnv(t) srv, capture := sendServer(t, http.StatusOK, `{"ok":true,"sessionId":"demo-1","message":"hello agent"}`) @@ -83,7 +84,57 @@ func TestSend_Success(t *testing.T) { } } +func TestSend_PrefixesMessageWithSenderSessionID(t *testing.T) { + t.Setenv("AO_SESSION_ID", "aa-47") + cfg := setConfigEnv(t) + srv, capture := sendServer(t, http.StatusOK, + `{"ok":true,"sessionId":"demo-1","message":"hi"}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "send", "--session", "demo-1", "--message", " hi ") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + var req struct { + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(capture.body), &req); err != nil { + t.Fatalf("decode body: %v\nbody=%s", err, capture.body) + } + want := "[from aa-47] hi " + if req.Message != want { + t.Errorf("captured message = %q, want %q", req.Message, want) + } +} + +func TestSend_BlankSenderSessionIDDoesNotPrefixMessage(t *testing.T) { + t.Setenv("AO_SESSION_ID", " \t ") + cfg := setConfigEnv(t) + srv, capture := sendServer(t, http.StatusOK, + `{"ok":true,"sessionId":"demo-1","message":"hello agent"}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "send", "--session", "demo-1", "--message", "hello agent") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + var req struct { + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(capture.body), &req); err != nil { + t.Fatalf("decode body: %v\nbody=%s", err, capture.body) + } + if req.Message != "hello agent" { + t.Errorf("captured message = %q, want %q", req.Message, "hello agent") + } +} + func TestSend_PreservesMessageWhitespace(t *testing.T) { + t.Setenv("AO_SESSION_ID", "") cfg := setConfigEnv(t) srv, capture := sendServer(t, http.StatusOK, `{"ok":true,"sessionId":"demo-1","message":"hi"}`) writeRunFileFor(t, cfg, srv) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 7c8d823319..ba9e84477a 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -99,6 +99,11 @@ func New(d Deps) *Manager { // workspace and runtime, then reports completion to the LCM. A failure after the // row exists parks it as terminated and rolls back what was built. func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) { + prompt, err := m.buildSpawnPrompt(ctx, cfg) + if err != nil { + return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err) + } + rec, err := m.store.CreateSession(ctx, seedRecord(cfg, m.clock())) if err != nil { return domain.SessionRecord{}, fmt.Errorf("spawn: create: %w", err) @@ -118,7 +123,6 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err) } - prompt := buildPrompt(cfg) agent, ok := m.agents.Agent(cfg.Harness) if !ok { _ = m.workspace.Destroy(ctx, ws) @@ -321,6 +325,70 @@ func buildPrompt(cfg ports.SpawnConfig) string { } } +func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig) (string, error) { + prompt := buildPrompt(cfg) + switch cfg.Kind { + case domain.KindOrchestrator: + return appendPromptSection(orchestratorPrompt(cfg.ProjectID), prompt), nil + case domain.KindWorker: + orchestratorID, ok, err := m.activeOrchestratorSessionID(ctx, cfg.ProjectID) + if err != nil { + return "", err + } + if ok { + prompt = appendPromptSection(prompt, workerOrchestratorPrompt(orchestratorID)) + } + } + return prompt, nil +} + +func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) { + recs, err := m.store.ListSessions(ctx, project) + if err != nil { + return "", false, fmt.Errorf("list sessions for %s: %w", project, err) + } + for _, rec := range recs { + if rec.Kind == domain.KindOrchestrator && !rec.IsTerminated { + return rec.ID, true, nil + } + } + return "", false, nil +} + +func orchestratorPrompt(project domain.ProjectID) string { + return fmt.Sprintf(`## Orchestrator role + +You are the human-facing coordinator for project %s. Coordinate work for the human, keep the project moving, and avoid doing implementation yourself unless it is necessary. + +Spawn worker sessions for implementation with: +`+"`ao spawn --project %s --prompt \"\"`"+` + +Message workers with `+"`ao send`"+`, for example: +`+"`ao send --session --message \"\"`"+` + +Use workers for focused implementation tasks, track their progress, synthesize their results, and only step into implementation directly for true emergencies or small coordination fixes.`, project, project) +} + +func workerOrchestratorPrompt(orchestratorID domain.SessionID) string { + return fmt.Sprintf(`## Orchestrator coordination + +An active orchestrator session exists for this project. If you hit a true blocker or need cross-session coordination, message it with: +`+"`ao send --session %s --message \"\"`"+` + +Only ping the orchestrator for true blockers, cross-session coordination, or decisions that cannot be resolved within your own task.`, orchestratorID) +} + +func appendPromptSection(prompt, section string) string { + switch { + case prompt == "": + return section + case section == "": + return prompt + default: + return prompt + "\n\n" + section + } +} + func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir string) map[string]string { return map[string]string{ EnvSessionID: string(id), diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 78900d4738..be2ee60e1b 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "testing" "time" @@ -258,6 +259,62 @@ func TestSpawn_DefaultsBranchFromSessionID(t *testing.T) { } } +func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) { + m, st, _, _ := newManager() + st.num = 1 + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator} + + s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"}) + if err != nil { + t.Fatal(err) + } + prompt := st.sessions[s.ID].Metadata.Prompt + for _, want := range []string{ + "do it", + "## Orchestrator coordination", + `ao send --session mer-1 --message ""`, + "Only ping the orchestrator for true blockers, cross-session coordination", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt missing %q:\n%s", want, prompt) + } + } +} + +func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) { + m, st, _, _ := newManager() + st.num = 1 + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true} + + s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"}) + if err != nil { + t.Fatal(err) + } + prompt := st.sessions[s.ID].Metadata.Prompt + if strings.Contains(prompt, "## Orchestrator coordination") || strings.Contains(prompt, "ao send --session mer-1") { + t.Fatalf("terminated orchestrator should not be added to prompt:\n%s", prompt) + } +} + +func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) { + m, st, _, _ := newManager() + s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}) + if err != nil { + t.Fatal(err) + } + prompt := st.sessions[s.ID].Metadata.Prompt + for _, want := range []string{ + "You are the human-facing coordinator for project mer", + `ao spawn --project mer --prompt ""`, + "`ao send`", + "avoid doing implementation yourself unless it is necessary", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt missing %q:\n%s", want, prompt) + } + } +} + func TestSpawn_KeepsExplicitBranch(t *testing.T) { m, st, _, _ := newManager() s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "feature/x"}) From 718a1b135c0fd39cc59cde839a71c17e9f8be86a Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 3 Jun 2026 04:24:04 +0530 Subject: [PATCH 106/250] docs: add AGENTS.md (#93) * docs: add AGENTS.md * docs: address AGENTS review --- AGENTS.md | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 3 ++ 2 files changed, 98 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..3cf976e705 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# AGENTS.md + +Operational guidance for coding agents working in this repository. Keep changes small, match the current rewrite architecture, and prefer the documented daemon/API boundaries over behavior from the old TypeScript implementation. + +## Repo layout + +- `backend/` — Go rewrite of Agent Orchestrator: Cobra `ao` CLI, loopback HTTP daemon, services, SQLite storage, lifecycle/reaper, runtime/workspace/agent/tracker adapters, terminal mux, and tests. +- `frontend/` — placeholder Electron + TypeScript shell. Treat it as a thin supervisor/UI surface; do not move daemon logic into it. +- `docs/` — current architecture/status notes. Start here before changing lifecycle, CLI, agents, storage, or daemon behavior. +- `test/` — external smoke/e2e assets, including the CLI fresh-install container check. +- `.github/workflows/` — CI definitions. Mirror these commands locally when possible. + +## Commands + +From the repo root unless noted: + +```bash +npm run lint # backend go test ./... + golangci-lint v2.12.2 +npm run frontend:typecheck # frontend TypeScript check +npm run sqlc # regenerate backend/internal/storage/sqlite/gen from queries/schema +npx @redwoodjs/agent-ci run --all # local workflow validation; requires Docker socket +``` + +Backend-specific checks: + +```bash +cd backend +go build ./... +go test ./... +go test -race ./... +go vet ./... +go run ./cmd/ao start +``` + +Frontend-specific checks: + +```bash +cd frontend +npm run typecheck +npm run build +``` + +## Where to look first + +- `README.md` — current run/config/test quickstart. +- `docs/README.md` — docs index. +- `docs/architecture.md` — backend mental model, package layout, lifecycle/session/service boundaries, and load-bearing rules. +- `docs/status.md` — current implementation state and next integration work. +- `docs/cli/README.md` — intended CLI shape: thin Cobra client over daemon HTTP, never direct storage/runtime access. +- `docs/agent/README.md` — agent adapter contract and hook behavior. +- `CLAUDE.md` — compatibility pointer for Claude Code; it directs agents back to `AGENTS.md`. + +For code entry points: + +- CLI commands: `backend/internal/cli/*.go`; follow nearby command/test patterns before adding a new style. +- HTTP controllers and DTOs: `backend/internal/httpd/controllers/`. +- Service read/write boundaries: `backend/internal/service/`. +- Domain vocabulary: `backend/internal/domain/`. +- Port contracts: `backend/internal/ports/`. +- SQLite queries/migrations/store: `backend/internal/storage/sqlite/`. +- Generated sqlc code: `backend/internal/storage/sqlite/gen/`. + +## Coding conventions + +- Keep every change surgical and directly tied to the task. Avoid drive-by cleanup, broad renames, formatting churn, speculative abstractions, and architectural refactors unless the task explicitly asks for them. +- Follow existing Go package boundaries. CLI code should call daemon HTTP routes through shared CLI client helpers; it should not open SQLite, spawn runtimes, or call adapters directly. +- Keep Cobra commands in the relevant command file and table-test them in the style of `backend/internal/cli/*_test.go`. +- Mirror existing response/request DTOs in the CLI instead of importing HTTP controller packages into CLI code, unless the package already establishes that dependency. +- Return usage errors as `usageError` so CLI misuse exits 2; runtime/daemon failures should exit 1. +- Preserve API error envelopes and request IDs when surfacing daemon errors. +- Use `context.Context` as the first argument for functions that do I/O or blocking work. +- Do not add abstractions for one-off use cases. Add helpers only when they remove duplication across real call sites. +- Tests should cover the user-visible behavior and boundary being changed: happy path, validation/missing args, daemon error envelopes, and any destructive confirmation path. + +## Hard rules and boundaries + +- The daemon is a loopback-only sidecar. Do not make the bind host configurable or expose it beyond `127.0.0.1`. +- The CLI is a thin client. Do not port old in-process TypeScript CLI behavior that bypasses daemon HTTP routes. +- Do not store derived/display session status. Status is derived from durable facts (`activity_state`, `is_terminated`, PR/check/comment facts) at service read time. +- Do not treat failed/unknown runtime probes as proof a session is dead. +- Do not force-delete dirty registered worktrees. +- Do not modify already-merged SQLite migrations. Add a new migration instead. +- Do not hand-edit `backend/internal/storage/sqlite/gen/*`; change `backend/internal/storage/sqlite/queries/*` or migrations and run `npm run sqlc`. +- SQLite change events come from DB triggers into `change_log`; do not add parallel manual CDC emission from store methods unless the architecture changes explicitly. +- Keep generated OpenAPI/API DTO drift in mind: controller response shapes live in `backend/internal/httpd/controllers/dto.go` and tests may assert CLI/HTTP wire compatibility. +- Do not add network calls to tests unless the package already has an integration/e2e pattern for them. Prefer `httptest`, fakes, and injected dependencies. +- Do not commit local run state, daemon data, temporary worktrees, build outputs, or credentials. + +## PR hygiene + +- Branch from `main` unless explicitly continuing an existing PR. +- Keep one issue per PR. If asked for separate work, create a separate branch and PR. +- Use conventional commit messages (`feat:`, `fix:`, `docs:`, `test:`, `chore:`). +- Explain intentional omissions in the PR body, especially when the TypeScript original had more behavior than the Go rewrite domain currently supports. +- Run the narrowest relevant tests first, then the repo/CI commands that match the touched area. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..5a7ef0f13b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE.md + +Read and follow [`AGENTS.md`](AGENTS.md) for repository layout, commands, coding conventions, and hard rules. From fab5451a9f5b9e96fcd620e37cacfb7ead23936e Mon Sep 17 00:00:00 2001 From: neversettle <41864816+neversettle17-101@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:43:50 +0530 Subject: [PATCH 107/250] =?UTF-8?q?feat(api):=20PR=20action=20routes=20?= =?UTF-8?q?=E2=80=94=20merge=20+=20resolve-comments=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): register PR action route shells (merge + resolve-comments) Adds two 501 Not Implemented route shells for the SCM/PR action lane as specified in issue #21. No business logic — the routes are stubs that return a structured planned body with the embedded OpenAPI spec slice, consistent with the existing route-shell pattern. Routes registered: POST /api/v1/prs/{id}/merge POST /api/v1/prs/{id}/resolve-comments Closes part of #18. Co-Authored-By: Claude Sonnet 4.6 * feat(api): PR action routes — full impl (merge + resolve-comments) Builds the two SCM/PR action routes end-to-end per issue #21: POST /api/v1/prs/{id}/merge POST /api/v1/prs/{id}/resolve-comments **ports/scm.go** — new PRService interface, MergeResult, ResolveResult. **adapters/scm/github** — adds ErrNotMergeable/ErrUnprocessable sentinels to the client (405/409/422 classification) and MergePR / ListUnresolvedThreadIDs / ResolveThread methods to the Provider. **internal/scm/pr_service.go** — concrete PRService over PRProvider. Parses the path ID as a PR number, calls the provider, maps github sentinel errors to domain errors (ErrPRNotFound / ErrPRNotMergeable / ErrPRPreconditions / ErrNothingToResolve). Nil PRService keeps routes registered but returns OpenAPI-backed 501s. **httpd/controllers/prs.go** — real handlers; writePRError maps the four domain errors to 404 / 409 / 422 / 500. **prs_test.go** — httptest coverage: 501 (nil service), 200/404/409/422 for both routes, spec-slice present in 501 body. **scm/pr_service_test.go** — table-driven unit tests with a fake PRProvider. Closes part of #18. Closes #21. Co-Authored-By: Claude Sonnet 4.6 * feat(api): PR action routes — merge + resolve-comments (#21) Implements POST /api/v1/prs/{id}/merge and POST /api/v1/prs/{id}/resolve-comments. Service logic lives in internal/service/pr (ActionManager interface + ActionService struct). Controllers use the projects pattern — import the service package directly rather than going through a ports interface. Drops the internal/scm intermediary package and the ports/scm.go file added in earlier iterations. Also fixes the ContentLength-based body-decode guard in resolveComments, which silently dropped JSON bodies sent with chunked transfer encoding; now decodes unconditionally and treats io.EOF as an absent body. Closes #21. Co-Authored-By: Claude Sonnet 4.6 * fix(specgen): remove dead path-param entries from schemaNames ControllersProjectIDParam, ControllersSessionIDParam, and ControllersPRIDParam are never matched by the schemaName interceptor — swaggest reflects path-param structs inline rather than as $ref component schemas, so the hook is never called for these types. Co-Authored-By: Claude Sonnet 4.6 * fix(pr): anchor resolve-comments to stated PR when explicit IDs supplied When commentIDs were provided, the parsed PR number was never used — any thread ID could be resolved regardless of which PR was in the URL path. Add a ListUnresolvedThreadIDs existence probe in the else branch so the PR must be reachable before iterating the caller-supplied IDs. Co-Authored-By: Claude Sonnet 4.6 * fix(controllers): exclude io.ErrUnexpectedEOF from isEmptyBody A truncated body (e.g. {"commentIds":["T_1") returns io.ErrUnexpectedEOF, not io.EOF. Treating it as an absent body caused the handler to fall through to "resolve all unresolved threads" instead of returning 400. Only io.EOF (reader returned no bytes) is a genuine empty-body signal. Co-Authored-By: Claude Sonnet 4.6 * refactor(api): revert to route shell — stubs, no adapter changes - Remove adapter changes (ErrNotMergeable/ErrUnprocessable from client.go, MergePR/ListUnresolvedThreadIDs/ResolveThread from provider.go) - ActionService returns dummy values with TODO; no business logic - Errors (ErrPRNotFound etc.) moved to controllers/errors.go - PR DTOs moved to controllers/dto.go - Remove 501 guards — stub service always wired via NewAPI default Co-Authored-By: Claude Sonnet 4.6 * fix: restore client.go, move PR errors to service/pr, fix lint - Restore original client.go alignment (no functional change) - Move PR sentinel errors to service/pr/errors.go - controllers/errors.go now only contains writePRError, referencing prsvc sentinels - Fix schemaNames alignment in specgen/build.go (goimports lint) Co-Authored-By: Claude Sonnet 4.6 * fix(api): replace fake-success stub with 503 when SCM not configured The nil-service fallback was silently injecting a stub that returned fake merge/resolve success, misleading callers when no SCM is wired. Remove the injection; nil Svc now returns 503 SCM_NOT_CONFIGURED. Also inline writePRError into prs.go and delete controllers/errors.go. Co-Authored-By: Claude Sonnet 4.6 * fix(specgen): mark resolve-comments request body as optional reqBody: nil removes the requestBody.required: true annotation so generated SDK clients can omit the body (which resolves all threads). Co-Authored-By: Claude Sonnet 4.6 * fix(prs): align nil-service guard with spec (501) and echo prID in stub Use apispec.NotImplemented (501) instead of 503 so nil-service responses match the OpenAPI spec and generated clients hit the documented 501 branch. Echo prID as PRNumber in the stub Merge to avoid claiming the wrong PR was merged if NewActionService is wired by accident before real impl lands. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- backend/internal/httpd/api.go | 10 +- backend/internal/httpd/apispec/openapi.yaml | 109 ++++++++++++ .../internal/httpd/apispec/specgen/build.go | 41 ++++- backend/internal/httpd/controllers/dto.go | 23 +++ backend/internal/httpd/controllers/prs.go | 83 +++++++++ .../internal/httpd/controllers/prs_test.go | 159 ++++++++++++++++++ backend/internal/service/pr/action_service.go | 46 +++++ .../service/pr/action_service_test.go | 28 +++ backend/internal/service/pr/errors.go | 11 ++ 9 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 backend/internal/httpd/controllers/prs.go create mode 100644 backend/internal/httpd/controllers/prs_test.go create mode 100644 backend/internal/service/pr/action_service.go create mode 100644 backend/internal/service/pr/action_service_test.go create mode 100644 backend/internal/service/pr/errors.go diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index b174197278..f04cf20cbe 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -10,16 +10,15 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + prsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/pr" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) -// APIDeps bundles every Manager the API layer's controllers depend on. -// Controllers see only resource-level interfaces; they do not reach through to -// lifecycle reducers, adapters, or storage. A nil dependency keeps its routes -// registered but returns the OpenAPI-backed 501 response. +// APIDeps bundles every service the API layer's controllers depend on. type APIDeps struct { Projects projectsvc.Manager Sessions controllers.SessionService + PRs prsvc.ActionManager } // API owns one controller per resource and is the single Register call the @@ -28,6 +27,7 @@ type API struct { cfg config.Config projects *controllers.ProjectsController sessions *controllers.SessionsController + prs *controllers.PRsController } // NewAPI constructs the API surface from its dependencies. cfg carries the @@ -42,6 +42,7 @@ func NewAPI(cfg config.Config, deps APIDeps) *API { sessions: &controllers.SessionsController{ Svc: deps.Sessions, }, + prs: &controllers.PRsController{Svc: deps.PRs}, } } @@ -61,6 +62,7 @@ func (a *API) Register(root chi.Router) { r.Use(middleware.Timeout(timeout)) a.projects.Register(r) a.sessions.Register(r) + a.prs.Register(r) // Sibling REST controllers plug in here. }) // Surfaces that intentionally bypass the REST timeout register at this level. diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index c121ffe49b..0439d89f90 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -177,6 +177,90 @@ paths: summary: Fetch one project; discriminates ok vs degraded tags: - projects + /api/v1/prs/{id}/merge: + post: + operationId: mergePR + parameters: + - description: PR number. + in: path + name: id + required: true + schema: + description: PR number. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/MergePRResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Unprocessable Entity + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Squash-merge a pull request + tags: + - prs + /api/v1/prs/{id}/resolve-comments: + post: + operationId: resolveComments + parameters: + - description: PR number. + in: path + name: id + required: true + schema: + description: PR number. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveCommentsResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Unprocessable Entity + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Resolve review threads on a pull request + tags: + - prs /api/v1/sessions: get: operationId: listSessions @@ -563,6 +647,19 @@ components: required: - sessions type: object + MergePRResponse: + properties: + method: + type: string + ok: + type: boolean + prNumber: + type: integer + required: + - ok + - prNumber + - method + type: object OrchestratorResponse: properties: id: @@ -658,6 +755,16 @@ components: required: - displayName type: object + ResolveCommentsResponse: + properties: + ok: + type: boolean + resolved: + type: integer + required: + - ok + - resolved + type: object RestoreSessionResponse: properties: ok: @@ -822,3 +929,5 @@ tags: name: projects - description: Agent session lifecycle and messaging name: sessions +- description: Pull-request actions (SCM lane) + name: prs diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index f3cc8f435b..406d41b814 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -59,6 +59,8 @@ func Build() ([]byte, error) { "Project registry, configuration, and lifecycle administration"), *(&openapi31.Tag{Name: "sessions"}).WithDescription( "Agent session lifecycle and messaging"), + *(&openapi31.Tag{Name: "prs"}).WithDescription( + "Pull-request actions (SCM lane)"), } for _, op := range operations() { @@ -119,8 +121,6 @@ var schemaNames = map[string]string{ "ControllersProjectResponse": "ProjectResponse", "ControllersGetProjectResponse": "ProjectGetResponse", "ControllersProjectOrDegraded": "ProjectOrDegraded", - "ControllersProjectIDParam": "ProjectIDParam", - "ControllersSessionIDParam": "SessionIDParam", "ControllersListSessionsQuery": "ListSessionsQuery", "ControllersListSessionsResponse": "ListSessionsResponse", "ControllersSpawnSessionRequest": "SpawnSessionRequest", @@ -133,6 +133,10 @@ var schemaNames = map[string]string{ "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", "ControllersOrchestratorResponse": "OrchestratorResponse", + // httpd/controllers — PR wire envelopes + "ControllersMergePRResponse": "MergePRResponse", + "ControllersResolveCommentsRequest": "ResolveCommentsRequest", + "ControllersResolveCommentsResponse": "ResolveCommentsResponse", // service/project entities + DTOs "ProjectProject": "Project", "ProjectSummary": "ProjectSummary", @@ -216,6 +220,7 @@ type operation struct { func operations() []operation { ops := append([]operation{}, projectOperations()...) ops = append(ops, sessionOperations()...) + ops = append(ops, prOperations()...) return ops } @@ -360,3 +365,35 @@ func sessionOperations() []operation { }, } } + +// prOperations declares the PR action operations. These live in the SCM lane: +// the handler delegates to a PRService backed by the SCM provider. A nil +// PRService (SCM not configured) returns 501 for both routes. +func prOperations() []operation { + return []operation{ + { + method: http.MethodPost, path: "/api/v1/prs/{id}/merge", id: "mergePR", tag: "prs", + summary: "Squash-merge a pull request", + pathParams: []any{controllers.PRIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.MergePRResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusUnprocessableEntity, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/prs/{id}/resolve-comments", id: "resolveComments", tag: "prs", + summary: "Resolve review threads on a pull request", + pathParams: []any{controllers.PRIDParam{}}, + reqBody: nil, // body is optional: omitting it resolves all unresolved threads + resps: []respUnit{ + {http.StatusOK, controllers.ResolveCommentsResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusUnprocessableEntity, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 22d8f42e75..8efd581cdd 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -174,3 +174,26 @@ type OrchestratorResponse struct { ProjectID domain.ProjectID `json:"projectId"` ProjectName string `json:"projectName,omitempty"` } + +// PRIDParam is the {id} path parameter shared by the /prs/{id} routes. +type PRIDParam struct { + ID string `path:"id" description:"PR number."` +} + +// MergePRResponse is the body of POST /api/v1/prs/{id}/merge (200). +type MergePRResponse struct { + OK bool `json:"ok"` + PRNumber int `json:"prNumber"` + Method string `json:"method"` +} + +// ResolveCommentsRequest is the optional body of POST /api/v1/prs/{id}/resolve-comments. +type ResolveCommentsRequest struct { + CommentIDs []string `json:"commentIds,omitempty"` +} + +// ResolveCommentsResponse is the body of POST /api/v1/prs/{id}/resolve-comments (200). +type ResolveCommentsResponse struct { + OK bool `json:"ok"` + Resolved int `json:"resolved"` +} diff --git a/backend/internal/httpd/controllers/prs.go b/backend/internal/httpd/controllers/prs.go new file mode 100644 index 0000000000..94a9f9c325 --- /dev/null +++ b/backend/internal/httpd/controllers/prs.go @@ -0,0 +1,83 @@ +package controllers + +import ( + "errors" + "io" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + prsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/pr" +) + +// PRsController owns the /prs action routes. +type PRsController struct { + Svc prsvc.ActionManager +} + +// Register mounts the PR action routes on the supplied router. +func (c *PRsController) Register(r chi.Router) { + r.Post("/prs/{id}/merge", c.merge) + r.Post("/prs/{id}/resolve-comments", c.resolveComments) +} + +func (c *PRsController) merge(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/prs/{id}/merge") + return + } + prID := chi.URLParam(r, "id") + res, err := c.Svc.Merge(r.Context(), prID) + if err != nil { + writePRError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, MergePRResponse{OK: true, PRNumber: res.PRNumber, Method: res.Method}) +} + +func (c *PRsController) resolveComments(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/prs/{id}/resolve-comments") + return + } + prID := chi.URLParam(r, "id") + + // Body is optional: omitting it resolves all unresolved threads. + var in ResolveCommentsRequest + if err := decodeJSON(r, &in); err != nil && !isEmptyBody(err) { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + + res, err := c.Svc.ResolveComments(r.Context(), prID, in.CommentIDs) + if err != nil { + writePRError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, ResolveCommentsResponse{OK: true, Resolved: res.Resolved}) +} + +// writePRError maps PR sentinel errors to their locked HTTP envelopes, +// falling back to 500 for unexpected failures. +func writePRError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, prsvc.ErrPRNotFound): + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PR_NOT_FOUND", "Unknown PR", nil) + case errors.Is(err, prsvc.ErrPRNotMergeable): + envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "PR_NOT_MERGEABLE", "PR is not mergeable", nil) + case errors.Is(err, prsvc.ErrPRPreconditions): + envelope.WriteAPIError(w, r, http.StatusUnprocessableEntity, "unprocessable", "PR_PRECONDITIONS_UNMET", "PR merge preconditions are not met", nil) + case errors.Is(err, prsvc.ErrNothingToResolve): + envelope.WriteAPIError(w, r, http.StatusUnprocessableEntity, "unprocessable", "NOTHING_TO_RESOLVE", "No unresolved review threads to resolve", nil) + default: + envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "PR_OPERATION_FAILED", "PR operation failed", nil) + } +} + +// isEmptyBody reports whether err signals an absent or empty request body. +// io.ErrUnexpectedEOF means a truncated/malformed body — bad request, not absent. +func isEmptyBody(err error) bool { + return errors.Is(err, io.EOF) +} diff --git a/backend/internal/httpd/controllers/prs_test.go b/backend/internal/httpd/controllers/prs_test.go new file mode 100644 index 0000000000..7d255b9817 --- /dev/null +++ b/backend/internal/httpd/controllers/prs_test.go @@ -0,0 +1,159 @@ +package controllers_test + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + prsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/pr" +) + +type fakePRService struct { + mergeResult prsvc.MergeResult + mergeErr error + resolveResult prsvc.ResolveResult + resolveErr error +} + +func (f *fakePRService) Merge(_ context.Context, _ string) (prsvc.MergeResult, error) { + return f.mergeResult, f.mergeErr +} + +func (f *fakePRService) ResolveComments(_ context.Context, _ string, _ []string) (prsvc.ResolveResult, error) { + return f.resolveResult, f.resolveErr +} + +func newPRTestServer(t *testing.T, svc prsvc.ActionManager) *httptest.Server { + t.Helper() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{PRs: svc})) + t.Cleanup(srv.Close) + return srv +} + +// ---- Nil service → 503 SCM_NOT_CONFIGURED ---- + +func TestPRsRoutes_NilService_MergeReturns501(t *testing.T) { + srv := newPRTestServer(t, nil) + body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/1/merge", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") +} + +func TestPRsRoutes_NilService_ResolveCommentsReturns501(t *testing.T) { + srv := newPRTestServer(t, nil) + body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/1/resolve-comments", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") +} + +// ---- Merge: 200 ---- + +func TestPRsRoutes_Merge_200(t *testing.T) { + svc := &fakePRService{mergeResult: prsvc.MergeResult{PRNumber: 42, Method: "squash"}} + srv := newPRTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/prs/42/merge", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", status, body) + } + var resp struct { + OK bool `json:"ok"` + PRNumber int `json:"prNumber"` + Method string `json:"method"` + } + mustJSON(t, body, &resp) + if !resp.OK || resp.PRNumber != 42 || resp.Method != "squash" { + t.Errorf("resp = %+v, want {ok:true prNumber:42 method:squash}", resp) + } +} + +// ---- Merge: 404 ---- + +func TestPRsRoutes_Merge_404(t *testing.T) { + svc := &fakePRService{mergeErr: prsvc.ErrPRNotFound} + srv := newPRTestServer(t, svc) + + body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/99/merge", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotFound, "PR_NOT_FOUND") +} + +// ---- Merge: 409 ---- + +func TestPRsRoutes_Merge_409(t *testing.T) { + svc := &fakePRService{mergeErr: prsvc.ErrPRNotMergeable} + srv := newPRTestServer(t, svc) + + body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/1/merge", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusConflict, "PR_NOT_MERGEABLE") +} + +// ---- Merge: 422 ---- + +func TestPRsRoutes_Merge_422(t *testing.T) { + svc := &fakePRService{mergeErr: prsvc.ErrPRPreconditions} + srv := newPRTestServer(t, svc) + + body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/1/merge", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusUnprocessableEntity, "PR_PRECONDITIONS_UNMET") +} + +// ---- ResolveComments: 200 ---- + +func TestPRsRoutes_ResolveComments_200(t *testing.T) { + svc := &fakePRService{resolveResult: prsvc.ResolveResult{Resolved: 3}} + srv := newPRTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/prs/42/resolve-comments", `{"commentIds":["T_1","T_2","T_3"]}`) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", status, body) + } + var resp struct { + OK bool `json:"ok"` + Resolved int `json:"resolved"` + } + mustJSON(t, body, &resp) + if !resp.OK || resp.Resolved != 3 { + t.Errorf("resp = %+v, want {ok:true resolved:3}", resp) + } +} + +func TestPRsRoutes_ResolveComments_200_NoBody(t *testing.T) { + svc := &fakePRService{resolveResult: prsvc.ResolveResult{Resolved: 2}} + srv := newPRTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/prs/42/resolve-comments", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", status, body) + } +} + +// ---- ResolveComments: 404 ---- + +func TestPRsRoutes_ResolveComments_404(t *testing.T) { + svc := &fakePRService{resolveErr: prsvc.ErrPRNotFound} + srv := newPRTestServer(t, svc) + + body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/99/resolve-comments", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusNotFound, "PR_NOT_FOUND") +} + +// ---- ResolveComments: 422 ---- + +func TestPRsRoutes_ResolveComments_422(t *testing.T) { + svc := &fakePRService{resolveErr: prsvc.ErrNothingToResolve} + srv := newPRTestServer(t, svc) + + body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/1/resolve-comments", "") + assertJSON(t, headers) + assertErrorCode(t, body, status, http.StatusUnprocessableEntity, "NOTHING_TO_RESOLVE") +} diff --git a/backend/internal/service/pr/action_service.go b/backend/internal/service/pr/action_service.go new file mode 100644 index 0000000000..c79ebe954d --- /dev/null +++ b/backend/internal/service/pr/action_service.go @@ -0,0 +1,46 @@ +package pr + +import ( + "context" + "strconv" +) + +// ActionManager is the controller-facing contract for /prs/{id} action routes. +type ActionManager interface { + Merge(ctx context.Context, prID string) (MergeResult, error) + ResolveComments(ctx context.Context, prID string, commentIDs []string) (ResolveResult, error) +} + +// MergeResult is the successful outcome of a PR merge. +type MergeResult struct { + PRNumber int + Method string // always "squash" +} + +// ResolveResult is the successful outcome of a resolve-comments operation. +type ResolveResult struct { + Resolved int +} + +// ActionService implements ActionManager. Business logic is not yet implemented. +type ActionService struct{} + +var _ ActionManager = (*ActionService)(nil) + +// NewActionService returns a stub ActionService. +func NewActionService() *ActionService { + return &ActionService{} +} + +// Merge squash-merges the PR identified by prID. +// TODO: implement — squash-merge the PR via the SCM provider. +func (s *ActionService) Merge(_ context.Context, prID string) (MergeResult, error) { + n, _ := strconv.Atoi(prID) + return MergeResult{PRNumber: n, Method: "squash"}, nil +} + +// ResolveComments resolves review threads on the PR identified by prID. +// TODO: implement — resolve review threads via the SCM provider. +func (s *ActionService) ResolveComments(_ context.Context, _ string, _ []string) (ResolveResult, error) { + return ResolveResult{Resolved: 0}, nil +} diff --git a/backend/internal/service/pr/action_service_test.go b/backend/internal/service/pr/action_service_test.go new file mode 100644 index 0000000000..1eb3d5f708 --- /dev/null +++ b/backend/internal/service/pr/action_service_test.go @@ -0,0 +1,28 @@ +package pr + +import ( + "context" + "testing" +) + +func TestMerge_ReturnsSquash(t *testing.T) { + svc := NewActionService() + res, err := svc.Merge(context.Background(), "42") + if err != nil { + t.Fatal(err) + } + if res.Method != "squash" { + t.Errorf("method = %q, want squash", res.Method) + } + if res.PRNumber != 42 { + t.Errorf("PRNumber = %d, want 42", res.PRNumber) + } +} + +func TestResolveComments_ReturnsOK(t *testing.T) { + svc := NewActionService() + _, err := svc.ResolveComments(context.Background(), "1", nil) + if err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/service/pr/errors.go b/backend/internal/service/pr/errors.go new file mode 100644 index 0000000000..ed54504f8e --- /dev/null +++ b/backend/internal/service/pr/errors.go @@ -0,0 +1,11 @@ +package pr + +import "errors" + +// Sentinel errors returned by the PR action service. +var ( + ErrPRNotFound = errors.New("pr: not found") + ErrPRNotMergeable = errors.New("pr: not mergeable") + ErrPRPreconditions = errors.New("pr: merge preconditions unmet") + ErrNothingToResolve = errors.New("pr: nothing to resolve") +) From ae9fa0e3414cfca484efe2c5b175bb6903df80f1 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 3 Jun 2026 04:46:55 +0530 Subject: [PATCH 108/250] feat(cli): add ao project ls/get/rm (#90) (#91) * feat(cli): add project ls get rm * fix(cli): satisfy project confirmation lint * chore(ci): remove agent-ci dockerfile * test(cli): cover project json output * fix(cli): label project agent as default harness --- backend/internal/cli/client.go | 40 +++- backend/internal/cli/project.go | 233 +++++++++++++++++++- backend/internal/cli/project_test.go | 312 +++++++++++++++++++++++++++ 3 files changed, 575 insertions(+), 10 deletions(-) create mode 100644 backend/internal/cli/project_test.go diff --git a/backend/internal/cli/client.go b/backend/internal/cli/client.go index e6de41fe6f..c4e9f4ee7e 100644 --- a/backend/internal/cli/client.go +++ b/backend/internal/cli/client.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "io" "net/http" "time" @@ -38,12 +40,29 @@ func (e apiError) String() string { return msg } +// getJSON sends GET /api/v1/ to the running daemon and decodes a 2xx +// response into out. A missing daemon or non-2xx API envelope is rendered the +// same way as mutating calls. +func (c *commandContext) getJSON(ctx context.Context, path string, out any) error { + return c.doJSON(ctx, http.MethodGet, path, nil, out) +} + // postJSON sends body as JSON to POST /api/v1/ on the running daemon and // decodes a 2xx response into out (out may be nil). A non-2xx response becomes // an error built from the API error envelope. A missing run-file or a stale one // (dead PID) yields a clear "not running" message rather than a // connection-refused dump. func (c *commandContext) postJSON(ctx context.Context, path string, body, out any) error { + return c.doJSON(ctx, http.MethodPost, path, body, out) +} + +// deleteJSON sends DELETE /api/v1/ to the running daemon and decodes a +// 2xx response into out. +func (c *commandContext) deleteJSON(ctx context.Context, path string, out any) error { + return c.doJSON(ctx, http.MethodDelete, path, nil, out) +} + +func (c *commandContext) doJSON(ctx context.Context, method, path string, body, out any) error { cfg, err := config.Load() if err != nil { return err @@ -59,19 +78,25 @@ func (c *commandContext) postJSON(ctx context.Context, path string, body, out an return fmt.Errorf("AO daemon is not running (stale run-file at %s) — start it with `ao start`", cfg.RunFilePath) } - payload, err := json.Marshal(body) - if err != nil { - return err + var reader io.Reader = http.NoBody + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(payload) } url := fmt.Sprintf("http://%s:%d/api/v1/%s", config.LoopbackHost, info.Port, path) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + req, err := http.NewRequestWithContext(ctx, method, url, reader) if err != nil { return err } - req.Header.Set("Content-Type", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } // Reuse the injected client's transport (keeps it stubbable in tests) but - // give mutating calls far more headroom than the 2s status-probe timeout. + // give daemon API calls far more headroom than the 2s status-probe timeout. client := *c.deps.HTTPClient client.Timeout = commandTimeout resp, err := client.Do(req) @@ -90,6 +115,9 @@ func (c *commandContext) postJSON(ctx context.Context, path string, body, out an } if out != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + if errors.Is(err, io.EOF) { + return nil + } return fmt.Errorf("decode response: %w", err) } } diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index e25d290661..6a5c1fb29c 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -1,7 +1,13 @@ package cli import ( + "bufio" + "errors" "fmt" + "net/url" + "sort" + "strings" + "text/tabwriter" "github.com/spf13/cobra" ) @@ -12,6 +18,19 @@ type projectAddOptions struct { name string } +type projectListOptions struct { + json bool +} + +type projectGetOptions struct { + json bool +} + +type projectRemoveOptions struct { + json bool + yes bool +} + // addProjectRequest mirrors the daemon's project AddInput body for // POST /api/v1/projects. projectId and name are optional (pointers omit them). type addProjectRequest struct { @@ -20,11 +39,43 @@ type addProjectRequest struct { Name *string `json:"name,omitempty"` } +type projectSummary struct { + ID string `json:"id"` + Name string `json:"name"` + SessionPrefix string `json:"sessionPrefix"` + ResolveError string `json:"resolveError,omitempty"` +} + +type projectDetails struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Repo string `json:"repo"` + DefaultBranch string `json:"defaultBranch"` + DefaultHarness string `json:"agent,omitempty"` + Tracker map[string]any `json:"tracker,omitempty"` + SCM map[string]any `json:"scm,omitempty"` + ResolveError string `json:"resolveError,omitempty"` +} + +type projectListResult struct { + Projects []projectSummary `json:"projects"` +} + +type projectGetResult struct { + Status string `json:"status"` + Project projectDetails `json:"project"` +} + type projectResult struct { - Project struct { - ID string `json:"id"` - Path string `json:"path"` - } `json:"project"` + Project projectDetails `json:"project"` +} + +type projectRemoveResult struct { + OK bool `json:"ok,omitempty"` + ID string `json:"id,omitempty"` + ProjectID string `json:"projectId,omitempty"` + RemovedStorageDir *bool `json:"removedStorageDir,omitempty"` } func newProjectCommand(ctx *commandContext) *cobra.Command { @@ -32,7 +83,65 @@ func newProjectCommand(ctx *commandContext) *cobra.Command { Use: "project", Short: "Manage projects", } + cmd.AddCommand(newProjectListCommand(ctx)) + cmd.AddCommand(newProjectGetCommand(ctx)) cmd.AddCommand(newProjectAddCommand(ctx)) + cmd.AddCommand(newProjectRemoveCommand(ctx)) + return cmd +} + +func newProjectListCommand(ctx *commandContext) *cobra.Command { + var opts projectListOptions + cmd := &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List registered projects", + Args: noArgs, + RunE: func(cmd *cobra.Command, args []string) error { + var res projectListResult + if err := ctx.getJSON(cmd.Context(), "projects", &res); err != nil { + return err + } + sort.Slice(res.Projects, func(i, j int) bool { + return res.Projects[i].ID < res.Projects[j].ID + }) + if opts.json { + return writeJSON(cmd.OutOrStdout(), res) + } + return writeProjectList(cmd, res.Projects) + }, + } + cmd.Flags().BoolVar(&opts.json, "json", false, "Output projects as JSON") + return cmd +} + +func newProjectGetCommand(ctx *commandContext) *cobra.Command { + var opts projectGetOptions + cmd := &cobra.Command{ + Use: "get ", + Short: "Fetch one registered project", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return usageError{err} + } + if strings.TrimSpace(args[0]) == "" { + return usageError{errors.New("usage: project id is required")} + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + id := strings.TrimSpace(args[0]) + var res projectGetResult + if err := ctx.getJSON(cmd.Context(), "projects/"+url.PathEscape(id), &res); err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), res) + } + return writeProjectDetails(cmd, res) + }, + } + cmd.Flags().BoolVar(&opts.json, "json", false, "Output project as JSON") return cmd } @@ -69,3 +178,119 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command { f.StringVar(&opts.name, "name", "", "Display name") return cmd } + +func newProjectRemoveCommand(ctx *commandContext) *cobra.Command { + var opts projectRemoveOptions + cmd := &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove", "delete"}, + Short: "Remove a registered project", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return usageError{err} + } + if strings.TrimSpace(args[0]) == "" { + return usageError{errors.New("usage: project id is required")} + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + id := strings.TrimSpace(args[0]) + if !opts.yes { + confirmed, err := confirmProjectRemoval(cmd, id) + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return err + } + } + var res projectRemoveResult + if err := ctx.deleteJSON(cmd.Context(), "projects/"+url.PathEscape(id), &res); err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), res) + } + removedID := res.ProjectID + if removedID == "" { + removedID = res.ID + } + if removedID == "" { + removedID = id + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "removed project %s\n", removedID) + return err + }, + } + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().BoolVar(&opts.json, "json", false, "Output removal result as JSON") + return cmd +} + +func writeProjectList(cmd *cobra.Command, projects []projectSummary) error { + out := cmd.OutOrStdout() + if len(projects) == 0 { + if _, err := fmt.Fprintln(out, "No projects registered."); err != nil { + return err + } + _, err := fmt.Fprintln(out, "Run `ao project add --path ` to register one.") + return err + } + + tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "ID\tNAME\tSESSION PREFIX\tSTATUS"); err != nil { + return err + } + for _, p := range projects { + status := "ok" + if p.ResolveError != "" { + status = "degraded: " + p.ResolveError + } + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", p.ID, p.Name, p.SessionPrefix, status); err != nil { + return err + } + } + return tw.Flush() +} + +func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error { + out := cmd.OutOrStdout() + p := res.Project + if _, err := fmt.Fprintf(out, "Project %s (%s)\n", p.ID, res.Status); err != nil { + return err + } + fields := []struct { + label string + value string + }{ + {label: "name", value: p.Name}, + {label: "path", value: p.Path}, + {label: "repo", value: p.Repo}, + {label: "default branch", value: p.DefaultBranch}, + {label: "default harness", value: p.DefaultHarness}, + {label: "resolve error", value: p.ResolveError}, + } + for _, f := range fields { + if f.value == "" { + continue + } + if _, err := fmt.Fprintf(out, " %s: %s\n", f.label, f.value); err != nil { + return err + } + } + return nil +} + +func confirmProjectRemoval(cmd *cobra.Command, id string) (bool, error) { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Remove project %q? Type the project id to confirm: ", id); err != nil { + return false, err + } + reader := bufio.NewReader(cmd.InOrStdin()) + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return false, err + } + return strings.TrimSpace(line) == id, nil +} diff --git a/backend/internal/cli/project_test.go b/backend/internal/cli/project_test.go new file mode 100644 index 0000000000..98892dc92e --- /dev/null +++ b/backend/internal/cli/project_test.go @@ -0,0 +1,312 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type projectCapture struct { + method string + path string +} + +func projectServer(t *testing.T, status int, respBody string) (*httptest.Server, *projectCapture) { + t.Helper() + capture := &projectCapture{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capture.method = r.Method + capture.path = r.URL.Path + if !strings.HasPrefix(r.URL.Path, "/api/v1/projects") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, respBody) + })) + t.Cleanup(srv.Close) + return srv, capture +} + +func TestProjectList_Success(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"projects":[{"id":"zeta","name":"Zeta","sessionPrefix":"zeta"},{"id":"alpha","name":"Alpha","sessionPrefix":"alpha","resolveError":"config missing"}]}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "ls") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodGet || capture.path != "/api/v1/projects" { + t.Fatalf("request = %s %s, want GET /api/v1/projects", capture.method, capture.path) + } + if !strings.Contains(out, "ID") || !strings.Contains(out, "SESSION PREFIX") { + t.Fatalf("output missing table header:\n%s", out) + } + if strings.Index(out, "alpha") > strings.Index(out, "zeta") { + t.Fatalf("projects should be sorted by id in output:\n%s", out) + } + if !strings.Contains(out, "degraded: config missing") { + t.Fatalf("output missing degraded status:\n%s", out) + } +} + +func TestProjectList_JSON(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := projectServer(t, http.StatusOK, `{"projects":[{"id":"demo","name":"Demo","sessionPrefix":"demo"}]}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "ls", "--json") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + var got projectListResult + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode json output: %v\nout=%s", err, out) + } + if len(got.Projects) != 1 || got.Projects[0].ID != "demo" { + t.Fatalf("projects = %#v, want demo", got.Projects) + } +} + +func TestProjectList_Empty(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := projectServer(t, http.StatusOK, `{"projects":[]}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "ls") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, "No projects registered") || !strings.Contains(out, "ao project add --path") { + t.Fatalf("empty output missing hint:\n%s", out) + } +} + +func TestProjectGet_Success(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"git@example.com:demo.git","defaultBranch":"main","agent":"codex"}}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "get", "demo") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodGet || capture.path != "/api/v1/projects/demo" { + t.Fatalf("request = %s %s, want GET /api/v1/projects/demo", capture.method, capture.path) + } + for _, want := range []string{"Project demo (ok)", "name: Demo", "path: /repo/demo", "default branch: main", "default harness: codex"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } +} + +func TestProjectGet_JSON(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"status":"degraded","project":{"id":"demo","name":"Demo","path":"/repo/demo","resolveError":"config missing"}}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "get", "demo", "--json") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodGet || capture.path != "/api/v1/projects/demo" { + t.Fatalf("request = %s %s, want GET /api/v1/projects/demo", capture.method, capture.path) + } + var got projectGetResult + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode json output: %v\nout=%s", err, out) + } + if got.Status != "degraded" || got.Project.ID != "demo" || got.Project.ResolveError != "config missing" { + t.Fatalf("get json = %#v, want degraded demo with resolve error", got) + } +} + +func TestProjectGet_MissingArg(t *testing.T) { + setConfigEnv(t) + _, _, err := executeCLI(t, Deps{}, "project", "get") + if err == nil { + t.Fatal("expected missing arg error") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2", got) + } +} + +func TestProjectGet_NotFound(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := projectServer(t, http.StatusNotFound, `{"error":"not_found","code":"PROJECT_NOT_FOUND","message":"Unknown project"}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "get", "missing") + if err == nil { + t.Fatal("expected not found error") + } + if got := ExitCode(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } + if !strings.Contains(err.Error(), "PROJECT_NOT_FOUND") && !strings.Contains(errOut, "PROJECT_NOT_FOUND") { + t.Fatalf("error did not surface not found envelope: %v\nstderr=%s", err, errOut) + } +} + +func TestProjectRemove_RequiresID(t *testing.T) { + setConfigEnv(t) + _, _, err := executeCLI(t, Deps{}, "project", "rm") + if err == nil { + t.Fatal("expected missing id error") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2", got) + } +} + +func TestProjectRemove_NotFound(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := projectServer(t, http.StatusNotFound, `{"error":"not_found","code":"PROJECT_NOT_FOUND","message":"Unknown project"}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "missing", "--yes") + if err == nil { + t.Fatal("expected not found error") + } + if got := ExitCode(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } + if !strings.Contains(err.Error(), "PROJECT_NOT_FOUND") && !strings.Contains(errOut, "PROJECT_NOT_FOUND") { + t.Fatalf("error did not surface not found envelope: %v\nstderr=%s", err, errOut) + } +} + +func TestProjectRemove_AbortsWhenConfirmationDoesNotMatch(t *testing.T) { + setConfigEnv(t) + out, _, err := executeCLI(t, Deps{ + In: strings.NewReader("nope\n"), + }, "project", "rm", "demo") + if err != nil { + t.Fatalf("unexpected abort error: %v", err) + } + if !strings.Contains(out, "Type the project id to confirm") || !strings.Contains(out, "aborted") { + t.Fatalf("output missing prompt/abort:\n%s", out) + } +} + +func TestProjectRemove_DeletesAfterConfirmation(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"ok":true,"id":"demo"}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + In: strings.NewReader("demo\n"), + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "demo") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodDelete || capture.path != "/api/v1/projects/demo" { + t.Fatalf("request = %s %s, want DELETE /api/v1/projects/demo", capture.method, capture.path) + } + if !strings.Contains(out, "removed project demo") { + t.Fatalf("output missing removal message:\n%s", out) + } +} + +func TestProjectRemove_JSONDocumentedEnvelope(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"ok":true,"id":"demo"}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + In: strings.NewReader("wrong\n"), + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "demo", "--yes", "--json") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodDelete || capture.path != "/api/v1/projects/demo" { + t.Fatalf("request = %s %s, want DELETE /api/v1/projects/demo", capture.method, capture.path) + } + var got projectRemoveResult + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode json output: %v\nout=%s", err, out) + } + if !got.OK || got.ID != "demo" || got.ProjectID != "" { + t.Fatalf("remove json = %#v, want documented ok/id envelope", got) + } +} + +func TestProjectRemove_JSONBackendEnvelope(t *testing.T) { + cfg := setConfigEnv(t) + removedStorageDir := false + srv, _ := projectServer(t, http.StatusOK, `{"projectId":"demo","removedStorageDir":false}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "demo", "--yes", "--json") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + var got projectRemoveResult + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode json output: %v\nout=%s", err, out) + } + if got.ProjectID != "demo" || got.RemovedStorageDir == nil || *got.RemovedStorageDir != removedStorageDir { + t.Fatalf("remove json = %#v, want backend projectId/removedStorageDir envelope", got) + } +} + +func TestProjectRemove_EmptySuccessFallsBackToRequestedID(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := projectServer(t, http.StatusNoContent, ``) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "demo", "--yes") + if err != nil { + t.Fatalf("unexpected error for empty 2xx body: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, "removed project demo") { + t.Fatalf("output missing fallback removal id:\n%s", out) + } +} + +func TestProjectRemove_YesSkipsConfirmationAndSupportsBackendRemoveEnvelope(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"projectId":"demo","removedStorageDir":false}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + In: strings.NewReader("wrong\n"), + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "demo", "--yes") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodDelete || capture.path != "/api/v1/projects/demo" { + t.Fatalf("request = %s %s, want DELETE /api/v1/projects/demo", capture.method, capture.path) + } + if strings.Contains(out, "Type the project id") || !strings.Contains(out, "removed project demo") { + t.Fatalf("--yes output should skip prompt and print removal:\n%s", out) + } +} From 010b422bb5b8f2e0ed89bf1dbd04e6002795274a Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 3 Jun 2026 04:50:45 +0530 Subject: [PATCH 109/250] feat(cli): add ao session ls/get/kill/restore (#90) (#92) * feat(cli): add session commands * test(cli): cover session json output * chore(cli): trim unused session response fields --- backend/internal/cli/root.go | 1 + backend/internal/cli/session.go | 458 +++++++++++++++++++++++++++ backend/internal/cli/session_test.go | 248 +++++++++++++++ 3 files changed, 707 insertions(+) create mode 100644 backend/internal/cli/session.go create mode 100644 backend/internal/cli/session_test.go diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 280cd7b1ae..e815c7fb9b 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -150,6 +150,7 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newSpawnCommand(ctx)) root.AddCommand(newSendCommand(ctx)) root.AddCommand(newProjectCommand(ctx)) + root.AddCommand(newSessionCommand(ctx)) root.AddCommand(newCompletionCommand()) root.AddCommand(newVersionCommand()) diff --git a/backend/internal/cli/session.go b/backend/internal/cli/session.go new file mode 100644 index 0000000000..28581f51ee --- /dev/null +++ b/backend/internal/cli/session.go @@ -0,0 +1,458 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" +) + +type sessionOptions struct { + project string + json bool +} + +type sessionListOptions struct { + sessionOptions + all bool + includeTerminated bool +} + +type sessionDTO struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + IssueID string `json:"issueId,omitempty"` + Kind string `json:"kind"` + Harness string `json:"harness,omitempty"` + Activity sessionActivity `json:"activity"` + IsTerminated bool `json:"isTerminated"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Status string `json:"status"` +} + +type sessionActivity struct { + State string `json:"state"` + LastActivityAt time.Time `json:"lastActivityAt"` +} + +type sessionListResponse struct { + Sessions []sessionDTO `json:"sessions"` +} + +type sessionResponse struct { + Session sessionDTO `json:"session"` +} + +type killSessionResponse struct { + SessionID string `json:"sessionId"` +} + +type restoreSessionResponse struct { + SessionID string `json:"sessionId"` + Session sessionDTO `json:"session"` +} + +type sessionListEntry struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Role string `json:"role"` + Status string `json:"status,omitempty"` + IssueID string `json:"issueId,omitempty"` + Harness string `json:"harness,omitempty"` + IsTerminated bool `json:"isTerminated"` + LastActivityAt *time.Time `json:"lastActivityAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type sessionListOutput struct { + Data []sessionListEntry `json:"data"` + Meta struct { + HiddenTerminatedCount int `json:"hiddenTerminatedCount"` + } `json:"meta"` +} + +func newSessionCommand(ctx *commandContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "session", + Short: "Manage agent sessions", + } + cmd.AddCommand(newSessionListCommand(ctx)) + cmd.AddCommand(newSessionGetCommand(ctx)) + cmd.AddCommand(newSessionKillCommand(ctx)) + cmd.AddCommand(newSessionRestoreCommand(ctx)) + return cmd +} + +func newSessionListCommand(ctx *commandContext) *cobra.Command { + var opts sessionListOptions + cmd := &cobra.Command{ + Use: "ls", + Short: "List sessions", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return ctx.listSessions(cmd.Context(), cmd, opts) + }, + } + f := cmd.Flags() + addSessionProjectFlag(f, &opts.project, "Filter by project ID") + f.BoolVarP(&opts.all, "all", "a", false, "Include orchestrator sessions") + f.BoolVar(&opts.includeTerminated, "include-terminated", false, "Include terminated sessions") + f.BoolVar(&opts.json, "json", false, "Output as JSON") + return cmd +} + +func newSessionGetCommand(ctx *commandContext) *cobra.Command { + var opts sessionOptions + cmd := &cobra.Command{ + Use: "get ", + Short: "Fetch one session", + Args: oneSessionIDArg, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := normalizeSessionID(args[0]) + if err != nil { + return err + } + return ctx.getSession(cmd.Context(), cmd, id, opts) + }, + } + f := cmd.Flags() + addSessionProjectFlag(f, &opts.project, "Project id to scope the lookup") + f.BoolVar(&opts.json, "json", false, "Output as JSON") + return cmd +} + +func newSessionKillCommand(ctx *commandContext) *cobra.Command { + var opts sessionOptions + cmd := &cobra.Command{ + Use: "kill ", + Short: "Terminate a session", + Args: oneSessionIDArg, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := normalizeSessionID(args[0]) + if err != nil { + return err + } + return ctx.killSession(cmd.Context(), cmd, id, opts) + }, + } + addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup") + return cmd +} + +func newSessionRestoreCommand(ctx *commandContext) *cobra.Command { + var opts sessionOptions + cmd := &cobra.Command{ + Use: "restore ", + Short: "Relaunch a terminated session", + Args: oneSessionIDArg, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := normalizeSessionID(args[0]) + if err != nil { + return err + } + return ctx.restoreSession(cmd.Context(), cmd, id, opts) + }, + } + addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup") + return cmd +} + +func addSessionProjectFlag(flags interface { + StringVarP(*string, string, string, string, string) +}, target *string, usage string) { + flags.StringVarP(target, "project", "p", "", usage) +} + +func oneSessionIDArg(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return usageError{err} + } + if _, err := normalizeSessionID(args[0]); err != nil { + return err + } + return nil +} + +func (c *commandContext) listSessions(ctx context.Context, cmd *cobra.Command, opts sessionListOptions) error { + params := url.Values{} + if opts.project != "" { + params.Set("project", opts.project) + } + if !opts.includeTerminated { + params.Set("active", "true") + } + var res sessionListResponse + if err := c.getJSON(ctx, apiPath("sessions", params), &res); err != nil { + return err + } + sessions := filterAndSortSessions(res.Sessions, opts.all) + hiddenTerminatedCount := 0 + if !opts.includeTerminated { + count, err := c.countHiddenTerminated(ctx, opts.project, opts.all) + if err != nil { + return err + } + hiddenTerminatedCount = count + } + if opts.json { + out := sessionListOutput{Data: sessionListEntries(sessions)} + out.Meta.HiddenTerminatedCount = hiddenTerminatedCount + return writeJSON(cmd.OutOrStdout(), out) + } + return writeSessionList(cmd, sessions, hiddenTerminatedCount) +} + +func (c *commandContext) countHiddenTerminated(ctx context.Context, project string, includeOrchestrators bool) (int, error) { + params := url.Values{} + if project != "" { + params.Set("project", project) + } + params.Set("active", "false") + var res sessionListResponse + if err := c.getJSON(ctx, apiPath("sessions", params), &res); err != nil { + return 0, err + } + return len(filterAndSortSessions(res.Sessions, includeOrchestrators)), nil +} + +func (c *commandContext) getSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error { + sess, err := c.fetchScopedSession(ctx, id, opts.project) + if err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), sessionResponse{Session: sess}) + } + return writeSessionDetails(cmd, sess) +} + +func (c *commandContext) killSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error { + if opts.project != "" { + if _, err := c.fetchScopedSession(ctx, id, opts.project); err != nil { + return err + } + } + var res killSessionResponse + if err := c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/kill", struct{}{}, &res); err != nil { + return err + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "session %s killed\n", res.SessionID) + return err +} + +func (c *commandContext) restoreSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error { + if opts.project != "" { + if _, err := c.fetchScopedSession(ctx, id, opts.project); err != nil { + return err + } + } + var res restoreSessionResponse + if err := c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/restore", struct{}{}, &res); err != nil { + return err + } + out := cmd.OutOrStdout() + if _, err := fmt.Fprintf(out, "session %s restored\n", res.SessionID); err != nil { + return err + } + if res.Session.ProjectID != "" { + if _, err := fmt.Fprintf(out, " project: %s\n", res.Session.ProjectID); err != nil { + return err + } + } + return nil +} + +func (c *commandContext) fetchScopedSession(ctx context.Context, id, project string) (sessionDTO, error) { + var res sessionResponse + if err := c.getJSON(ctx, "sessions/"+url.PathEscape(id), &res); err != nil { + return sessionDTO{}, err + } + if project != "" && res.Session.ProjectID != project { + return sessionDTO{}, usageError{fmt.Errorf("session %s is not in project %s", id, project)} + } + return res.Session, nil +} + +func filterAndSortSessions(sessions []sessionDTO, includeOrchestrators bool) []sessionDTO { + out := make([]sessionDTO, 0, len(sessions)) + for _, sess := range sessions { + if !includeOrchestrators && sess.Kind == "orchestrator" { + continue + } + out = append(out, sess) + } + sort.Slice(out, func(i, j int) bool { + if out[i].ProjectID != out[j].ProjectID { + return out[i].ProjectID < out[j].ProjectID + } + return out[i].ID < out[j].ID + }) + return out +} + +func sessionListEntries(sessions []sessionDTO) []sessionListEntry { + entries := make([]sessionListEntry, 0, len(sessions)) + for _, sess := range sessions { + var last *time.Time + if !sess.Activity.LastActivityAt.IsZero() { + activity := sess.Activity.LastActivityAt + last = &activity + } + entries = append(entries, sessionListEntry{ + ID: sess.ID, + ProjectID: sess.ProjectID, + Role: sessionRole(sess), + Status: sess.Status, + IssueID: sess.IssueID, + Harness: sess.Harness, + IsTerminated: sess.IsTerminated, + LastActivityAt: last, + CreatedAt: sess.CreatedAt, + UpdatedAt: sess.UpdatedAt, + }) + } + return entries +} + +func writeSessionList(cmd *cobra.Command, sessions []sessionDTO, hiddenTerminatedCount int) error { + out := cmd.OutOrStdout() + if len(sessions) == 0 { + if _, err := fmt.Fprintln(out, "(no active sessions)"); err != nil { + return err + } + } else { + currentProject := "" + for _, sess := range sessions { + if sess.ProjectID != currentProject { + if currentProject != "" { + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + currentProject = sess.ProjectID + if _, err := fmt.Fprintf(out, "%s:\n", currentProject); err != nil { + return err + } + } + if _, err := fmt.Fprintf(out, " %s", sess.ID); err != nil { + return err + } + parts := sessionLineParts(sess) + if len(parts) > 0 { + if _, err := fmt.Fprintf(out, " %s", strings.Join(parts, " ")); err != nil { + return err + } + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + } + if hiddenTerminatedCount > 0 { + _, err := fmt.Fprintf(out, "%d terminated session%s hidden. Use --include-terminated to show.\n", hiddenTerminatedCount, pluralS(hiddenTerminatedCount)) + return err + } + return nil +} + +func sessionLineParts(sess sessionDTO) []string { + parts := []string{} + if !sess.Activity.LastActivityAt.IsZero() { + parts = append(parts, "("+formatSessionAge(time.Since(sess.Activity.LastActivityAt))+")") + } + if sess.Status != "" { + parts = append(parts, "["+sess.Status+"]") + } + if sess.Kind != "" { + parts = append(parts, sess.Kind) + } + if sess.IssueID != "" { + parts = append(parts, sess.IssueID) + } + return parts +} + +func writeSessionDetails(cmd *cobra.Command, sess sessionDTO) error { + out := cmd.OutOrStdout() + fields := [][2]string{ + {"id", sess.ID}, + {"project", sess.ProjectID}, + {"role", sessionRole(sess)}, + {"status", sess.Status}, + {"activity", sess.Activity.State}, + {"harness", sess.Harness}, + {"issue", sess.IssueID}, + {"terminated", fmt.Sprintf("%t", sess.IsTerminated)}, + } + for _, field := range fields { + if field[1] == "" { + continue + } + if _, err := fmt.Fprintf(out, "%s: %s\n", field[0], field[1]); err != nil { + return err + } + } + if !sess.CreatedAt.IsZero() { + if _, err := fmt.Fprintf(out, "created: %s\n", sess.CreatedAt.Format(time.RFC3339)); err != nil { + return err + } + } + if !sess.UpdatedAt.IsZero() { + if _, err := fmt.Fprintf(out, "updated: %s\n", sess.UpdatedAt.Format(time.RFC3339)); err != nil { + return err + } + } + return nil +} + +func sessionRole(sess sessionDTO) string { + if sess.Kind == "orchestrator" { + return "orchestrator" + } + return "worker" +} + +func formatSessionAge(d time.Duration) string { + if d < 0 { + d = 0 + } + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh", int(d.Hours())) + } + return fmt.Sprintf("%dd", int(d.Hours()/24)) +} + +func pluralS(n int) string { + if n == 1 { + return "" + } + return "s" +} + +func apiPath(path string, params url.Values) string { + if len(params) == 0 { + return path + } + return path + "?" + params.Encode() +} + +func normalizeSessionID(id string) (string, error) { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return "", usageError{errors.New("session id is required")} + } + return trimmed, nil +} diff --git a/backend/internal/cli/session_test.go b/backend/internal/cli/session_test.go new file mode 100644 index 0000000000..317a8f48be --- /dev/null +++ b/backend/internal/cli/session_test.go @@ -0,0 +1,248 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" +) + +type sessionRequestLog struct { + mu sync.Mutex + requests []string +} + +func (l *sessionRequestLog) append(r *http.Request) { + l.mu.Lock() + defer l.mu.Unlock() + entry := r.Method + " " + r.URL.Path + if r.URL.RawQuery != "" { + entry += "?" + r.URL.RawQuery + } + l.requests = append(l.requests, entry) +} + +func (l *sessionRequestLog) all() []string { + l.mu.Lock() + defer l.mu.Unlock() + return append([]string(nil), l.requests...) +} + +func sessionCommandServer(t *testing.T) (*httptest.Server, *sessionRequestLog) { + t.Helper() + log := &sessionRequestLog{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.append(r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions": + active := r.URL.Query().Get("active") + switch active { + case "false": + _, _ = io.WriteString(w, `{"sessions":[`+sessionJSON("demo-old", "demo", "worker", "terminated", true)+`]}`) + default: + _, _ = io.WriteString(w, `{"sessions":[`+ + sessionJSON("demo-2", "demo", "orchestrator", "idle", false)+`,`+ + sessionJSON("demo-1", "demo", "worker", "working", false)+`]}`) + } + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/demo-1": + _, _ = io.WriteString(w, `{"session":`+sessionJSON("demo-1", "demo", "worker", "working", false)+`}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/kill": + _, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","freed":true}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/restore": + _, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","session":`+sessionJSON("demo-1", "demo", "worker", "idle", false)+`}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv, log +} + +func sessionJSON(id, project, kind, status string, terminated bool) string { + b, _ := json.Marshal(map[string]any{ + "id": id, + "projectId": project, + "kind": kind, + "harness": "codex", + "activity": map[string]any{"state": "idle", "lastActivityAt": "2026-06-02T12:00:00Z"}, + "isTerminated": terminated, + "createdAt": "2026-06-02T11:00:00Z", + "updatedAt": "2026-06-02T12:00:00Z", + "status": status, + }) + return string(b) +} + +func TestSessionList_ProjectFilterAndDefaultFiltering(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "session", "ls", "--project", "demo") + if err != nil { + t.Fatalf("session ls failed: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, "demo:") || !strings.Contains(out, "demo-1") { + t.Fatalf("output missing worker session:\n%s", out) + } + if strings.Contains(out, "demo-2") { + t.Fatalf("orchestrator session should be hidden without --all:\n%s", out) + } + if !strings.Contains(out, "1 terminated session hidden") { + t.Fatalf("hidden terminated hint missing:\n%s", out) + } + want := []string{ + "GET /api/v1/sessions?active=true&project=demo", + "GET /api/v1/sessions?active=false&project=demo", + } + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSessionList_JSONOutputDecodes(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "session", "ls", "--project", "demo", "--json") + if err != nil { + t.Fatalf("session ls --json failed: %v\nstderr=%s", err, errOut) + } + var got sessionListOutput + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("session ls --json output is not decodable: %v\noutput=%s", err, out) + } + if got.Meta.HiddenTerminatedCount != 1 { + t.Fatalf("hiddenTerminatedCount = %d, want 1", got.Meta.HiddenTerminatedCount) + } + if len(got.Data) != 1 { + t.Fatalf("len(data) = %d, want 1; data=%#v", len(got.Data), got.Data) + } + if got.Data[0].ID != "demo-1" || got.Data[0].ProjectID != "demo" || got.Data[0].Role != "worker" { + t.Fatalf("unexpected JSON entry: %#v", got.Data[0]) + } +} + +func TestSessionGet_SuccessWithProjectScope(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "session", "get", "demo-1", "-p", "demo") + if err != nil { + t.Fatalf("session get failed: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, "id: demo-1") || !strings.Contains(out, "project: demo") { + t.Fatalf("unexpected get output:\n%s", out) + } + want := []string{"GET /api/v1/sessions/demo-1"} + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSessionGet_JSONOutputDecodes(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "session", "get", "demo-1", "--project", "demo", "--json") + if err != nil { + t.Fatalf("session get --json failed: %v\nstderr=%s", err, errOut) + } + var got sessionResponse + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("session get --json output is not decodable: %v\noutput=%s", err, out) + } + if got.Session.ID != "demo-1" || got.Session.ProjectID != "demo" || got.Session.Status != "working" { + t.Fatalf("unexpected session JSON: %#v", got.Session) + } +} + +func TestSessionKill_SuccessWithProjectScope(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "session", "kill", "demo-1", "--project", "demo") + if err != nil { + t.Fatalf("session kill failed: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, "session demo-1 killed") { + t.Fatalf("unexpected kill output:\n%s", out) + } + want := []string{"GET /api/v1/sessions/demo-1", "POST /api/v1/sessions/demo-1/kill"} + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSessionRestore_SuccessWithProjectScope(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "session", "restore", "demo-1", "-p", "demo") + if err != nil { + t.Fatalf("session restore failed: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, "session demo-1 restored") || !strings.Contains(out, "project: demo") { + t.Fatalf("unexpected restore output:\n%s", out) + } + want := []string{"GET /api/v1/sessions/demo-1", "POST /api/v1/sessions/demo-1/restore"} + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSessionCommands_MissingIDIsUsageError(t *testing.T) { + setConfigEnv(t) + for _, sub := range []string{"get", "kill", "restore"} { + t.Run(sub, func(t *testing.T) { + _, _, err := executeCLI(t, Deps{}, "session", sub) + if err == nil { + t.Fatal("expected missing id to fail") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2 (err=%v)", got, err) + } + }) + } +} + +func TestSessionGet_ProjectMismatchDoesNotPassScope(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "session", "get", "demo-1", "--project", "other") + if err == nil { + t.Fatal("expected project mismatch to fail") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2", got) + } + if !strings.Contains(err.Error(), "not in project other") { + t.Fatalf("unexpected error: %v", err) + } +} From bab0d2d16771a88174bc7b9dd54f1c4dc7165219 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 3 Jun 2026 16:18:00 +0530 Subject: [PATCH 110/250] feat: add light backend CLI commands (#98) --- backend/internal/cli/client.go | 6 + backend/internal/cli/dto_drift_e2e_test.go | 8 + backend/internal/cli/orchestrator.go | 121 +++++++++++ backend/internal/cli/orchestrator_test.go | 83 ++++++++ backend/internal/cli/root.go | 1 + backend/internal/cli/session.go | 199 ++++++++++++++++++ backend/internal/cli/session_test.go | 127 ++++++++++- backend/internal/domain/session.go | 1 + backend/internal/httpd/apispec/openapi.yaml | 132 +++++++++++- .../internal/httpd/apispec/specgen/build.go | 36 +++- backend/internal/httpd/controllers/dto.go | 23 ++ .../internal/httpd/controllers/sessions.go | 72 ++++++- .../httpd/controllers/sessions_test.go | 162 +++++++++++++- .../internal/service/project/service_test.go | 3 + backend/internal/service/session/service.go | 19 ++ .../internal/service/session/service_test.go | 36 ++++ backend/internal/session_manager/manager.go | 10 +- backend/internal/storage/sqlite/gen/models.go | 1 + .../storage/sqlite/gen/projects.sql.go | 2 +- .../storage/sqlite/gen/sessions.sql.go | 37 +++- .../0003_add_session_display_name.sql | 9 + .../storage/sqlite/queries/sessions.sql | 15 +- .../storage/sqlite/store/session_store.go | 29 ++- .../storage/sqlite/store/store_test.go | 25 +++ 24 files changed, 1127 insertions(+), 30 deletions(-) create mode 100644 backend/internal/cli/orchestrator.go create mode 100644 backend/internal/cli/orchestrator_test.go create mode 100644 backend/internal/storage/sqlite/migrations/0003_add_session_display_name.sql diff --git a/backend/internal/cli/client.go b/backend/internal/cli/client.go index c4e9f4ee7e..c4e405f5c5 100644 --- a/backend/internal/cli/client.go +++ b/backend/internal/cli/client.go @@ -56,6 +56,12 @@ func (c *commandContext) postJSON(ctx context.Context, path string, body, out an return c.doJSON(ctx, http.MethodPost, path, body, out) } +// patchJSON sends body as JSON to PATCH /api/v1/ on the running daemon +// and decodes a 2xx response into out. +func (c *commandContext) patchJSON(ctx context.Context, path string, body, out any) error { + return c.doJSON(ctx, http.MethodPatch, path, body, out) +} + // deleteJSON sends DELETE /api/v1/ to the running daemon and decodes a // 2xx response into out. func (c *commandContext) deleteJSON(ctx context.Context, path string, out any) error { diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 07c10a6563..9135278c04 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -74,6 +74,14 @@ func (f *fakeSessionService) Kill(context.Context, domain.SessionID) (bool, erro return false, nil } +func (f *fakeSessionService) Cleanup(context.Context, domain.ProjectID) ([]domain.SessionID, error) { + return nil, nil +} + +func (f *fakeSessionService) Rename(context.Context, domain.SessionID, string) error { + return nil +} + func (f *fakeSessionService) Send(context.Context, domain.SessionID, string) error { return nil } diff --git a/backend/internal/cli/orchestrator.go b/backend/internal/cli/orchestrator.go new file mode 100644 index 0000000000..65cce1b0fa --- /dev/null +++ b/backend/internal/cli/orchestrator.go @@ -0,0 +1,121 @@ +package cli + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" +) + +type orchestratorListOptions struct { + json bool +} + +type orchestratorListOutput struct { + Data []sessionListEntry `json:"data"` +} + +func newOrchestratorCommand(ctx *commandContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "orchestrator", + Short: "Manage orchestrator sessions", + } + cmd.AddCommand(newOrchestratorListCommand(ctx)) + return cmd +} + +func newOrchestratorListCommand(ctx *commandContext) *cobra.Command { + var opts orchestratorListOptions + cmd := &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List orchestrator sessions", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return ctx.listOrchestrators(cmd.Context(), cmd, opts) + }, + } + cmd.Flags().BoolVar(&opts.json, "json", false, "Output as JSON") + return cmd +} + +func (c *commandContext) listOrchestrators(ctx context.Context, cmd *cobra.Command, opts orchestratorListOptions) error { + var res sessionListResponse + if err := c.getJSON(ctx, "orchestrators", &res); err != nil { + return err + } + orchestrators := filterAndSortOrchestrators(res.Sessions) + if opts.json { + return writeJSON(cmd.OutOrStdout(), orchestratorListOutput{Data: sessionListEntries(orchestrators)}) + } + return writeOrchestratorList(cmd, orchestrators) +} + +func filterAndSortOrchestrators(sessions []sessionDTO) []sessionDTO { + out := make([]sessionDTO, 0, len(sessions)) + for _, sess := range sessions { + if sess.Kind != "orchestrator" { + continue + } + out = append(out, sess) + } + sort.Slice(out, func(i, j int) bool { + if out[i].ProjectID != out[j].ProjectID { + return out[i].ProjectID < out[j].ProjectID + } + return out[i].ID < out[j].ID + }) + return out +} + +func writeOrchestratorList(cmd *cobra.Command, sessions []sessionDTO) error { + out := cmd.OutOrStdout() + if len(sessions) == 0 { + _, err := fmt.Fprintln(out, "(no orchestrators)") + return err + } + currentProject := "" + for _, sess := range sessions { + if sess.ProjectID != currentProject { + if currentProject != "" { + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + currentProject = sess.ProjectID + if _, err := fmt.Fprintf(out, "%s:\n", currentProject); err != nil { + return err + } + } + if _, err := fmt.Fprintf(out, " %s", sess.ID); err != nil { + return err + } + parts := orchestratorLineParts(sess) + if len(parts) > 0 { + if _, err := fmt.Fprintf(out, " %s", strings.Join(parts, " ")); err != nil { + return err + } + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + return nil +} + +func orchestratorLineParts(sess sessionDTO) []string { + parts := []string{} + if !sess.Activity.LastActivityAt.IsZero() { + parts = append(parts, "("+formatSessionAge(time.Since(sess.Activity.LastActivityAt))+")") + } + if sess.Status != "" { + parts = append(parts, "["+sess.Status+"]") + } + if sess.IsTerminated { + parts = append(parts, "terminated") + } + return parts +} diff --git a/backend/internal/cli/orchestrator_test.go b/backend/internal/cli/orchestrator_test.go new file mode 100644 index 0000000000..31fdd7c128 --- /dev/null +++ b/backend/internal/cli/orchestrator_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func orchestratorCommandServer(t *testing.T) (*httptest.Server, *sessionRequestLog) { + t.Helper() + log := &sessionRequestLog{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.append(r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/orchestrators": + _, _ = io.WriteString(w, `{"sessions":[`+ + sessionJSON("other-orch", "other", "orchestrator", "idle", false)+`,`+ + sessionJSON("demo-worker", "demo", "worker", "working", false)+`,`+ + sessionJSON("demo-orch", "demo", "orchestrator", "working", false)+`]}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv, log +} + +func TestOrchestratorList_TableOutput(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := orchestratorCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "orchestrator", "ls") + if err != nil { + t.Fatalf("orchestrator ls failed: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, "demo:") || !strings.Contains(out, "demo-orch") { + t.Fatalf("output missing demo orchestrator:\n%s", out) + } + if !strings.Contains(out, "other:") || !strings.Contains(out, "other-orch") { + t.Fatalf("output missing other orchestrator:\n%s", out) + } + if strings.Contains(out, "demo-worker") { + t.Fatalf("worker session should not be shown in orchestrator ls:\n%s", out) + } + want := []string{"GET /api/v1/orchestrators"} + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestOrchestratorList_JSONOutputDecodes(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := orchestratorCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "orchestrator", "ls", "--json") + if err != nil { + t.Fatalf("orchestrator ls --json failed: %v\nstderr=%s", err, errOut) + } + var got orchestratorListOutput + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("orchestrator ls --json output is not decodable: %v\noutput=%s", err, out) + } + if len(got.Data) != 2 { + t.Fatalf("len(data) = %d, want 2; data=%#v", len(got.Data), got.Data) + } + if got.Data[0].ID != "demo-orch" || got.Data[0].ProjectID != "demo" || got.Data[0].Role != "orchestrator" { + t.Fatalf("unexpected first JSON entry: %#v", got.Data[0]) + } + if got.Data[1].ID != "other-orch" || got.Data[1].ProjectID != "other" || got.Data[1].Role != "orchestrator" { + t.Fatalf("unexpected second JSON entry: %#v", got.Data[1]) + } +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index e815c7fb9b..61e18a0a8b 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -151,6 +151,7 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newSendCommand(ctx)) root.AddCommand(newProjectCommand(ctx)) root.AddCommand(newSessionCommand(ctx)) + root.AddCommand(newOrchestratorCommand(ctx)) root.AddCommand(newCompletionCommand()) root.AddCommand(newVersionCommand()) diff --git a/backend/internal/cli/session.go b/backend/internal/cli/session.go index 28581f51ee..9f66962636 100644 --- a/backend/internal/cli/session.go +++ b/backend/internal/cli/session.go @@ -1,6 +1,7 @@ package cli import ( + "bufio" "context" "errors" "fmt" @@ -23,12 +24,22 @@ type sessionListOptions struct { includeTerminated bool } +type sessionCleanupOptions struct { + project string + yes bool +} + +type sessionRenameRequest struct { + DisplayName string `json:"displayName"` +} + type sessionDTO struct { ID string `json:"id"` ProjectID string `json:"projectId"` IssueID string `json:"issueId,omitempty"` Kind string `json:"kind"` Harness string `json:"harness,omitempty"` + DisplayName string `json:"displayName,omitempty"` Activity sessionActivity `json:"activity"` IsTerminated bool `json:"isTerminated"` CreatedAt time.Time `json:"createdAt"` @@ -58,6 +69,15 @@ type restoreSessionResponse struct { Session sessionDTO `json:"session"` } +type renameSessionResponse struct { + SessionID string `json:"sessionId"` + DisplayName string `json:"displayName"` +} + +type cleanupSessionsResponse struct { + Cleaned []string `json:"cleaned"` +} + type sessionListEntry struct { ID string `json:"id"` ProjectID string `json:"projectId"` @@ -87,6 +107,8 @@ func newSessionCommand(ctx *commandContext) *cobra.Command { cmd.AddCommand(newSessionGetCommand(ctx)) cmd.AddCommand(newSessionKillCommand(ctx)) cmd.AddCommand(newSessionRestoreCommand(ctx)) + cmd.AddCommand(newSessionRenameCommand(ctx)) + cmd.AddCommand(newSessionCleanupCommand(ctx)) return cmd } @@ -164,6 +186,40 @@ func newSessionRestoreCommand(ctx *commandContext) *cobra.Command { return cmd } +func newSessionRenameCommand(ctx *commandContext) *cobra.Command { + var opts sessionOptions + cmd := &cobra.Command{ + Use: "rename ", + Short: "Rename a session", + Args: sessionRenameArgs, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := normalizeSessionID(args[0]) + if err != nil { + return err + } + return ctx.renameSession(cmd.Context(), cmd, id, args[1], opts) + }, + } + addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup") + return cmd +} + +func newSessionCleanupCommand(ctx *commandContext) *cobra.Command { + var opts sessionCleanupOptions + cmd := &cobra.Command{ + Use: "cleanup", + Short: "Clean up terminated sessions", + Long: "Clean up terminated sessions by reclaiming eligible workspaces. Dirty worktrees are skipped by the daemon.", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return ctx.cleanupSessions(cmd.Context(), cmd, opts) + }, + } + addSessionProjectFlag(cmd.Flags(), &opts.project, "Filter by project ID") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip confirmation prompt") + return cmd +} + func addSessionProjectFlag(flags interface { StringVarP(*string, string, string, string, string) }, target *string, usage string) { @@ -180,6 +236,19 @@ func oneSessionIDArg(cmd *cobra.Command, args []string) error { return nil } +func sessionRenameArgs(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(2)(cmd, args); err != nil { + return usageError{err} + } + if _, err := normalizeSessionID(args[0]); err != nil { + return err + } + if strings.TrimSpace(args[1]) == "" { + return usageError{errors.New("session name is required")} + } + return nil +} + func (c *commandContext) listSessions(ctx context.Context, cmd *cobra.Command, opts sessionListOptions) error { params := url.Values{} if opts.project != "" { @@ -269,6 +338,96 @@ func (c *commandContext) restoreSession(ctx context.Context, cmd *cobra.Command, return nil } +func (c *commandContext) renameSession(ctx context.Context, cmd *cobra.Command, id, displayName string, opts sessionOptions) error { + if opts.project != "" { + if _, err := c.fetchScopedSession(ctx, id, opts.project); err != nil { + return err + } + } + name := strings.TrimSpace(displayName) + var res renameSessionResponse + if err := c.patchJSON(ctx, "sessions/"+url.PathEscape(id), sessionRenameRequest{DisplayName: name}, &res); err != nil { + return err + } + sessionID := res.SessionID + if sessionID == "" { + sessionID = id + } + if res.DisplayName != "" { + name = res.DisplayName + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "session %s renamed to %q\n", sessionID, name) + return err +} + +func (c *commandContext) cleanupSessions(ctx context.Context, cmd *cobra.Command, opts sessionCleanupOptions) error { + candidates, err := c.previewCleanupSessions(ctx, opts.project) + if err != nil { + return err + } + out := cmd.OutOrStdout() + if _, err := fmt.Fprintln(out, "Checking for completed sessions..."); err != nil { + return err + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + if len(candidates) == 0 { + _, err := fmt.Fprintln(out, " No sessions to clean up.") + return err + } + labels := cleanupLabels(candidates, opts.project) + for _, label := range labels { + if _, err := fmt.Fprintf(out, " Would clean %s\n", label); err != nil { + return err + } + } + if !opts.yes { + confirmed, err := confirmSessionCleanup(cmd, len(candidates), opts.project) + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(out, "aborted") + return err + } + } + params := url.Values{} + if opts.project != "" { + params.Set("project", opts.project) + } + var res cleanupSessionsResponse + if err := c.postJSON(ctx, apiPath("sessions/cleanup", params), struct{}{}, &res); err != nil { + return err + } + cleaned := res.Cleaned + labelByID := cleanupLabelByID(candidates, opts.project) + for _, id := range cleaned { + label := id + if mapped := labelByID[id]; mapped != "" { + label = mapped + } + if _, err := fmt.Fprintf(out, " Cleaned: %s\n", label); err != nil { + return err + } + } + _, err = fmt.Fprintf(out, "\nCleanup complete. %d session%s cleaned.\n", len(cleaned), pluralS(len(cleaned))) + return err +} + +func (c *commandContext) previewCleanupSessions(ctx context.Context, project string) ([]sessionDTO, error) { + params := url.Values{} + params.Set("active", "false") + if project != "" { + params.Set("project", project) + } + var res sessionListResponse + if err := c.getJSON(ctx, apiPath("sessions", params), &res); err != nil { + return nil, err + } + return filterAndSortSessions(res.Sessions, true), nil +} + func (c *commandContext) fetchScopedSession(ctx context.Context, id, project string) (sessionDTO, error) { var res sessionResponse if err := c.getJSON(ctx, "sessions/"+url.PathEscape(id), &res); err != nil { @@ -321,6 +480,29 @@ func sessionListEntries(sessions []sessionDTO) []sessionListEntry { return entries } +func cleanupLabels(sessions []sessionDTO, scopedProject string) []string { + labels := make([]string, 0, len(sessions)) + for _, sess := range sessions { + labels = append(labels, cleanupLabel(sess, scopedProject)) + } + return labels +} + +func cleanupLabelByID(sessions []sessionDTO, scopedProject string) map[string]string { + labels := make(map[string]string, len(sessions)) + for _, sess := range sessions { + labels[sess.ID] = cleanupLabel(sess, scopedProject) + } + return labels +} + +func cleanupLabel(sess sessionDTO, scopedProject string) string { + if scopedProject == "" && sess.ProjectID != "" { + return sess.ProjectID + ":" + sess.ID + } + return sess.ID +} + func writeSessionList(cmd *cobra.Command, sessions []sessionDTO, hiddenTerminatedCount int) error { out := cmd.OutOrStdout() if len(sessions) == 0 { @@ -384,6 +566,7 @@ func writeSessionDetails(cmd *cobra.Command, sess sessionDTO) error { fields := [][2]string{ {"id", sess.ID}, {"project", sess.ProjectID}, + {"name", sess.DisplayName}, {"role", sessionRole(sess)}, {"status", sess.Status}, {"activity", sess.Activity.State}, @@ -456,3 +639,19 @@ func normalizeSessionID(id string) (string, error) { } return trimmed, nil } + +func confirmSessionCleanup(cmd *cobra.Command, count int, project string) (bool, error) { + scope := " across all projects" + if project != "" { + scope = fmt.Sprintf(" in project %q", project) + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Clean %d terminated session%s%s? Type yes to confirm: ", count, pluralS(count), scope); err != nil { + return false, err + } + reader := bufio.NewReader(cmd.InOrStdin()) + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return false, err + } + return strings.EqualFold(strings.TrimSpace(line), "yes"), nil +} diff --git a/backend/internal/cli/session_test.go b/backend/internal/cli/session_test.go index 317a8f48be..45e2b283fb 100644 --- a/backend/internal/cli/session_test.go +++ b/backend/internal/cli/session_test.go @@ -43,7 +43,9 @@ func sessionCommandServer(t *testing.T) (*httptest.Server, *sessionRequestLog) { active := r.URL.Query().Get("active") switch active { case "false": - _, _ = io.WriteString(w, `{"sessions":[`+sessionJSON("demo-old", "demo", "worker", "terminated", true)+`]}`) + _, _ = io.WriteString(w, `{"sessions":[`+ + sessionJSON("demo-old", "demo", "worker", "terminated", true)+`,`+ + sessionJSON("demo-orch", "demo", "orchestrator", "terminated", true)+`]}`) default: _, _ = io.WriteString(w, `{"sessions":[`+ sessionJSON("demo-2", "demo", "orchestrator", "idle", false)+`,`+ @@ -51,10 +53,19 @@ func sessionCommandServer(t *testing.T) (*httptest.Server, *sessionRequestLog) { } case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/demo-1": _, _ = io.WriteString(w, `{"session":`+sessionJSON("demo-1", "demo", "worker", "working", false)+`}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/cleanup": + _, _ = io.WriteString(w, `{"ok":true,"cleaned":["demo-old","demo-orch"]}`) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/kill": _, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","freed":true}`) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/restore": _, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","session":`+sessionJSON("demo-1", "demo", "worker", "idle", false)+`}`) + case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/sessions/demo-1": + var req sessionRenameRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","displayName":`+jsonQuote(req.DisplayName)+`}`) default: http.NotFound(w, r) } @@ -69,6 +80,7 @@ func sessionJSON(id, project, kind, status string, terminated bool) string { "projectId": project, "kind": kind, "harness": "codex", + "displayName": "Current Name", "activity": map[string]any{"state": "idle", "lastActivityAt": "2026-06-02T12:00:00Z"}, "isTerminated": terminated, "createdAt": "2026-06-02T11:00:00Z", @@ -78,6 +90,11 @@ func sessionJSON(id, project, kind, status string, terminated bool) string { return string(b) } +func jsonQuote(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + func TestSessionList_ProjectFilterAndDefaultFiltering(t *testing.T) { cfg := setConfigEnv(t) srv, log := sessionCommandServer(t) @@ -213,6 +230,79 @@ func TestSessionRestore_SuccessWithProjectScope(t *testing.T) { } } +func TestSessionCleanup_YesSkipsPrompt(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + In: strings.NewReader("no\n"), + ProcessAlive: func(int) bool { return true }, + }, "session", "cleanup", "--project", "demo", "--yes") + if err != nil { + t.Fatalf("session cleanup failed: %v\nstderr=%s", err, errOut) + } + if strings.Contains(out, "Type yes to confirm") { + t.Fatalf("--yes should skip confirmation prompt:\n%s", out) + } + for _, want := range []string{"Checking for completed sessions", "Would clean demo-old", "Would clean demo-orch", "Cleaned: demo-old", "Cleaned: demo-orch", "Cleanup complete. 2 sessions cleaned."} { + if !strings.Contains(out, want) { + t.Fatalf("cleanup output missing %q:\n%s", want, out) + } + } + want := []string{ + "GET /api/v1/sessions?active=false&project=demo", + "POST /api/v1/sessions/cleanup?project=demo", + } + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSessionCleanup_PromptFailsWithoutInput(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, _, err := executeCLI(t, Deps{ + In: strings.NewReader(""), + ProcessAlive: func(int) bool { return true }, + }, "session", "cleanup", "--project", "demo") + if err == nil { + t.Fatal("expected cleanup prompt without input to fail") + } + if got := ExitCode(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } + if !strings.Contains(out, "Type yes to confirm") { + t.Fatalf("output missing confirmation prompt:\n%s", out) + } + want := []string{"GET /api/v1/sessions?active=false&project=demo"} + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSessionRename_SuccessWithProjectScope(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "session", "rename", "demo-1", "New Name", "-p", "demo") + if err != nil { + t.Fatalf("session rename failed: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, `session demo-1 renamed to "New Name"`) { + t.Fatalf("unexpected rename output:\n%s", out) + } + want := []string{"GET /api/v1/sessions/demo-1", "PATCH /api/v1/sessions/demo-1"} + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + func TestSessionCommands_MissingIDIsUsageError(t *testing.T) { setConfigEnv(t) for _, sub := range []string{"get", "kill", "restore"} { @@ -228,6 +318,18 @@ func TestSessionCommands_MissingIDIsUsageError(t *testing.T) { } } +func TestSessionRename_MissingNameIsUsageError(t *testing.T) { + setConfigEnv(t) + + _, _, err := executeCLI(t, Deps{}, "session", "rename", "demo-1") + if err == nil { + t.Fatal("expected missing name to fail") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2 (err=%v)", got, err) + } +} + func TestSessionGet_ProjectMismatchDoesNotPassScope(t *testing.T) { cfg := setConfigEnv(t) srv, _ := sessionCommandServer(t) @@ -246,3 +348,26 @@ func TestSessionGet_ProjectMismatchDoesNotPassScope(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestSessionRename_ProjectMismatchDoesNotPatch(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "session", "rename", "demo-1", "New Name", "--project", "other") + if err == nil { + t.Fatal("expected project mismatch to fail") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2", got) + } + if !strings.Contains(err.Error(), "not in project other") { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"GET /api/v1/sessions/demo-1"} + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index 2354072377..96abd6f8d3 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -41,6 +41,7 @@ type SessionRecord struct { IssueID IssueID `json:"issueId,omitempty"` Kind SessionKind `json:"kind"` Harness AgentHarness `json:"harness,omitempty"` + DisplayName string `json:"displayName,omitempty"` Activity Activity `json:"activity"` IsTerminated bool `json:"isTerminated"` Metadata SessionMetadata `json:"-"` diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 0439d89f90..7e2ed972ed 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -9,6 +9,30 @@ servers: url: http://127.0.0.1:3001 paths: /api/v1/orchestrators: + get: + operationId: listOrchestrators + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListSessionsResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: List orchestrator sessions across projects + tags: + - sessions post: operationId: spawnOrchestrator requestBody: @@ -51,6 +75,45 @@ paths: summary: Spawn an orchestrator session tags: - sessions + /api/v1/orchestrators/{id}: + get: + operationId: getOrchestrator + parameters: + - description: Orchestrator session identifier, e.g. project-orchestrator. + in: path + name: id + required: true + schema: + description: Orchestrator session identifier, e.g. project-orchestrator. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Fetch one orchestrator session + tags: + - sessions /api/v1/projects: get: operationId: listProjects @@ -409,7 +472,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SessionResponse' + $ref: '#/components/schemas/RenameSessionResponse' description: OK "400": content: @@ -423,6 +486,12 @@ paths: schema: $ref: '#/components/schemas/APIError' description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error "501": content: application/json: @@ -555,6 +624,40 @@ paths: summary: Send a message to a running session's agent tags: - sessions + /api/v1/sessions/cleanup: + post: + operationId: cleanupSessions + parameters: + - description: Project id filter. When omitted, clean terminated sessions across + all projects. + in: query + name: project + schema: + description: Project id filter. When omitted, clean terminated sessions + across all projects. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/CleanupSessionsResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Clean up terminated session workspaces + tags: + - sessions components: schemas: APIError: @@ -590,6 +693,18 @@ components: required: - path type: object + CleanupSessionsResponse: + properties: + cleaned: + items: + type: string + type: array + ok: + type: boolean + required: + - ok + - cleaned + type: object DegradedProject: properties: id: @@ -755,6 +870,19 @@ components: required: - displayName type: object + RenameSessionResponse: + properties: + displayName: + type: string + ok: + type: boolean + sessionId: + type: string + required: + - ok + - sessionId + - displayName + type: object ResolveCommentsResponse: properties: ok: @@ -837,6 +965,8 @@ components: createdAt: format: date-time type: string + displayName: + type: string harness: type: string id: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 406d41b814..e178a1532c 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -122,11 +122,14 @@ var schemaNames = map[string]string{ "ControllersGetProjectResponse": "ProjectGetResponse", "ControllersProjectOrDegraded": "ProjectOrDegraded", "ControllersListSessionsQuery": "ListSessionsQuery", + "ControllersCleanupSessionsQuery": "CleanupSessionsQuery", "ControllersListSessionsResponse": "ListSessionsResponse", "ControllersSpawnSessionRequest": "SpawnSessionRequest", "ControllersSessionResponse": "SessionResponse", "ControllersRenameSessionRequest": "RenameSessionRequest", + "ControllersRenameSessionResponse": "RenameSessionResponse", "ControllersRestoreSessionResponse": "RestoreSessionResponse", + "ControllersCleanupSessionsResponse": "CleanupSessionsResponse", "ControllersKillSessionResponse": "KillSessionResponse", "ControllersSendSessionMessageRequest": "SendSessionMessageRequest", "ControllersSendSessionMessageResponse": "SendSessionMessageResponse", @@ -311,9 +314,20 @@ func sessionOperations() []operation { pathParams: []any{controllers.SessionIDParam{}}, reqBody: controllers.RenameSessionRequest{}, resps: []respUnit{ - {http.StatusOK, controllers.SessionResponse{}}, + {http.StatusOK, controllers.RenameSessionResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions/cleanup", id: "cleanupSessions", tag: "sessions", + summary: "Clean up terminated session workspaces", + pathParams: []any{controllers.CleanupSessionsQuery{}}, + resps: []respUnit{ + {http.StatusOK, controllers.CleanupSessionsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, {http.StatusNotImplemented, envelope.APIError{}}, }, }, @@ -351,6 +365,15 @@ func sessionOperations() []operation { {http.StatusInternalServerError, envelope.APIError{}}, }, }, + { + method: http.MethodGet, path: "/api/v1/orchestrators", id: "listOrchestrators", tag: "sessions", + summary: "List orchestrator sessions across projects", + resps: []respUnit{ + {http.StatusOK, controllers.ListSessionsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, { method: http.MethodPost, path: "/api/v1/orchestrators", id: "spawnOrchestrator", tag: "sessions", summary: "Spawn an orchestrator session", @@ -363,6 +386,17 @@ func sessionOperations() []operation { {http.StatusNotImplemented, envelope.APIError{}}, }, }, + { + method: http.MethodGet, path: "/api/v1/orchestrators/{id}", id: "getOrchestrator", tag: "sessions", + summary: "Fetch one orchestrator session", + pathParams: []any{controllers.OrchestratorIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.SessionResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, } } diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 8efd581cdd..9ccef68b21 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -105,6 +105,11 @@ type ListSessionsQuery struct { Fresh *bool `query:"fresh,omitempty" description:"When true, return only fresh non-terminated sessions."` } +// CleanupSessionsQuery is the query string accepted by POST /api/v1/sessions/cleanup. +type CleanupSessionsQuery struct { + Project string `query:"project,omitempty" description:"Project id filter. When omitted, clean terminated sessions across all projects."` +} + // ListSessionsResponse is the body of GET /api/v1/sessions. type ListSessionsResponse struct { Sessions []domain.Session `json:"sessions"` @@ -131,6 +136,13 @@ type RenameSessionRequest struct { DisplayName string `json:"displayName" minLength:"1"` } +// RenameSessionResponse is the body of PATCH /api/v1/sessions/{sessionId}. +type RenameSessionResponse struct { + OK bool `json:"ok"` + SessionID domain.SessionID `json:"sessionId"` + DisplayName string `json:"displayName"` +} + // RestoreSessionResponse is the body of POST /api/v1/sessions/{sessionId}/restore. type RestoreSessionResponse struct { OK bool `json:"ok"` @@ -145,6 +157,12 @@ type KillSessionResponse struct { Freed bool `json:"freed,omitempty"` } +// CleanupSessionsResponse is the body of POST /api/v1/sessions/cleanup. +type CleanupSessionsResponse struct { + OK bool `json:"ok"` + Cleaned []domain.SessionID `json:"cleaned"` +} + // SendSessionMessageRequest is the body of POST /api/v1/sessions/{sessionId}/send. type SendSessionMessageRequest struct { Message string `json:"message" minLength:"1" maxLength:"4096"` @@ -157,6 +175,11 @@ type SendSessionMessageResponse struct { Message string `json:"message"` } +// OrchestratorIDParam is the {id} path parameter for orchestrator routes. +type OrchestratorIDParam struct { + ID string `path:"id" description:"Orchestrator session identifier, e.g. project-orchestrator."` +} + // SpawnOrchestratorRequest is the body of POST /api/v1/orchestrators. type SpawnOrchestratorRequest struct { ProjectID domain.ProjectID `json:"projectId"` diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 632d31f44c..35524df79e 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -30,6 +30,8 @@ type SessionService interface { Get(ctx context.Context, id domain.SessionID) (domain.Session, error) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) Kill(ctx context.Context, id domain.SessionID) (bool, error) + Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) + Rename(ctx context.Context, id domain.SessionID, displayName string) error Send(ctx context.Context, id domain.SessionID, message string) error } @@ -43,12 +45,15 @@ type SessionsController struct { func (c *SessionsController) Register(r chi.Router) { r.Get("/sessions", c.list) r.Post("/sessions", c.spawn) + r.Post("/sessions/cleanup", c.cleanup) r.Get("/sessions/{sessionId}", c.get) r.Patch("/sessions/{sessionId}", c.rename) r.Post("/sessions/{sessionId}/restore", c.restore) r.Post("/sessions/{sessionId}/kill", c.kill) r.Post("/sessions/{sessionId}/send", c.send) + r.Get("/orchestrators", c.listOrchestrators) r.Post("/orchestrators", c.spawnOrchestrator) + r.Get("/orchestrators/{id}", c.getOrchestrator) } func (c *SessionsController) list(w http.ResponseWriter, r *http.Request) { @@ -112,7 +117,25 @@ func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) { } func (c *SessionsController) rename(w http.ResponseWriter, r *http.Request) { - apispec.NotImplemented(w, r, "PATCH", "/api/v1/sessions/{sessionId}") + if c.Svc == nil { + apispec.NotImplemented(w, r, "PATCH", "/api/v1/sessions/{sessionId}") + return + } + var in RenameSessionRequest + if err := decodeJSON(r, &in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + displayName := strings.TrimSpace(in.DisplayName) + if displayName == "" { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "DISPLAY_NAME_REQUIRED", "displayName is required", nil) + return + } + if err := c.Svc.Rename(r.Context(), sessionID(r), displayName); err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, RenameSessionResponse{OK: true, SessionID: sessionID(r), DisplayName: displayName}) } func (c *SessionsController) restore(w http.ResponseWriter, r *http.Request) { @@ -141,6 +164,19 @@ func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusOK, KillSessionResponse{OK: true, SessionID: sessionID(r), Freed: freed}) } +func (c *SessionsController) cleanup(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/cleanup") + return + } + cleaned, err := c.Svc.Cleanup(r.Context(), domain.ProjectID(r.URL.Query().Get("project"))) + if err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, CleanupSessionsResponse{OK: true, Cleaned: cleaned}) +} + func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { if c.Svc == nil { apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/send") @@ -205,10 +241,44 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re }) } +func (c *SessionsController) listOrchestrators(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/orchestrators") + return + } + sessions, err := c.Svc.List(r.Context(), sessionsvc.ListFilter{OrchestratorOnly: true}) + if err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, ListSessionsResponse{Sessions: sessions}) +} + +func (c *SessionsController) getOrchestrator(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/orchestrators/{id}") + return + } + sess, err := c.Svc.Get(r.Context(), orchestratorID(r)) + if err != nil { + writeSessionError(w, r, err) + return + } + if sess.Kind != domain.KindOrchestrator { + writeSessionError(w, r, sessionmanager.ErrNotFound) + return + } + envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sess}) +} + func sessionID(r *http.Request) domain.SessionID { return domain.SessionID(chi.URLParam(r, "sessionId")) } +func orchestratorID(r *http.Request) domain.SessionID { + return domain.SessionID(chi.URLParam(r, "id")) +} + func parseSessionListFilter(r *http.Request) (sessionsvc.ListFilter, error) { q := r.URL.Query() filter := sessionsvc.ListFilter{ProjectID: domain.ProjectID(q.Get("project"))} diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 7ec882cc18..4d33fbc7d8 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -14,11 +14,14 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/ports" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) type fakeSessionService struct { - sessions map[domain.SessionID]domain.Session - sent string + sessions map[domain.SessionID]domain.Session + sent string + cleanupProjects []domain.ProjectID + cleanupResult []domain.SessionID } func newFakeSessionService() *fakeSessionService { @@ -71,6 +74,24 @@ func (f *fakeSessionService) Kill(_ context.Context, id domain.SessionID) (bool, return true, nil } +func (f *fakeSessionService) Cleanup(_ context.Context, project domain.ProjectID) ([]domain.SessionID, error) { + f.cleanupProjects = append(f.cleanupProjects, project) + if f.cleanupResult != nil { + return f.cleanupResult, nil + } + return []domain.SessionID{"ao-1"}, nil +} + +func (f *fakeSessionService) Rename(_ context.Context, id domain.SessionID, displayName string) error { + s, ok := f.sessions[id] + if !ok { + return sessionmanager.ErrNotFound + } + s.DisplayName = displayName + f.sessions[id] = s + return nil +} + func (f *fakeSessionService) Send(_ context.Context, _ domain.SessionID, message string) error { f.sent = message return nil @@ -151,7 +172,21 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { } body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-2", `{"displayName":"Renamed"}`) - assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") + if status != http.StatusOK { + t.Fatalf("rename = %d, want 200; body=%s", status, body) + } + var renamed struct { + OK bool `json:"ok"` + SessionID string `json:"sessionId"` + DisplayName string `json:"displayName"` + } + mustJSON(t, body, &renamed) + if !renamed.OK || renamed.SessionID != "ao-2" || renamed.DisplayName != "Renamed" { + t.Fatalf("rename response = %#v", renamed) + } + if svc.sessions["ao-2"].DisplayName != "Renamed" { + t.Fatalf("session displayName not updated: %+v", svc.sessions["ao-2"]) + } body, status, _ = doRequest(t, srv, "POST", "/api/v1/orchestrators", `{"projectId":"ao"}`) if status != http.StatusCreated { @@ -159,6 +194,73 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { } } +func TestSessionsAPI_RenameNotFound(t *testing.T) { + srv := newSessionTestServer(t, newFakeSessionService()) + + body, status, _ := doRequest(t, srv, "PATCH", "/api/v1/sessions/missing-1", `{"displayName":"Renamed"}`) + assertErrorCode(t, body, status, http.StatusNotFound, "SESSION_NOT_FOUND") +} + +func TestSessionsAPI_RenameValidation(t *testing.T) { + srv := newSessionTestServer(t, newFakeSessionService()) + + body, status, _ := doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-1", `{"displayName":" "}`) + assertErrorCode(t, body, status, http.StatusBadRequest, "DISPLAY_NAME_REQUIRED") + + body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-1", `{`) + assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON") +} + +func TestSessionsAPI_ListOrchestratorsOnly(t *testing.T) { + svc := newFakeSessionService() + now := time.Now().UTC() + svc.sessions["ao-orch"] = domain.Session{ + SessionRecord: domain.SessionRecord{ + ID: "ao-orch", + ProjectID: "ao", + Kind: domain.KindOrchestrator, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, + CreatedAt: now, + UpdatedAt: now, + }, + Status: domain.StatusIdle, + } + svc.sessions["other-orch"] = domain.Session{ + SessionRecord: domain.SessionRecord{ + ID: "other-orch", + ProjectID: "other", + Kind: domain.KindOrchestrator, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, + CreatedAt: now, + UpdatedAt: now, + }, + Status: domain.StatusIdle, + } + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "GET", "/api/v1/orchestrators", "") + if status != http.StatusOK { + t.Fatalf("GET orchestrators = %d, want 200; body=%s", status, body) + } + var list struct { + Sessions []sessionBody `json:"sessions"` + } + mustJSON(t, body, &list) + if len(list.Sessions) != 2 { + t.Fatalf("len(orchestrators) = %d, want 2; body=%s", len(list.Sessions), body) + } + got := map[string]string{} + for _, sess := range list.Sessions { + got[sess.ID] = sess.Kind + } + if got["ao-orch"] != string(domain.KindOrchestrator) || got["other-orch"] != string(domain.KindOrchestrator) { + t.Fatalf("missing orchestrators: %#v", got) + } + if _, ok := got["ao-1"]; ok { + t.Fatalf("worker session leaked into orchestrator list: %#v", got) + } +} + func TestSessionsAPI_SendValidation(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) @@ -166,9 +268,55 @@ func TestSessionsAPI_SendValidation(t *testing.T) { assertErrorCode(t, body, status, http.StatusBadRequest, "MESSAGE_REQUIRED") } +func TestSessionsAPI_CleanupWithProjectFilter(t *testing.T) { + svc := newFakeSessionService() + svc.cleanupResult = []domain.SessionID{"ao-1"} + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/cleanup?project=ao", "") + if status != http.StatusOK { + t.Fatalf("cleanup = %d, want 200; body=%s", status, body) + } + var got struct { + OK bool `json:"ok"` + Cleaned []string `json:"cleaned"` + } + mustJSON(t, body, &got) + if !got.OK || len(got.Cleaned) != 1 || got.Cleaned[0] != "ao-1" { + t.Fatalf("cleanup response = %#v", got) + } + if len(svc.cleanupProjects) != 1 || svc.cleanupProjects[0] != "ao" { + t.Fatalf("cleanupProjects = %#v, want [ao]", svc.cleanupProjects) + } +} + +func TestSessionsAPI_CleanupWithoutProjectFilter(t *testing.T) { + svc := newFakeSessionService() + svc.cleanupResult = []domain.SessionID{"ao-1", "other-1"} + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/cleanup", "") + if status != http.StatusOK { + t.Fatalf("cleanup = %d, want 200; body=%s", status, body) + } + var got struct { + Cleaned []string `json:"cleaned"` + } + mustJSON(t, body, &got) + if len(got.Cleaned) != 2 || got.Cleaned[0] != "ao-1" || got.Cleaned[1] != "other-1" { + t.Fatalf("cleanup response = %#v", got) + } + if len(svc.cleanupProjects) != 1 || svc.cleanupProjects[0] != "" { + t.Fatalf("cleanupProjects = %#v, want empty project filter", svc.cleanupProjects) + } +} + type sessionBody struct { - ID string `json:"id"` - IssueID string `json:"issueId"` - Harness string `json:"harness"` - Status string `json:"status"` + ID string `json:"id"` + ProjectID string `json:"projectId"` + IssueID string `json:"issueId"` + Kind string `json:"kind"` + Harness string `json:"harness"` + DisplayName string `json:"displayName"` + Status string `json:"status"` } diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 03dedfad89..3dd0208eea 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -89,6 +89,9 @@ func TestManager_AddListGetRemove(t *testing.T) { } _, err = m.Get(ctx, "ao") wantCode(t, err, "PROJECT_NOT_FOUND") + + _, err = m.Remove(ctx, "ao") + wantCode(t, err, "PROJECT_NOT_FOUND") } func TestManager_ReaddAfterRemove(t *testing.T) { diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index fe2e63e7f3..226de1d69b 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -3,6 +3,8 @@ package session import ( "context" "fmt" + "strings" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -14,6 +16,7 @@ type Store interface { GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) + RenameSession(ctx context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error) GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) } @@ -66,6 +69,22 @@ func (s *Service) Send(ctx context.Context, id domain.SessionID, message string) return s.manager.Send(ctx, id, message) } +// Rename updates the user-facing session display name. +func (s *Service) Rename(ctx context.Context, id domain.SessionID, displayName string) error { + displayName = strings.TrimSpace(displayName) + if displayName == "" { + return fmt.Errorf("rename %s: display name is required", id) + } + renamed, err := s.store.RenameSession(ctx, id, displayName, time.Now().UTC()) + if err != nil { + return fmt.Errorf("rename %s: %w", id, err) + } + if !renamed { + return fmt.Errorf("rename %s: %w", id, sessionmanager.ErrNotFound) + } + return nil +} + // Cleanup delegates terminal workspace cleanup to the internal manager. func (s *Service) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) { return s.manager.Cleanup(ctx, project) diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index 25c9610f6e..682841ef01 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -2,10 +2,13 @@ package session import ( "context" + "errors" "fmt" "testing" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) type fakeStore struct { @@ -48,6 +51,17 @@ func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, return out, nil } +func (f *fakeStore) RenameSession(_ context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error) { + r, ok := f.sessions[id] + if !ok { + return false, nil + } + r.DisplayName = displayName + r.UpdatedAt = updatedAt + f.sessions[id] = r + return true, nil +} + func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) { pr, ok := f.pr[id] return pr, ok, nil @@ -66,3 +80,25 @@ func TestSessionListDerivesStatusFromPRFacts(t *testing.T) { t.Fatalf("got %+v", list) } } + +func TestSessionRenameUpdatesDisplayName(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer"} + + err := (&Service{store: st}).Rename(context.Background(), "mer-1", " Fix issue #90 ") + if err != nil { + t.Fatal(err) + } + if got := st.sessions["mer-1"].DisplayName; got != "Fix issue #90" { + t.Fatalf("display name = %q, want trimmed rename", got) + } +} + +func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) { + st := newFakeStore() + + err := (&Service{store: st}).Rename(context.Background(), "mer-404", "Missing") + if !errors.Is(err, sessionmanager.ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index ba9e84477a..40b167660e 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -47,6 +47,7 @@ type Store interface { CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) + ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) } // Manager coordinates internal session spawn, restore, kill, and cleanup over @@ -274,7 +275,7 @@ func (m *Manager) Send(ctx context.Context, id domain.SessionID, message string) // Cleanup reclaims the workspaces of terminal sessions in a project. A workspace // whose teardown is refused (uncommitted work) is skipped, never forced. func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) { - recs, err := m.store.ListSessions(ctx, project) + recs, err := m.cleanupRecords(ctx, project) if err != nil { return nil, fmt.Errorf("cleanup %s: %w", project, err) } @@ -298,6 +299,13 @@ func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) ([]doma return cleaned, nil } +func (m *Manager) cleanupRecords(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { + if project == "" { + return m.store.ListAllSessions(ctx) + } + return m.store.ListSessions(ctx, project) +} + // ---- helpers ---- func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index e65add746a..32666b2f1f 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -79,4 +79,5 @@ type Session struct { Prompt string CreatedAt time.Time UpdatedAt time.Time + DisplayName string } diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index 89c99d1ea3..dea720c65e 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -14,7 +14,7 @@ import ( ) const archiveProject = `-- name: ArchiveProject :execrows -UPDATE projects SET archived_at = ? WHERE id = ? +UPDATE projects SET archived_at = ? WHERE id = ? AND archived_at IS NULL ` type ArchiveProjectParams struct { diff --git a/backend/internal/storage/sqlite/gen/sessions.sql.go b/backend/internal/storage/sqlite/gen/sessions.sql.go index fc1fa82bb5..db18bfe143 100644 --- a/backend/internal/storage/sqlite/gen/sessions.sql.go +++ b/backend/internal/storage/sqlite/gen/sessions.sql.go @@ -15,7 +15,7 @@ import ( const getSession = `-- name: GetSession :one SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions WHERE id = ? ` @@ -39,17 +39,18 @@ func (q *Queries) GetSession(ctx context.Context, id domain.SessionID) (Session, &i.Prompt, &i.CreatedAt, &i.UpdatedAt, + &i.DisplayName, ) return i, err } const insertSession = `-- name: InsertSession :exec INSERT INTO sessions ( - id, project_id, num, issue_id, kind, harness, + id, project_id, num, issue_id, kind, harness, display_name, activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertSessionParams struct { @@ -59,6 +60,7 @@ type InsertSessionParams struct { IssueID domain.IssueID Kind domain.SessionKind Harness domain.AgentHarness + DisplayName string ActivityState domain.ActivityState ActivityLastAt time.Time IsTerminated bool @@ -79,6 +81,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er arg.IssueID, arg.Kind, arg.Harness, + arg.DisplayName, arg.ActivityState, arg.ActivityLastAt, arg.IsTerminated, @@ -96,7 +99,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er const listAllSessions = `-- name: ListAllSessions :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions ORDER BY project_id, num ` @@ -126,6 +129,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { &i.Prompt, &i.CreatedAt, &i.UpdatedAt, + &i.DisplayName, ); err != nil { return nil, err } @@ -143,7 +147,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { const listSessionsByProject = `-- name: ListSessionsByProject :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions WHERE project_id = ? ORDER BY num ` @@ -173,6 +177,7 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID domain.Pr &i.Prompt, &i.CreatedAt, &i.UpdatedAt, + &i.DisplayName, ); err != nil { return nil, err } @@ -198,9 +203,27 @@ func (q *Queries) NextSessionNum(ctx context.Context, projectID domain.ProjectID return next, err } +const renameSession = `-- name: RenameSession :execrows +UPDATE sessions SET display_name = ?, updated_at = ? WHERE id = ? +` + +type RenameSessionParams struct { + DisplayName string + UpdatedAt time.Time + ID domain.SessionID +} + +func (q *Queries) RenameSession(ctx context.Context, arg RenameSessionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, renameSession, arg.DisplayName, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateSession = `-- name: UpdateSession :exec UPDATE sessions SET - issue_id = ?, kind = ?, harness = ?, + issue_id = ?, kind = ?, harness = ?, display_name = ?, activity_state = ?, activity_last_at = ?, is_terminated = ?, branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?, updated_at = ? @@ -211,6 +234,7 @@ type UpdateSessionParams struct { IssueID domain.IssueID Kind domain.SessionKind Harness domain.AgentHarness + DisplayName string ActivityState domain.ActivityState ActivityLastAt time.Time IsTerminated bool @@ -228,6 +252,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) er arg.IssueID, arg.Kind, arg.Harness, + arg.DisplayName, arg.ActivityState, arg.ActivityLastAt, arg.IsTerminated, diff --git a/backend/internal/storage/sqlite/migrations/0003_add_session_display_name.sql b/backend/internal/storage/sqlite/migrations/0003_add_session_display_name.sql new file mode 100644 index 0000000000..38a8183dc5 --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0003_add_session_display_name.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE sessions ADD COLUMN display_name TEXT NOT NULL DEFAULT ''; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE sessions DROP COLUMN display_name; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/sessions.sql b/backend/internal/storage/sqlite/queries/sessions.sql index cec6ad3668..9c3e1da780 100644 --- a/backend/internal/storage/sqlite/queries/sessions.sql +++ b/backend/internal/storage/sqlite/queries/sessions.sql @@ -3,15 +3,15 @@ SELECT COALESCE(MAX(num), 0) + 1 AS next FROM sessions WHERE project_id = ?; -- name: InsertSession :exec INSERT INTO sessions ( - id, project_id, num, issue_id, kind, harness, + id, project_id, num, issue_id, kind, harness, display_name, activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateSession :exec UPDATE sessions SET - issue_id = ?, kind = ?, harness = ?, + issue_id = ?, kind = ?, harness = ?, display_name = ?, activity_state = ?, activity_last_at = ?, is_terminated = ?, branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?, updated_at = ? @@ -20,18 +20,21 @@ WHERE id = ?; -- name: GetSession :one SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions WHERE id = ?; -- name: ListSessionsByProject :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions WHERE project_id = ? ORDER BY num; -- name: ListAllSessions :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions ORDER BY project_id, num; + +-- name: RenameSession :execrows +UPDATE sessions SET display_name = ?, updated_at = ? WHERE id = ?; diff --git a/backend/internal/storage/sqlite/store/session_store.go b/backend/internal/storage/sqlite/store/session_store.go index 7c8596ffd4..355b955f8a 100644 --- a/backend/internal/storage/sqlite/store/session_store.go +++ b/backend/internal/storage/sqlite/store/session_store.go @@ -40,6 +40,22 @@ func (s *Store) UpdateSession(ctx context.Context, rec domain.SessionRecord) err return s.qw.UpdateSession(ctx, recordToUpdate(rec)) } +// RenameSession updates only the user-facing display name for an existing +// session. It returns ok=false when the session id does not exist. +func (s *Store) RenameSession(ctx context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + rows, err := s.qw.RenameSession(ctx, gen.RenameSessionParams{ + ID: id, + DisplayName: displayName, + UpdatedAt: updatedAt, + }) + if err != nil { + return false, fmt.Errorf("rename session %s: %w", id, err) + } + return rows > 0, nil +} + // GetSession returns the full record for a session, or ok=false if absent. func (s *Store) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { row, err := s.qr.GetSession(ctx, id) @@ -80,11 +96,12 @@ func mapSessionRows(rows []gen.Session) []domain.SessionRecord { func rowToRecord(row gen.Session) domain.SessionRecord { return domain.SessionRecord{ - ID: row.ID, - ProjectID: row.ProjectID, - IssueID: row.IssueID, - Kind: row.Kind, - Harness: row.Harness, + ID: row.ID, + ProjectID: row.ProjectID, + IssueID: row.IssueID, + Kind: row.Kind, + Harness: row.Harness, + DisplayName: row.DisplayName, Activity: domain.Activity{ State: row.ActivityState, LastActivityAt: row.ActivityLastAt, @@ -111,6 +128,7 @@ func recordToInsert(rec domain.SessionRecord, num int64) gen.InsertSessionParams IssueID: rec.IssueID, Kind: rec.Kind, Harness: rec.Harness, + DisplayName: rec.DisplayName, ActivityState: activity.State, ActivityLastAt: activity.LastActivityAt, IsTerminated: rec.IsTerminated, @@ -131,6 +149,7 @@ func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams { IssueID: rec.IssueID, Kind: rec.Kind, Harness: rec.Harness, + DisplayName: rec.DisplayName, ActivityState: activity.State, ActivityLastAt: activity.LastActivityAt, IsTerminated: rec.IsTerminated, diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 7731e5ca30..62df84c0d6 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -101,6 +101,31 @@ func TestSessionCreateAssignsPerProjectID(t *testing.T) { } } +func TestSessionRenameUpdatesDisplayName(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + + renamedAt := r.UpdatedAt.Add(time.Minute) + ok, err := s.RenameSession(ctx, r.ID, "Fix flaky tests", renamedAt) + if err != nil || !ok { + t.Fatalf("rename: ok=%v err=%v", ok, err) + } + got, _, _ := s.GetSession(ctx, r.ID) + if got.DisplayName != "Fix flaky tests" || !got.UpdatedAt.Equal(renamedAt) { + t.Fatalf("rename not persisted: %+v", got) + } + + ok, err = s.RenameSession(ctx, "mer-missing", "Missing", renamedAt) + if err != nil { + t.Fatalf("rename missing: %v", err) + } + if ok { + t.Fatal("rename missing ok=true, want false") + } +} + func TestSessionUpdateActivityAndTermination(t *testing.T) { s := newTestStore(t) ctx := context.Background() From 210c9df7583e00b8934531d8accf6c5b992ef4b6 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 3 Jun 2026 16:50:54 +0530 Subject: [PATCH 111/250] feat(cli): enrich ao doctor (#90) (#99) * feat(cli): enrich ao doctor * fix(cli): address doctor review feedback --- backend/internal/cli/doctor.go | 311 +++++++++++++++++++++++++--- backend/internal/cli/doctor_test.go | 302 +++++++++++++++++++++++++-- backend/internal/cli/e2e_test.go | 5 +- backend/internal/cli/root.go | 33 +-- 4 files changed, 591 insertions(+), 60 deletions(-) diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index 02cd671c60..5a87a55991 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -2,11 +2,17 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" + "io" "io/fs" + "net/http" "os" "path/filepath" + "regexp" + "strconv" + "strings" "github.com/spf13/cobra" @@ -24,6 +30,7 @@ const ( type doctorCheck struct { Level doctorLevel `json:"level"` + Section string `json:"section,omitempty"` Name string `json:"name"` Message string `json:"message"` } @@ -34,6 +41,27 @@ type doctorReport struct { Checks []doctorCheck `json:"checks"` } +const ( + doctorSectionCore = "Core" + doctorSectionTools = "Tools" + doctorSectionAgents = "Agent harnesses" + doctorSectionGitHub = "GitHub" + minGitVersion = "2.25.0" + githubDoctorUserAgent = "ao-agent-orchestrator/doctor" + defaultDoctorGitHubRESTBase = "https://api.github.com" +) + +type harnessProbe struct { + Name string + BinaryName string + VersionArg string +} + +var doctorHarnesses = []harnessProbe{ + {Name: "claude-code", BinaryName: "claude", VersionArg: "--version"}, + {Name: "codex", BinaryName: "codex", VersionArg: "--version"}, +} + func newDoctorCommand(ctx *commandContext) *cobra.Command { var asJSON bool cmd := &cobra.Command{ @@ -56,10 +84,8 @@ func newDoctorCommand(ctx *commandContext) *cobra.Command { return err } } else { - for _, check := range checks { - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil { - return err - } + if err := writeDoctorText(cmd, checks); err != nil { + return err } } @@ -73,29 +99,53 @@ func newDoctorCommand(ctx *commandContext) *cobra.Command { return cmd } +func writeDoctorText(cmd *cobra.Command, checks []doctorCheck) error { + var lastSection string + for _, check := range checks { + if check.Section != "" && check.Section != lastSection { + if lastSection != "" { + if _, err := fmt.Fprintln(cmd.OutOrStdout()); err != nil { + return err + } + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s:\n", check.Section); err != nil { + return err + } + lastSection = check.Section + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil { + return err + } + } + return nil +} + func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { checks := []doctorCheck{} cfg, err := config.Load() if err != nil { - return append(checks, doctorCheck{Level: doctorFail, Name: "config", Message: err.Error()}) + return append(checks, doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "config", Message: err.Error()}) } checks = append(checks, doctorCheck{ - Level: doctorPass, Name: "config", + Level: doctorPass, Section: doctorSectionCore, Name: "config", Message: fmt.Sprintf("runFile=%s dataDir=%s port=%d", cfg.RunFilePath, cfg.DataDir, cfg.Port), }) if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil { - checks = append(checks, doctorCheck{Level: doctorFail, Name: "data-dir", Message: err.Error()}) + checks = append(checks, doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "data-dir", Message: err.Error()}) } else { - checks = append(checks, doctorCheck{Level: doctorPass, Name: "data-dir", Message: cfg.DataDir}) + checks = append(checks, + doctorCheck{Level: doctorPass, Section: doctorSectionCore, Name: "data-dir", Message: cfg.DataDir}, + checkDataDirWritable(cfg.DataDir), + ) } checks = append(checks, checkStore(cfg.DataDir)) st, err := c.inspectDaemon(ctx) if err != nil { - checks = append(checks, doctorCheck{Level: doctorFail, Name: "daemon", Message: err.Error()}) + checks = append(checks, doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "daemon", Message: err.Error()}) } else { level := doctorPass switch st.State { @@ -111,13 +161,17 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { if st.Error != "" { msg += " (" + st.Error + ")" } - checks = append(checks, doctorCheck{Level: level, Name: "daemon", Message: msg}) + checks = append(checks, doctorCheck{Level: level, Section: doctorSectionCore, Name: "daemon", Message: msg}) } checks = append(checks, - c.checkTool("git", true), + c.checkGit(ctx), c.checkZellij(ctx), ) + for _, harness := range doctorHarnesses { + checks = append(checks, c.checkHarness(ctx, harness)) + } + checks = append(checks, c.checkGitHubToken(ctx)) return checks } @@ -133,44 +187,249 @@ func checkStore(dataDir string) doctorCheck { switch { case err == nil: return doctorCheck{ - Level: doctorPass, Name: "sqlite", + Level: doctorPass, Section: doctorSectionCore, Name: "sqlite", Message: fmt.Sprintf("%s (%d bytes); migrations are applied by the daemon at startup", dbPath, info.Size()), } case errors.Is(err, fs.ErrNotExist): return doctorCheck{ - Level: doctorWarn, Name: "sqlite", + Level: doctorWarn, Section: doctorSectionCore, Name: "sqlite", Message: "database not created yet; run `ao start` to initialize and migrate it", } default: - return doctorCheck{Level: doctorFail, Name: "sqlite", Message: err.Error()} + return doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "sqlite", Message: err.Error()} + } +} + +func checkDataDirWritable(dataDir string) doctorCheck { + f, err := os.CreateTemp(dataDir, ".ao-doctor-write-*") + if err != nil { + return doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "data-dir-write", Message: err.Error()} + } + name := f.Name() + if _, err := f.WriteString("ok\n"); err != nil { + _ = f.Close() + _ = os.Remove(name) + return doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "data-dir-write", Message: err.Error()} + } + if err := f.Close(); err != nil { + _ = os.Remove(name) + return doctorCheck{Level: doctorFail, Section: doctorSectionCore, Name: "data-dir-write", Message: err.Error()} + } + if err := os.Remove(name); err != nil { + return doctorCheck{Level: doctorWarn, Section: doctorSectionCore, Name: "data-dir-write", Message: fmt.Sprintf("write probe succeeded but cleanup failed: %v", err)} } + return doctorCheck{Level: doctorPass, Section: doctorSectionCore, Name: "data-dir-write", Message: "write probe succeeded"} +} + +func (c *commandContext) checkGit(ctx context.Context) doctorCheck { + path, err := c.deps.LookPath("git") + if err != nil || path == "" { + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "git", Message: "not found in PATH"} + } + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + out, err := c.deps.CommandOutput(reqCtx, path, "--version") + if err != nil { + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s: %v", path, err)} + } + version, err := parseGitVersion(string(out)) + if err != nil { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version unknown: %s)", path, firstOutputLine(out))} + } + cmp, err := compareDottedVersion(version, minGitVersion) + if err != nil { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version unknown: %s)", path, firstOutputLine(out))} + } + if cmp < 0 { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; AO expects >= %s for worktrees)", path, version, minGitVersion)} + } + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "git", Message: fmt.Sprintf("%s (version %s; supports worktrees)", path, version)} } func (c *commandContext) checkZellij(ctx context.Context) doctorCheck { path, err := c.deps.LookPath("zellij") - if err != nil { - return doctorCheck{Level: doctorWarn, Name: "zellij", Message: "not found in PATH"} + if err != nil || path == "" { + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "zellij", Message: "not found in PATH"} } reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() out, err := c.deps.CommandOutput(reqCtx, path, "--version") if err != nil { - return doctorCheck{Level: doctorFail, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} } version, err := zellij.CheckVersionOutput(string(out)) if err != nil { - return doctorCheck{Level: doctorFail, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} + return doctorCheck{Level: doctorFail, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s: %v", path, err)} + } + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "zellij", Message: fmt.Sprintf("%s (version %s; require >= %s)", path, version, zellij.RequiredVersion())} +} + +func (c *commandContext) checkHarness(ctx context.Context, harness harnessProbe) doctorCheck { + path, err := c.deps.LookPath(harness.BinaryName) + if err != nil || path == "" { + return doctorCheck{ + Level: doctorWarn, Section: doctorSectionAgents, Name: harness.Name, + Message: fmt.Sprintf("%s not found in PATH", harness.BinaryName), + } + } + if harness.VersionArg == "" { + return doctorCheck{Level: doctorPass, Section: doctorSectionAgents, Name: harness.Name, Message: fmt.Sprintf("%s resolves to %s", harness.BinaryName, path)} + } + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + out, err := c.deps.CommandOutput(reqCtx, path, harness.VersionArg) + if err != nil { + return doctorCheck{ + Level: doctorWarn, Section: doctorSectionAgents, Name: harness.Name, + Message: fmt.Sprintf("%s resolves to %s, but `%s %s` failed: %v", harness.BinaryName, path, harness.BinaryName, harness.VersionArg, err), + } + } + version := firstOutputLine(out) + if version == "" { + version = "version output was empty" + } + return doctorCheck{Level: doctorPass, Section: doctorSectionAgents, Name: harness.Name, Message: fmt.Sprintf("%s resolves to %s (%s)", harness.BinaryName, path, version)} +} + +func (c *commandContext) checkGitHubToken(ctx context.Context) doctorCheck { + token, source, err := c.githubToken(ctx) + if err != nil { + return doctorCheck{Level: doctorWarn, Section: doctorSectionGitHub, Name: "github-token", Message: err.Error()} + } + + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, strings.TrimRight(c.deps.DoctorGitHubRESTBase, "/")+"/user", http.NoBody) + if err != nil { + return doctorCheck{Level: doctorFail, Section: doctorSectionGitHub, Name: "github-token", Message: err.Error()} + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", githubDoctorUserAgent) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := c.deps.HTTPClient.Do(req) + if err != nil { + return doctorCheck{Level: doctorFail, Section: doctorSectionGitHub, Name: "github-token", Message: fmt.Sprintf("%s token validation failed: %v", source, err)} + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return doctorCheck{Level: doctorFail, Section: doctorSectionGitHub, Name: "github-token", Message: fmt.Sprintf("%s token rejected by GitHub (HTTP %d)", source, resp.StatusCode)} + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return doctorCheck{Level: doctorWarn, Section: doctorSectionGitHub, Name: "github-token", Message: fmt.Sprintf("%s token probe returned HTTP %d", source, resp.StatusCode)} + } + + var user struct { + Login string `json:"login"` + } + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return doctorCheck{Level: doctorFail, Section: doctorSectionGitHub, Name: "github-token", Message: fmt.Sprintf("%s token probe decode failed: %v", source, err)} + } + login := user.Login + if login == "" { + login = "unknown user" + } + scopes := strings.TrimSpace(resp.Header.Get("X-OAuth-Scopes")) + scopeMsg := "scopes unavailable" + if scopes != "" { + scopeMsg = "scopes: " + scopes + } + return doctorCheck{Level: doctorPass, Section: doctorSectionGitHub, Name: "github-token", Message: fmt.Sprintf("%s token valid for %s (%s)", source, login, scopeMsg)} +} + +func (c *commandContext) githubToken(ctx context.Context) (token, source string, err error) { + for _, name := range []string{"AO_GITHUB_TOKEN", "GITHUB_TOKEN"} { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return v, name, nil + } } - return doctorCheck{Level: doctorPass, Name: "zellij", Message: fmt.Sprintf("%s (%s)", path, version)} + path, lookErr := c.deps.LookPath("gh") + if lookErr != nil || path == "" { + return "", "", errors.New("no GitHub token found (set AO_GITHUB_TOKEN/GITHUB_TOKEN or run `gh auth login`)") + } + reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + out, cmdErr := c.deps.CommandOutput(reqCtx, path, "auth", "token") + if cmdErr != nil { + return "", "", fmt.Errorf("gh is installed but no token was available (`gh auth token` failed: %w)", cmdErr) + } + token = strings.TrimSpace(string(out)) + if token == "" { + return "", "", errors.New("gh is installed but returned an empty auth token") + } + return token, "gh", nil } -func (c *commandContext) checkTool(name string, required bool) doctorCheck { - path, err := c.deps.LookPath(name) - if err == nil { - return doctorCheck{Level: doctorPass, Name: name, Message: path} +var ( + ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + gitVersionRE = regexp.MustCompile(`(?i)\bgit version\s+(\d+(?:\.\d+){1,3})`) +) + +func parseGitVersion(out string) (string, error) { + clean := ansiRE.ReplaceAllString(out, "") + m := gitVersionRE.FindStringSubmatch(clean) + if len(m) < 2 { + return "", fmt.Errorf("parse git version from %q", strings.TrimSpace(clean)) } - if required { - return doctorCheck{Level: doctorFail, Name: name, Message: "not found in PATH"} + return m[1], nil +} + +func firstOutputLine(out []byte) string { + clean := strings.TrimSpace(ansiRE.ReplaceAllString(string(out), "")) + if clean == "" { + return "" + } + line := strings.SplitN(clean, "\n", 2)[0] + return strings.TrimSpace(line) +} + +func compareDottedVersion(a, b string) (int, error) { + ap, err := dottedVersionParts(a) + if err != nil { + return 0, err + } + bp, err := dottedVersionParts(b) + if err != nil { + return 0, err + } + maxLen := len(ap) + if len(bp) > maxLen { + maxLen = len(bp) + } + for i := 0; i < maxLen; i++ { + var av, bv int + if i < len(ap) { + av = ap[i] + } + if i < len(bp) { + bv = bp[i] + } + switch { + case av < bv: + return -1, nil + case av > bv: + return 1, nil + } + } + return 0, nil +} + +func dottedVersionParts(s string) ([]int, error) { + raw := strings.Split(s, ".") + parts := make([]int, 0, len(raw)) + for _, part := range raw { + if part == "" { + return nil, fmt.Errorf("empty version segment in %q", s) + } + n, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("parse version segment %q in %q: %w", part, s, err) + } + parts = append(parts, n) } - return doctorCheck{Level: doctorWarn, Name: name, Message: "not found in PATH"} + return parts, nil } diff --git a/backend/internal/cli/doctor_test.go b/backend/internal/cli/doctor_test.go index 14dfcb2c5d..34e013f116 100644 --- a/backend/internal/cli/doctor_test.go +++ b/backend/internal/cli/doctor_test.go @@ -2,23 +2,69 @@ package cli import ( "context" + "encoding/json" "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" "strings" "testing" ) +func TestDoctorChecksGitVersion(t *testing.T) { + setConfigEnv(t) + c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(_ context.Context, name string, args ...string) ([]byte, error) { + if name != "/bin/git" || len(args) != 1 || args[0] != "--version" { + t.Fatalf("unexpected command: %s %v", name, args) + } + return []byte("git version 2.43.0\n"), nil + }) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "git") + if check.Level != doctorPass || !strings.Contains(check.Message, "2.43.0") || !strings.Contains(check.Message, "supports worktrees") { + t.Fatalf("git check = %+v, want PASS with version", check) + } +} + +func TestDoctorWarnsOnUnsupportedGitVersion(t *testing.T) { + setConfigEnv(t) + c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { + return []byte("git version 2.24.9\n"), nil + }) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "git") + if check.Level != doctorWarn || !strings.Contains(check.Message, ">= 2.25.0") { + t.Fatalf("git check = %+v, want WARN with minimum version", check) + } +} + +func TestDoctorFailsWhenGitMissing(t *testing.T) { + setConfigEnv(t) + c := doctorContext(t, map[string]string{}, nil) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "git") + if check.Level != doctorFail { + t.Fatalf("git check = %+v, want FAIL", check) + } +} + func TestDoctorChecksZellijVersion(t *testing.T) { setConfigEnv(t) - cmdPath := map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"} - c := &commandContext{deps: Deps{ - LookPath: func(name string) (string, error) { return cmdPath[name], nil }, - CommandOutput: func(_ context.Context, name string, args ...string) ([]byte, error) { - if name != "/bin/zellij" || len(args) != 1 || args[0] != "--version" { - t.Fatalf("unexpected command: %s %v", name, args) + c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, args ...string) ([]byte, error) { + switch name { + case "/bin/git": + return []byte("git version 2.43.0\n"), nil + case "/bin/zellij": + if len(args) != 1 || args[0] != "--version" { + t.Fatalf("unexpected zellij command: %s %v", name, args) } return []byte("zellij 0.44.3\n"), nil - }, - }.withDefaults()} + default: + t.Fatalf("unexpected command: %s %v", name, args) + return nil, nil + } + }) check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") if check.Level != doctorPass || !strings.Contains(check.Message, "0.44.3") { @@ -28,13 +74,12 @@ func TestDoctorChecksZellijVersion(t *testing.T) { func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) { setConfigEnv(t) - cmdPath := map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"} - c := &commandContext{deps: Deps{ - LookPath: func(name string) (string, error) { return cmdPath[name], nil }, - CommandOutput: func(context.Context, string, ...string) ([]byte, error) { - return []byte("zellij 0.44.2\n"), nil - }, - }.withDefaults()} + c := doctorContext(t, map[string]string{"git": "/bin/git", "zellij": "/bin/zellij"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "/bin/git" { + return []byte("git version 2.43.0\n"), nil + } + return []byte("zellij 0.44.2\n"), nil + }) check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") if check.Level != doctorFail || !strings.Contains(check.Message, "require >= 0.44.3") { @@ -44,21 +89,238 @@ func TestDoctorFailsUnsupportedZellijVersion(t *testing.T) { func TestDoctorWarnsWhenZellijMissing(t *testing.T) { setConfigEnv(t) - c := &commandContext{deps: Deps{ + c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { + return []byte("git version 2.43.0\n"), nil + }) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") + if check.Level != doctorWarn { + t.Fatalf("zellij check = %+v, want WARN", check) + } +} + +func TestDoctorChecksHarnessVersions(t *testing.T) { + setConfigEnv(t) + cmdPath := map[string]string{ + "git": "/bin/git", + "claude": "/bin/claude", + "codex": "/bin/codex", + } + c := doctorContext(t, cmdPath, func(_ context.Context, name string, args ...string) ([]byte, error) { + switch name { + case "/bin/git": + return []byte("git version 2.43.0\n"), nil + case "/bin/claude", "/bin/codex": + if len(args) != 1 || args[0] != "--version" { + t.Fatalf("unexpected harness command: %s %v", name, args) + } + return []byte(strings.TrimPrefix(name, "/bin/") + " 1.2.3\n"), nil + default: + t.Fatalf("unexpected command: %s %v", name, args) + return nil, nil + } + }) + + checks := c.runDoctor(context.Background()) + for _, name := range []string{"claude-code", "codex"} { + check := findDoctorCheck(t, checks, name) + if check.Level != doctorPass || !strings.Contains(check.Message, "resolves to") { + t.Fatalf("%s check = %+v, want PASS with path/version", name, check) + } + } +} + +func TestDoctorWarnsWhenHarnessMissing(t *testing.T) { + setConfigEnv(t) + c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { + return []byte("git version 2.43.0\n"), nil + }) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "codex") + if check.Level != doctorWarn || !strings.Contains(check.Message, "not found in PATH") { + t.Fatalf("codex check = %+v, want WARN missing binary", check) + } +} + +func TestDoctorWarnsWhenHarnessVersionFails(t *testing.T) { + setConfigEnv(t) + c := doctorContext(t, map[string]string{"git": "/bin/git", "codex": "/bin/codex"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "/bin/git" { + return []byte("git version 2.43.0\n"), nil + } + return nil, errors.New("boom") + }) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "codex") + if check.Level != doctorWarn || !strings.Contains(check.Message, "failed") { + t.Fatalf("codex check = %+v, want WARN version failure", check) + } +} + +func TestDoctorChecksGitHubTokenFromEnv(t *testing.T) { + setConfigEnv(t) + srv := githubDoctorServer(t, http.StatusOK, `{"login":"octocat"}`, "repo, read:org") + c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { + return []byte("git version 2.43.0\n"), nil + }) + t.Setenv("AO_GITHUB_TOKEN", "env-token") + c.deps.HTTPClient = srv.Client() + c.deps.DoctorGitHubRESTBase = srv.URL + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "github-token") + if check.Level != doctorPass || !strings.Contains(check.Message, "AO_GITHUB_TOKEN") || !strings.Contains(check.Message, "repo, read:org") { + t.Fatalf("github-token check = %+v, want PASS with source and scopes", check) + } +} + +func TestDoctorChecksGitHubTokenFromGHCLI(t *testing.T) { + setConfigEnv(t) + srv := githubDoctorServer(t, http.StatusOK, `{"login":"octocat"}`, "") + c := doctorContext(t, map[string]string{"git": "/bin/git", "gh": "/bin/gh"}, func(_ context.Context, name string, args ...string) ([]byte, error) { + if name == "/bin/gh" { + if len(args) != 2 || args[0] != "auth" || args[1] != "token" { + t.Fatalf("unexpected gh command: %s %v", name, args) + } + return []byte("gh-token\n"), nil + } + return []byte("git version 2.43.0\n"), nil + }) + c.deps.HTTPClient = srv.Client() + c.deps.DoctorGitHubRESTBase = srv.URL + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "github-token") + if check.Level != doctorPass || !strings.Contains(check.Message, "gh token valid") { + t.Fatalf("github-token check = %+v, want PASS from gh", check) + } +} + +func TestDoctorWarnsWhenGitHubTokenMissing(t *testing.T) { + setConfigEnv(t) + c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { + return []byte("git version 2.43.0\n"), nil + }) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "github-token") + if check.Level != doctorWarn || !strings.Contains(check.Message, "no GitHub token found") { + t.Fatalf("github-token check = %+v, want WARN missing token", check) + } +} + +func TestDoctorFailsExpiredGitHubToken(t *testing.T) { + setConfigEnv(t) + srv := githubDoctorServer(t, http.StatusUnauthorized, `{"message":"Bad credentials"}`, "") + c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(context.Context, string, ...string) ([]byte, error) { + return []byte("git version 2.43.0\n"), nil + }) + t.Setenv("GITHUB_TOKEN", "expired-token") + c.deps.HTTPClient = srv.Client() + c.deps.DoctorGitHubRESTBase = srv.URL + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "github-token") + if check.Level != doctorFail || !strings.Contains(check.Message, "HTTP 401") { + t.Fatalf("github-token check = %+v, want FAIL rejected token", check) + } +} + +func TestDoctorJSONOutputIsDecodable(t *testing.T) { + setConfigEnv(t) + clearDoctorGitHubEnv(t) + out, errOut, err := executeCLI(t, Deps{ LookPath: func(name string) (string, error) { if name == "git" { return "/bin/git", nil } return "", errors.New("missing") }, - }.withDefaults()} + CommandOutput: func(context.Context, string, ...string) ([]byte, error) { + return []byte("git version 2.43.0\n"), nil + }, + ProcessAlive: func(int) bool { return false }, + }, "doctor", "--json") + if err != nil { + t.Fatalf("doctor --json failed: %v\nstderr=%s\nstdout=%s", err, errOut, out) + } + var got doctorReport + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode doctor json: %v\nout=%s", err, out) + } + if !got.OK || len(got.Checks) == 0 { + t.Fatalf("doctor json = %#v, want ok with checks", got) + } + if findDoctorCheck(t, got.Checks, "git").Section != doctorSectionTools { + t.Fatalf("git json check missing section: %#v", findDoctorCheck(t, got.Checks, "git")) + } +} - check := findDoctorCheck(t, c.runDoctor(context.Background()), "zellij") - if check.Level != doctorWarn { - t.Fatalf("zellij check = %+v, want WARN", check) +func TestDoctorTextOutputIsGrouped(t *testing.T) { + setConfigEnv(t) + clearDoctorGitHubEnv(t) + out, errOut, err := executeCLI(t, Deps{ + LookPath: func(name string) (string, error) { + if name == "git" { + return "/bin/git", nil + } + return "", errors.New("missing") + }, + CommandOutput: func(context.Context, string, ...string) ([]byte, error) { + return []byte("git version 2.43.0\n"), nil + }, + ProcessAlive: func(int) bool { return false }, + }, "doctor") + if err != nil { + t.Fatalf("doctor failed: %v\nstderr=%s\nstdout=%s", err, errOut, out) + } + for _, want := range []string{"Core:\nPASS config:", "Tools:\nPASS git:", "Agent harnesses:\nWARN claude-code:", "WARN codex:", "GitHub:\nWARN github-token:"} { + if !strings.Contains(out, want) { + t.Fatalf("doctor output missing %q:\n%s", want, out) + } } } +func clearDoctorGitHubEnv(t *testing.T) { + t.Helper() + t.Setenv("AO_GITHUB_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("GH_TOKEN", "") +} + +func doctorContext(t *testing.T, paths map[string]string, commandOutput func(context.Context, string, ...string) ([]byte, error)) *commandContext { + t.Helper() + clearDoctorGitHubEnv(t) + deps := Deps{ + LookPath: func(name string) (string, error) { + path, ok := paths[name] + if !ok || path == "" { + return "", fmt.Errorf("%s missing", name) + } + return path, nil + }, + ProcessAlive: func(int) bool { return false }, + } + if commandOutput != nil { + deps.CommandOutput = commandOutput + } + return &commandContext{deps: deps.withDefaults()} +} + +func githubDoctorServer(t *testing.T, status int, body, scopes string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/user" { + t.Fatalf("unexpected github probe: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") { + t.Fatalf("missing bearer auth header: %q", got) + } + if scopes != "" { + w.Header().Set("X-OAuth-Scopes", scopes) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + })) +} + func findDoctorCheck(t *testing.T, checks []doctorCheck, name string) doctorCheck { t.Helper() for _, check := range checks { diff --git a/backend/internal/cli/e2e_test.go b/backend/internal/cli/e2e_test.go index f89e467116..e762627bdf 100644 --- a/backend/internal/cli/e2e_test.go +++ b/backend/internal/cli/e2e_test.go @@ -80,13 +80,16 @@ func (e env) environ(portOverride string) []string { if strings.HasPrefix(kv, "AO_") { continue } + if strings.HasPrefix(kv, "GITHUB_TOKEN=") || strings.HasPrefix(kv, "GH_TOKEN=") || strings.HasPrefix(kv, "GH_CONFIG_DIR=") { + continue + } out = append(out, kv) } port := fmt.Sprintf("%d", e.port) if portOverride != "" { port = portOverride } - return append(out, "AO_RUN_FILE="+e.runFile, "AO_DATA_DIR="+e.dataDir, "AO_PORT="+port) + return append(out, "AO_RUN_FILE="+e.runFile, "AO_DATA_DIR="+e.dataDir, "AO_PORT="+port, "GH_CONFIG_DIR="+filepath.Join(e.dataDir, "gh-config")) } func freePort(t *testing.T) int { diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 61e18a0a8b..7d5c0df2e7 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -56,24 +56,28 @@ type Deps struct { ProcessAlive func(pid int) bool LookPath func(file string) (string, error) CommandOutput func(ctx context.Context, name string, args ...string) ([]byte, error) - Now func() time.Time - Sleep func(time.Duration) + // DoctorGitHubRESTBase lets tests point the doctor GitHub token probe at + // httptest without mutating package-global state. + DoctorGitHubRESTBase string + Now func() time.Time + Sleep func(time.Duration) } // DefaultDeps returns production dependencies. func DefaultDeps() Deps { return Deps{ - In: os.Stdin, - Out: os.Stdout, - Err: os.Stderr, - HTTPClient: &http.Client{Timeout: 2 * time.Second}, - Executable: os.Executable, - StartProcess: startProcess, - ProcessAlive: processalive.Alive, - LookPath: exec.LookPath, - CommandOutput: commandOutput, - Now: time.Now, - Sleep: time.Sleep, + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + HTTPClient: &http.Client{Timeout: 2 * time.Second}, + Executable: os.Executable, + StartProcess: startProcess, + ProcessAlive: processalive.Alive, + LookPath: exec.LookPath, + CommandOutput: commandOutput, + DoctorGitHubRESTBase: defaultDoctorGitHubRESTBase, + Now: time.Now, + Sleep: time.Sleep, } } @@ -110,6 +114,9 @@ func (d Deps) withDefaults() Deps { if d.CommandOutput == nil { d.CommandOutput = def.CommandOutput } + if d.DoctorGitHubRESTBase == "" { + d.DoctorGitHubRESTBase = def.DoctorGitHubRESTBase + } if d.Now == nil { d.Now = def.Now } From e25b2ad4de6dbe352a59d5803b52fea5a6e5b1fb Mon Sep 17 00:00:00 2001 From: yyovil Date: Wed, 3 Jun 2026 17:06:32 +0530 Subject: [PATCH 112/250] feat(agent): opencode adapter + activity plugin hooks (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent): add opencode adapter + activity plugin hooks Add an opencode (sst/opencode) agent adapter implementing the 6-method ports.Agent interface and register it in the daemon's agent resolver, so a session with harness "opencode" spawns and restores a real opencode worker. opencode diverges from the claude-code/codex adapters in two ways the adapter bridges: - No native command-hook config. Unlike Claude Code (.claude/settings.local.json) and Codex (.codex/hooks.json), opencode has no "run this command on event" config (see sst/opencode#5409). Its only lifecycle surface is a JS/TS plugin loaded from .opencode/plugins/. GetAgentHooks therefore //go:embeds an AO-owned plugin (assets/ao-activity.ts) and writes it atomically; install is an idempotent overwrite and uninstall is a sentinel-guarded delete, so user-authored plugins are never touched. The plugin maps opencode events onto AO's three normalized activity events: session.created -> session-start, message.updated/message.part.updated -> user-prompt-submit, and session.status(idle) -> stop (NOT the deprecated session.idle, which is unreliable under `opencode run`). It shells `ao hooks opencode ` via a guarded sh -c so a missing `ao` binary is a silent no-op. - A single approval flag. opencode exposes only --dangerously-skip-permissions (no graduated accept-edits/auto) and no system-prompt flag, so those map to a bypass-only permission flag and a documented no-op for the system prompt (deferred to opencode's own config). Launch uses the interactive TUI (`opencode --prompt

`); restore continues a captured native session via `opencode --session `. opencode_test.go mirrors codex_test.go (12 tests) and the daemon wiring test now asserts the opencode harness resolves. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(agent): address Greptile review on opencode adapter (#80) - Dispatch all opencode plugin hooks synchronously (Bun.spawnSync). The session.created handler previously fired session-start via an async Bun.spawn; if opencode does not await the event handler, a following message.updated -> user-prompt-submit (sync) could complete before the in-flight async session-start, so AO would see the prompt before the session was registered. A sync spawn blocks opencode's single-threaded event loop, so events are now reported strictly in dispatch order. Removes the now-unused async callHook helper. - Fix package/doc comments that said .opencode/plugin/ (singular) to match the plural .opencode/plugins/ the adapter actually writes to. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(agent): surface opencode hook failures instead of swallowing them The activity plugin previously discarded every failure: callHookSync ignored the subprocess exit code/stderr and both catch blocks were empty, so a failing `ao hooks` invocation or a malformed event payload was completely invisible. Now failures are reported through opencode's structured logger (client.app.log) while still never crashing opencode: - callHookSync pipes stderr and checks result.success; a non-zero exit (a real `ao hooks` failure — the `command -v ao` guard makes a missing binary exit 0) is logged with its exit code and stderr. - spawn exceptions (e.g. no `sh`) are caught and logged. - the event-handler catch logs the offending event type instead of swallowing. - logHookFailure is itself best-effort (optional-chained, rejection swallowed), so logging can never throw back into opencode. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(agent): address opencode adapter review — install guard, prompt dedup, hook timeout Maintainer review on #80 surfaced three pre-merge issues: - GetAgentHooks could clobber a user file: install overwrote .opencode/plugins/ao-activity.ts unconditionally while uninstall was sentinel-guarded. Install now refuses (loud error) to overwrite a file that isn't AO-managed; absent/AO-managed targets still write idempotently. - Empty-prompt report poisoned the dedup: message.updated fired user-prompt-submit with an empty prompt AND marked the message seen, so the text from the following message.part.updated was deduped away and never reached AO — breaking title-from-prompt. reportUserPrompt now reports at most twice: an optional early empty report (keeps run-mode flows active) that does NOT block a later text report, and a text report that is terminal. - Bun.spawnSync had no timeout, so a hung `ao hooks` could block opencode indefinitely. Each spawn is now time-boxed at 30s, matching the claude-code and codex hook timeouts. Adds TestGetAgentHooksRefusesToClobberForeignFile and a spawn-timeout assertion. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: harden opencode activity hooks --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in> --- .../agent/opencode/assets/ao-activity.ts | 174 ++++++++ .../internal/adapters/agent/opencode/hooks.go | 185 +++++++++ .../adapters/agent/opencode/opencode.go | 263 ++++++++++++ .../adapters/agent/opencode/opencode_test.go | 377 ++++++++++++++++++ backend/internal/daemon/lifecycle_wiring.go | 3 +- backend/internal/daemon/wiring_test.go | 1 + 6 files changed, 1002 insertions(+), 1 deletion(-) create mode 100644 backend/internal/adapters/agent/opencode/assets/ao-activity.ts create mode 100644 backend/internal/adapters/agent/opencode/hooks.go create mode 100644 backend/internal/adapters/agent/opencode/opencode.go create mode 100644 backend/internal/adapters/agent/opencode/opencode_test.go diff --git a/backend/internal/adapters/agent/opencode/assets/ao-activity.ts b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts new file mode 100644 index 0000000000..4cd217df9b --- /dev/null +++ b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts @@ -0,0 +1,174 @@ +// agent-orchestrator: managed opencode activity plugin (do not edit) +// +// It maps opencode's native lifecycle events onto AO's three normalized +// activity events: +// session.created -> `ao hooks opencode session-start` +// message.updated / message.part.updated -> `ao hooks opencode user-prompt-submit` +// session.status (status.type == idle) -> `ao hooks opencode stop` +// +// The opencode-native session id (and prompt/model where known) is piped to the +// hook command as JSON on stdin, run with cwd set to the worktree so AO can +// correlate the opencode session to its AO session. Every invocation is +// best-effort and must never crash the user's opencode session: a missing `ao` +// binary is a guarded no-op (`command -v ao`), and spawn exceptions, non-zero +// exit codes, and malformed event payloads are caught and surfaced through +// opencode's structured logger (client.app.log) for diagnosis — never rethrown. +// +// `import type` is erased at runtime by Bun's transpiler, so this loads even +// before opencode has installed @opencode-ai/plugin into the config dir. +import type { Plugin } from "@opencode-ai/plugin" + +export const aoActivity: Plugin = async ({ directory, client }) => { + // ao hooks must never be able to hang opencode: cap each invocation, matching + // the 30s timeout the claude-code and codex hook entries use. + const HOOK_TIMEOUT_MS = 30_000 + // A user message is reported at most twice (see reportUserPrompt): an optional + // early empty report, then an upgrade carrying the prompt text. Maps a message + // id to whether the report we already sent included the prompt text. + const promptReports = new Map() + // message.* events don't carry the session id, so track it from events that do. + let currentSessionID: string | null = null + // The model of the most recent assistant message, forwarded for context. + let currentModel: string | null = null + const messageStore = new Map() + + // Wrap in `sh -c` with a guard so a missing `ao` binary is a silent no-op + // (exit 0) rather than a per-event error in the user's session. + function hookCmd(hookName: string): string[] { + return ["sh", "-c", `if ! command -v ao >/dev/null 2>&1; then exit 0; fi; exec ao hooks opencode ${hookName}`] + } + + // Report a hook failure through opencode's structured logger. Best-effort: the + // log call must itself never throw or reject back into opencode, hence the + // optional chaining + swallowed rejection. + function logHookFailure(hookName: string, detail: string) { + try { + void client?.app + ?.log?.({ body: { service: "ao-activity", level: "error", message: `hook ${hookName} failed: ${detail}` } }) + ?.catch?.(() => {}) + } catch { + // The logger itself is unavailable — nothing more we can safely do. + } + } + + // All hooks are dispatched synchronously (Bun.spawnSync), for two reasons: + // 1. Ordering. An async hook yields the event loop; if opencode does not + // await the handler's promise, a later event (e.g. message.updated -> + // user-prompt-submit) could complete before an in-flight async + // session-start, so AO would see the prompt before the session is + // registered. spawnSync blocks opencode's single-threaded loop until the + // hook returns, so events are reported strictly in dispatch order. + // 2. `opencode run` exits on the idle event, so an async stop hook would be + // killed before completing. + // + // A non-zero exit (the guard makes a missing `ao` exit 0, so this is a real + // `ao hooks` failure) or a spawn exception is logged with its stderr and never + // rethrown, so reporting failures are diagnosable without crashing opencode. + function callHookSync(hookName: string, payload: Record) { + try { + const result = Bun.spawnSync(hookCmd(hookName), { + cwd: directory, + stdin: new TextEncoder().encode(JSON.stringify(payload) + "\n"), + stdout: "ignore", + stderr: "pipe", + timeout: HOOK_TIMEOUT_MS, + }) + if (!result.success) { + const stderr = result.stderr ? new TextDecoder().decode(result.stderr).trim() : "" + logHookFailure(hookName, `exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`) + } + } catch (err) { + // The spawn itself failed (e.g. no `sh` on PATH). Never propagate. + logHookFailure(hookName, err instanceof Error ? err.message : String(err)) + } + } + + function switchedSession(sessionID: string): boolean { + if (currentSessionID === sessionID) return false + promptReports.clear() + messageStore.clear() + currentModel = null + currentSessionID = sessionID + return true + } + + // Report a user prompt, preferring the one that carries the prompt text. + // message.updated can arrive before message.part.updated with no text, so an + // early empty report must NOT dedup away the later text report — otherwise the + // prompt never reaches AO and title-from-prompt metadata breaks. Therefore: an + // empty report fires at most once (so run-mode flows that omit the text part + // still mark the session active), and a text report fires once and is terminal. + function reportUserPrompt(sessionID: string, messageID: string, prompt: string) { + const hasText = prompt.length > 0 + const reportedWithText = promptReports.get(messageID) + if (reportedWithText) return // already reported with text — terminal + if (reportedWithText === false && !hasText) return // already reported empty; no new info + promptReports.set(messageID, hasText) + callHookSync("user-prompt-submit", { session_id: sessionID, prompt, model: currentModel ?? "" }) + } + + return { + event: async ({ event }) => { + try { + switch (event.type) { + case "session.created": { + const session = (event as any).properties?.info + if (!session?.id) break + if (switchedSession(session.id)) { + callHookSync("session-start", { session_id: session.id }) + } + break + } + + case "message.updated": { + const msg = (event as any).properties?.info + if (!msg) break + if (msg.sessionID && switchedSession(msg.sessionID)) { + callHookSync("session-start", { session_id: msg.sessionID }) + } + if (msg.role === "assistant" && msg.modelID) currentModel = msg.modelID + // Fallback: some `opencode run` flows never deliver message.part.updated + // for the prompt, so start the turn from the user message itself. + if (msg.role === "user") { + messageStore.set(msg.id, msg) + const sessionID = msg.sessionID ?? currentSessionID + if (sessionID) reportUserPrompt(sessionID, msg.id, "") + } + break + } + + case "message.part.updated": { + const part = (event as any).properties?.part + if (!part?.messageID) break + const msg = messageStore.get(part.messageID) + if (msg?.role === "user" && part.type === "text") { + const sessionID = msg.sessionID ?? currentSessionID + const prompt = part.text ?? "" + if (sessionID) reportUserPrompt(sessionID, msg.id, prompt) + if (prompt.length > 0) messageStore.delete(part.messageID) + } + break + } + + case "session.status": { + // session.status fires in both TUI and `opencode run`; session.idle + // is deprecated and not reliably emitted in run mode. + // AO's "stop" hook means "the current turn is idle/finished", not + // "the whole native session has terminated", so multi-turn TUI + // sessions intentionally emit one stop per idle transition. + const props = (event as any).properties + if (props?.status?.type !== "idle") break + const sessionID = props?.sessionID ?? currentSessionID + if (!sessionID) break + callHookSync("stop", { session_id: sessionID, model: currentModel ?? "" }) + break + } + } + } catch (err) { + // A malformed/unexpected event payload must never crash opencode; log + // it (tagged with the event type) for diagnosis and move on. + logHookFailure(`event:${(event as any)?.type ?? "unknown"}`, err instanceof Error ? err.message : String(err)) + } + }, + } +} diff --git a/backend/internal/adapters/agent/opencode/hooks.go b/backend/internal/adapters/agent/opencode/hooks.go new file mode 100644 index 0000000000..8e7b1b5ea2 --- /dev/null +++ b/backend/internal/adapters/agent/opencode/hooks.go @@ -0,0 +1,185 @@ +package opencode + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + _ "embed" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // opencode scans both `.opencode/plugin/` and `.opencode/plugins/` for + // `*.js`/`*.ts` files (see opencode's ConfigPlugin glob + // "{plugin,plugins}/*.{ts,js}"). AO writes the plural `plugins/`, matching + // the directory the upstream opencode tooling (and the entire-cli reference + // integration) uses. + opencodePluginDirName = ".opencode" + opencodePluginSubDir = "plugins" + + // opencodePluginFileName is the AO-owned plugin file. AO fully owns this + // filename: install overwrites it and uninstall deletes it (guarded by the + // sentinel), so user-authored plugins in other files are never touched. + // It is TypeScript (opencode runs on Bun); the file's only import is a + // type-only import, which Bun erases at runtime. + opencodePluginFileName = "ao-activity.ts" + + // opencodePluginSentinel marks the file as AO-managed. AreHooksInstalled and + // UninstallHooks key off it so AO never deletes a user file that happens to + // share the name. It must appear verbatim in the embedded plugin source. + opencodePluginSentinel = "agent-orchestrator: managed opencode activity plugin" + + // opencodeHookCommandPrefix identifies the hook commands AO owns. The + // embedded plugin shells `ao hooks opencode `; this prefix is the + // shared contract with the (forthcoming) `ao hooks` CLI and is asserted by + // tests so the plugin can't silently drift away from it. + opencodeHookCommandPrefix = "ao hooks opencode " +) + +// opencodePluginSource is the AO-managed opencode plugin, embedded so it ships +// inside the binary and is written verbatim into a session's worktree on hook +// install. It is a real, lintable source file under assets/ rather than a Go +// string literal because it is opencode plugin source code, not a data +// structure AO assembles (the way it builds Codex/Claude hook JSON). +// +//go:embed assets/ao-activity.ts +var opencodePluginSource string + +// opencodeManagedEvents are the three normalized activity events the embedded +// plugin reports. They are defined here (not parsed from the file) so tests can +// assert the plugin wires every one via the `ao hooks opencode ` command. +var opencodeManagedEvents = []string{"session-start", "user-prompt-submit", "stop"} + +// GetAgentHooks installs AO's opencode activity plugin into the worktree-local +// .opencode/plugins/ directory. Unlike Claude Code and Codex, opencode has no +// native command-hook config to merge into; its only lifecycle-extensibility +// surface is a JS/TS plugin. AO therefore writes a dedicated, AO-owned plugin +// file. The write is atomic and idempotent: re-installing overwrites AO's own +// file with identical content. It refuses to overwrite a file that is NOT +// AO-managed (no sentinel), so a user plugin that happens to occupy our path is +// never silently destroyed — install fails loudly instead. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("opencode.GetAgentHooks: WorkspacePath is required") + } + + pluginPath := opencodePluginPath(cfg.WorkspacePath) + // Guard against clobbering a user file at our path: overwrite only when the + // target is absent or already AO-managed. A foreign file is a loud error, + // not silent data loss (uninstall is sentinel-guarded the same way). + if _, err := os.Stat(pluginPath); err == nil { + managed, err := isAOManagedPlugin(pluginPath) + if err != nil { + return fmt.Errorf("opencode.GetAgentHooks: %w", err) + } + if !managed { + return fmt.Errorf("opencode.GetAgentHooks: refusing to overwrite non-AO file at %s — move it so AO can install its plugin", pluginPath) + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("opencode.GetAgentHooks: stat plugin: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil { + return fmt.Errorf("opencode.GetAgentHooks: create plugin dir: %w", err) + } + if err := atomicWriteFile(pluginPath, []byte(opencodePluginSource), 0o600); err != nil { + return fmt.Errorf("opencode.GetAgentHooks: write plugin: %w", err) + } + return nil +} + +// UninstallHooks removes AO's opencode plugin from the workspace-local +// .opencode/plugins/ directory. It deletes the file only when it carries the AO +// sentinel, so a user file that happens to share the name is left in place. A +// missing file is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("opencode.UninstallHooks: workspacePath is required") + } + + pluginPath := opencodePluginPath(workspacePath) + managed, err := isAOManagedPlugin(pluginPath) + if err != nil { + return fmt.Errorf("opencode.UninstallHooks: %w", err) + } + if !managed { + return nil + } + if err := os.Remove(pluginPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("opencode.UninstallHooks: remove plugin: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether AO's opencode plugin is present in the +// workspace-local plugin dir. A missing file, or a same-named file without the +// AO sentinel, means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("opencode.AreHooksInstalled: workspacePath is required") + } + managed, err := isAOManagedPlugin(opencodePluginPath(workspacePath)) + if err != nil { + return false, fmt.Errorf("opencode.AreHooksInstalled: %w", err) + } + return managed, nil +} + +func opencodePluginPath(workspacePath string) string { + return filepath.Join(workspacePath, opencodePluginDirName, opencodePluginSubDir, opencodePluginFileName) +} + +// isAOManagedPlugin reports whether the file at path exists and carries the AO +// sentinel. A missing file yields (false, nil). +func isAOManagedPlugin(path string) (bool, error) { + data, err := os.ReadFile(path) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read %s: %w", path, err) + } + return strings.Contains(string(data), opencodePluginSentinel), nil +} + +// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- +// write can't leave a truncated plugin file that opencode then fails to import +// (silently disabling activity reporting). +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once renamed + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/backend/internal/adapters/agent/opencode/opencode.go b/backend/internal/adapters/agent/opencode/opencode.go new file mode 100644 index 0000000000..377f1bde32 --- /dev/null +++ b/backend/internal/adapters/agent/opencode/opencode.go @@ -0,0 +1,263 @@ +// Package opencode implements the opencode (sst/opencode) agent adapter: +// launching new TUI sessions, resuming sessions by native id, installing a +// workspace-local activity plugin, and reading plugin-derived session info. +// +// opencode differs from Claude Code and Codex in two ways AO has to bridge: +// - It has no native command-hook config (no settings.local.json / hooks.json +// equivalent). Its only lifecycle-extensibility surface is a JS/TS plugin +// loaded from .opencode/plugins/, so GetAgentHooks installs an AO-owned +// plugin file (see hooks.go) instead of merging JSON. +// - Its CLI exposes only one approval flag (--dangerously-skip-permissions) +// and no system-prompt flag, so the graduated permission modes and the +// system prompt are deferred to opencode's own config. +// +// AO-managed sessions derive native session identity and display metadata from +// the opencode plugin's reported events, mirroring the Codex adapter. +package opencode + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // adapterID is the registry id and the value users pass to + // `ao spawn --agent`. It matches domain.HarnessOpenCode. + adapterID = "opencode" + + // Normalized session-metadata keys the opencode plugin persists into the AO + // session store and SessionInfo reads back. Shared vocabulary with the Codex + // and Claude Code adapters so the dashboard treats every agent uniformly. + opencodeAgentSessionIDMetadataKey = "agentSessionId" + opencodeTitleMetadataKey = "title" + opencodeSummaryMetadataKey = "summary" +) + +// Plugin is the opencode agent adapter. It is safe for concurrent use; the +// binary path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register opencode adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "opencode", + Description: "Run opencode worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. opencode exposes none +// yet: model and agent selection are read from opencode's own config +// (opencode.json / ~/.config/opencode), exactly as a normal launch. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new interactive opencode session. +// Shape: +// +// opencode [--dangerously-skip-permissions] [--prompt ] +// +// The session runs in the worktree (cwd is set by the runtime, as for Claude +// Code and Codex). opencode has no CLI flag to set a system prompt, so +// cfg.SystemPrompt / SystemPromptFile are intentionally ignored here — opencode +// resolves instructions from its own config and AGENTS.md rules. The initial +// task prompt is delivered via --prompt (its argument, so a leading "-" is not +// read as a flag). +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.opencodeBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + appendPermissionFlags(&cmd, cfg.Permissions) + if cfg.Prompt != "" { + cmd = append(cmd, "--prompt", cfg.Prompt) + } + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that opencode receives its prompt in the +// launch command itself (via --prompt). +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing opencode +// session: `opencode [--dangerously-skip-permissions] --session `. +// It re-applies the permission flag (resume otherwise reverts to the configured +// default) but not the prompt, which the session already carries. ok is false +// when the plugin-derived native session id has not landed yet, so callers fall +// back to fresh launch behavior — mirroring the Codex adapter. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[opencodeAgentSessionIDMetadataKey]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.opencodeBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 4) + cmd = append(cmd, binary) + appendPermissionFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--session", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces opencode plugin-derived metadata. Metadata is +// intentionally nil for opencode: callers get the normalized fields directly, +// matching the Codex adapter. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[opencodeAgentSessionIDMetadataKey], + Title: session.Metadata[opencodeTitleMetadataKey], + Summary: session.Metadata[opencodeSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// appendPermissionFlags maps AO's permission modes onto opencode's single +// approval flag. opencode exposes only --dangerously-skip-permissions (no +// graduated accept-edits/auto modes), so: +// - bypass-permissions → --dangerously-skip-permissions +// - default / accept-edits / auto → no flag. opencode resolves approvals from +// its own `permission` config exactly as a normal launch. +func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) { + if normalizePermissionMode(permissions) == ports.PermissionModeBypassPermissions { + *cmd = append(*cmd, "--dangerously-skip-permissions") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + // Empty or unrecognized: defer to opencode's own config (no flag). + return ports.PermissionModeDefault + } +} + +// ResolveOpenCodeBinary returns the path to the opencode binary on this machine, +// searching PATH then a handful of well-known install locations (the install +// script's ~/.opencode/bin, Homebrew, npm global). Returns "opencode" as a +// last-ditch fallback so callers see a clear "command not found" rather than an +// empty argv. +func ResolveOpenCodeBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"opencode.cmd", "opencode.exe", "opencode"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "opencode.cmd"), + filepath.Join(appData, "npm", "opencode.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + } + return "opencode", nil + } + + if path, err := exec.LookPath("opencode"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/opencode", + "/opt/homebrew/bin/opencode", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".opencode", "bin", "opencode"), + filepath.Join(home, ".npm", "bin", "opencode"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "opencode", nil +} + +func (p *Plugin) opencodeBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveOpenCodeBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/opencode/opencode_test.go b/backend/internal/adapters/agent/opencode/opencode_test.go new file mode 100644 index 0000000000..ba73297c1c --- /dev/null +++ b/backend/internal/adapters/agent/opencode/opencode_test.go @@ -0,0 +1,377 @@ +package opencode + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGetLaunchCommandBuildsArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + SystemPromptFile: filepath.Join("tmp", "prompt with spaces.md"), + SystemPrompt: "ignored", + }) + if err != nil { + t.Fatal(err) + } + + // opencode has no system-prompt flag, so SystemPrompt/SystemPromptFile are + // dropped; the prompt is delivered via --prompt. + want := []string{ + "opencode", + "--dangerously-skip-permissions", + "--prompt", "-fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + wantFlag bool + notExpected string + }{ + {name: "default", permission: ports.PermissionModeDefault, notExpected: "--dangerously-skip-permissions"}, + {name: "accept-edits", permission: ports.PermissionModeAcceptEdits, notExpected: "--dangerously-skip-permissions"}, + {name: "auto", permission: ports.PermissionModeAuto, notExpected: "--dangerously-skip-permissions"}, + {name: "bypass-permissions", permission: ports.PermissionModeBypassPermissions, wantFlag: true}, + {name: "empty", permission: "", notExpected: "--dangerously-skip-permissions"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: tt.permission}) + if err != nil { + t.Fatal(err) + } + has := contains(cmd, "--dangerously-skip-permissions") + if tt.wantFlag && !has { + t.Fatalf("command %#v missing --dangerously-skip-permissions", cmd) + } + if tt.notExpected != "" && has { + t.Fatalf("command %#v contains %q", cmd, tt.notExpected) + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestGetAgentHooksInstallsPlugin(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + workspace := t.TempDir() + + // A user's own plugin in the same dir must survive AO's install untouched. + pluginDir := filepath.Dir(opencodePluginPath(workspace)) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + userPlugin := filepath.Join(pluginDir, "user.js") + userBody := []byte("export const userPlugin = async () => ({})\n") + if err := os.WriteFile(userPlugin, userBody, 0o644); err != nil { + t.Fatal(err) + } + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + // A second install must be idempotent (overwrite with identical content). + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + data, err := os.ReadFile(opencodePluginPath(workspace)) + if err != nil { + t.Fatal(err) + } + body := string(data) + if !strings.Contains(body, opencodePluginSentinel) { + t.Fatalf("installed plugin missing AO sentinel:\n%s", body) + } + // Every normalized activity event must be wired via `ao hooks opencode `. + for _, event := range opencodeManagedEvents { + want := opencodeHookCommandPrefix + event + if !strings.Contains(body, want) { + t.Fatalf("installed plugin missing hook command %q:\n%s", want, body) + } + } + // The opencode-native lifecycle events the plugin subscribes to. Stop maps + // to session.status(idle) — NOT the deprecated session.idle — and the user + // prompt is detected from message.updated/message.part.updated. + for _, marker := range []string{"session.created", "message.updated", "message.part.updated", "session.status"} { + if !strings.Contains(body, marker) { + t.Fatalf("installed plugin missing opencode event %q:\n%s", marker, body) + } + } + // Guard against regressing back to subscribing to the deprecated/unreliable + // session.idle event (the quoted event string is how a `case` would name it; + // the explanatory comment mentions it unquoted, which is fine). + if strings.Contains(body, `"session.idle"`) { + t.Fatalf("plugin subscribes to deprecated session.idle; use session.status(idle):\n%s", body) + } + // A hung `ao hooks` call must not block opencode forever, so each spawn is + // time-boxed (parity with the claude/codex 30s hook timeout). + if !strings.Contains(body, "timeout:") { + t.Fatalf("plugin spawn has no timeout; a hung hook would block opencode:\n%s", body) + } + + // The user's plugin is untouched. + got, err := os.ReadFile(userPlugin) + if err != nil { + t.Fatalf("user plugin removed by install: %v", err) + } + if !reflect.DeepEqual(got, userBody) { + t.Fatalf("user plugin modified by install: %q", got) + } +} + +func TestGetAgentHooksRefusesToClobberForeignFile(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + workspace := t.TempDir() + ctx := context.Background() + + // A non-AO file occupying AO's exact path must NOT be silently overwritten. + pluginPath := opencodePluginPath(workspace) + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o755); err != nil { + t.Fatal(err) + } + foreign := []byte("export const notOurs = async () => ({})\n") + if err := os.WriteFile(pluginPath, foreign, 0o644); err != nil { + t.Fatal(err) + } + + err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: workspace}) + if err == nil { + t.Fatal("GetAgentHooks overwrote a non-AO file; want a loud error") + } + got, readErr := os.ReadFile(pluginPath) + if readErr != nil { + t.Fatalf("foreign file removed by refused install: %v", readErr) + } + if !reflect.DeepEqual(got, foreign) { + t.Fatalf("foreign file modified by refused install: %q", got) + } +} + +func TestUninstallHooksRemovesPlugin(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + workspace := t.TempDir() + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own plugin; it must survive uninstall. + pluginDir := filepath.Dir(opencodePluginPath(workspace)) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + userPlugin := filepath.Join(pluginDir, "user.js") + if err := os.WriteFile(userPlugin, []byte("export const userPlugin = async () => ({})\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + if _, err := os.Stat(opencodePluginPath(workspace)); !os.IsNotExist(err) { + t.Fatalf("AO plugin still present after uninstall: err=%v", err) + } + if _, err := os.Stat(userPlugin); err != nil { + t.Fatalf("user plugin removed by uninstall: %v", err) + } +} + +func TestUninstallHooksLeavesForeignFile(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + workspace := t.TempDir() + ctx := context.Background() + + // A non-AO file occupying AO's filename must NOT be deleted by uninstall. + pluginPath := opencodePluginPath(workspace) + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o755); err != nil { + t.Fatal(err) + } + foreign := []byte("export const notOurs = async () => ({})\n") + if err := os.WriteFile(pluginPath, foreign, 0o644); err != nil { + t.Fatal(err) + } + + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled on foreign file = (%v, %v), want (false, nil)", installed, err) + } + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("foreign file removed by uninstall: %v", err) + } + if !reflect.DeepEqual(got, foreign) { + t.Fatalf("foreign file modified by uninstall: %q", got) + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Session: ports.SessionRef{ + Metadata: map[string]string{opencodeAgentSessionIDMetadataKey: "ses_abc123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "opencode", + "--dangerously-skip-permissions", + "--session", "ses_abc123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{opencodeAgentSessionIDMetadataKey: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeDefault, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + opencodeAgentSessionIDMetadataKey: "ses_abc123", + opencodeTitleMetadataKey: "Fix login redirect", + opencodeSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "ses_abc123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for opencode", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 69aae57404..84464376ad 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -9,6 +9,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -118,7 +119,7 @@ func newSessionMessenger(store *sqlite.Store, runtime runtimeMessageSender, _ *s // empty/duplicate id — a programmer error, not a runtime condition. func buildAgentRegistry() (*adapters.Registry, error) { reg := adapters.NewRegistry() - for _, a := range []adapters.Adapter{claudecode.New(), codex.New()} { + for _, a := range []adapters.Adapter{claudecode.New(), codex.New(), opencode.New()} { if err := reg.Register(a); err != nil { return nil, fmt.Errorf("register agent adapter %q: %w", a.Manifest().ID, err) } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 0350c3737e..93b023e731 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -92,6 +92,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { }{ {domain.HarnessClaudeCode, "claude-code"}, {domain.HarnessCodex, "codex"}, + {domain.HarnessOpenCode, "opencode"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness) From 7880f59cf5d2ced86e6af4959c05efd825f6c1fd Mon Sep 17 00:00:00 2001 From: neversettle <41864816+neversettle17-101@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:09:02 +0530 Subject: [PATCH 113/250] refactor(backend): LLD maintainability fixes in controllers/service layers (#95) (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(backend): LLD maintainability fixes in controllers/service layers Addresses the high + medium severity findings from the LLD review of backend/internal/httpd and backend/internal/service (#95): 1. Controllers no longer import internal/session_manager. Session sentinel errors are now *domain.ServiceError values carrying their own HTTP mapping, so the controller translates them with one generic errors.As — no cross-package sentinel imports. 2. One error pattern across services: project.Error is now an alias of the shared domain.ServiceError, and session_manager sentinels use it too. A single writeServiceError replaces the per-resource error switches. 3. Clean-orchestrator business logic moved out of the controller into session.Service.SpawnOrchestrator(ctx, projectID, clean). 4. isGitRepo no longer treats case-different paths as equal on case-sensitive filesystems; case-insensitive compare is gated to darwin/windows via samePath. 5. Project repo check sits behind an injectable GitChecker, so the service is testable without a real git binary. 6. httpd exports only the production constructors (NewWithDeps, NewRouterWithControl); the 3 test-only wrappers are removed and the "router with empty deps" convenience moved to an unexported test helper. Closes #95 Co-Authored-By: Claude Opus 4.8 * refactor(backend): standardize service errors on internal/httpd/errors Replace the domain.ServiceError approach with a REST-API-scoped error package and a single envelope renderer, per review feedback: - Add internal/httpd/errors (package errors, aliased apierr): one structured Error type with semantic Kinds (Internal/Invalid/NotFound/Conflict) and constructors. Imports nothing, so any layer can depend on it. - envelope.WriteError is now the single path from a service error to the wire APIError, and the only place a Kind becomes an HTTP status/word. The per-resource writeProjectError/writeSessionError translators are gone. - Delete domain/errors.go (keeps domain pure of HTTP-flavored kinds) and service/project/errors.go (no per-service error files); services build errors inline via apierr constructors. - session_manager sentinels are apierr.Error values (pointer identity still works with errors.Is). Co-Authored-By: Claude Opus 4.8 * revert(backend): drop GitChecker seam and isGitRepo case-sensitivity change Defer findings #4 (isGitRepo case-sensitivity) and #5 (GitChecker seam) out of this PR. Restores the original exec-based isGitRepo and the New(store) constructor; removes git.go, git_test.go, and the test-only export shims. The error-standardization and other findings are unaffected. Co-Authored-By: Claude Opus 4.8 * refactor(session): translate engine errors to API errors at the facade The session_manager is the internal command engine and must not depend on the REST API error vocabulary. Revert its sentinels to plain errors.New values and move the engine→API translation into the service/session facade (toAPIError), which is the correct boundary. Controllers still see apierr.Error and never import the engine; the engine no longer imports internal/httpd/errors. Co-Authored-By: Claude Opus 4.8 * docs(session): tighten error comments to state what the code does Co-Authored-By: Claude Opus 4.8 * style(envelope): make KindInternal an explicit case in httpStatus Co-Authored-By: Claude Opus 4.8 * refactor(apierr): rename package, test SpawnOrchestrator, parity fixes Address review feedback on PR #96: - Rename internal/httpd/errors → internal/httpd/apierr (package apierr) so importers no longer alias around the stdlib errors package. - Add a commander seam to session.Service and unit-test the relocated clean-orchestrator rule: clean=true kills all active orchestrators before spawning; clean=false spawns without kills. - project.Add: wrap the UpsertProject store error in apierr.Internal for parity with its sibling paths (was a raw 500). - Document that KindInternal is iota's zero value, so a zero-value Error defaults to 500. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- backend/internal/cli/dto_drift_e2e_test.go | 8 +- backend/internal/httpd/apierr/apierr.go | 66 +++++++++++++++++ backend/internal/httpd/apispec/parity_test.go | 2 +- .../internal/httpd/controllers/projects.go | 33 +-------- .../httpd/controllers/projects_test.go | 10 +-- .../internal/httpd/controllers/prs_test.go | 2 +- .../internal/httpd/controllers/sessions.go | 59 ++++----------- .../httpd/controllers/sessions_test.go | 24 +++++- backend/internal/httpd/envelope/envelope.go | 33 +++++++++ backend/internal/httpd/logger_test.go | 2 +- backend/internal/httpd/router.go | 30 +++----- backend/internal/httpd/server.go | 13 +--- backend/internal/httpd/server_test.go | 10 +-- backend/internal/httpd/terminal_mux_test.go | 2 +- backend/internal/httpd/testhelpers_test.go | 17 +++++ backend/internal/service/project/errors.go | 37 ---------- backend/internal/service/project/service.go | 31 ++++---- .../internal/service/project/service_test.go | 7 +- backend/internal/service/session/service.go | 73 ++++++++++++++++--- .../internal/service/session/service_test.go | 72 +++++++++++++++++- backend/internal/session_manager/manager.go | 3 +- 21 files changed, 343 insertions(+), 191 deletions(-) create mode 100644 backend/internal/httpd/apierr/apierr.go create mode 100644 backend/internal/httpd/testhelpers_test.go delete mode 100644 backend/internal/service/project/errors.go diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 9135278c04..47a58f8ee9 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -62,6 +62,10 @@ func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (do }, nil } +func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, _ bool) (domain.Session, error) { + return f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) +} + func (f *fakeSessionService) Get(context.Context, domain.SessionID) (domain.Session, error) { return domain.Session{}, nil } @@ -124,10 +128,10 @@ func startDriftTestDaemon(t *testing.T, sessions controllers.SessionService, pro t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) - router := httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ + router := httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ Sessions: sessions, Projects: projects, - }) + }, httpd.ControlDeps{}) srv := httptest.NewServer(router) t.Cleanup(srv.Close) diff --git a/backend/internal/httpd/apierr/apierr.go b/backend/internal/httpd/apierr/apierr.go new file mode 100644 index 0000000000..48eb0cbe4e --- /dev/null +++ b/backend/internal/httpd/apierr/apierr.go @@ -0,0 +1,66 @@ +// Package apierr defines the REST API's error vocabulary: a single structured +// error type every service returns and the controllers render into the locked +// APIError envelope with one errors.As. It is deliberately scoped to the HTTP +// API tree — these services exist to serve the daemon's REST surface — and +// imports nothing, so any layer may depend on it without an import cycle. +package apierr + +// Kind is a semantic failure category. It is not an HTTP status or word: the +// envelope layer is the only place a Kind is translated into one. +type Kind int + +const ( + // KindInternal is an unexpected failure; it maps to 500. As iota's zero + // value it is also the Kind of a zero-value Error, so an Error built without + // a Kind safely defaults to a 500. + KindInternal Kind = iota + // KindInvalid is malformed or rejected input; it maps to 400. + KindInvalid + // KindNotFound is a missing resource; it maps to 404. + KindNotFound + // KindConflict is a state/uniqueness clash; it maps to 409. + KindConflict +) + +// Error is the structured error every service returns. Code is a stable machine +// identifier (e.g. "SESSION_NOT_FOUND"); Message is the human-facing text. It +// reaches the controller through fmt.Errorf("...: %w", err) wrapping and is +// matched there with errors.As. +type Error struct { + Kind Kind + Code string + Message string + Details map[string]any +} + +func (e *Error) Error() string { + if e == nil { + return "" + } + return e.Message +} + +// New builds an Error from its parts. +func New(kind Kind, code, message string, details map[string]any) *Error { + return &Error{Kind: kind, Code: code, Message: message, Details: details} +} + +// Invalid is a 400-class error. +func Invalid(code, message string, details map[string]any) *Error { + return New(KindInvalid, code, message, details) +} + +// NotFound is a 404-class error. +func NotFound(code, message string) *Error { + return New(KindNotFound, code, message, nil) +} + +// Conflict is a 409-class error. +func Conflict(code, message string, details map[string]any) *Error { + return New(KindConflict, code, message, details) +} + +// Internal is a 500-class error. +func Internal(code, message string) *Error { + return New(KindInternal, code, message, nil) +} diff --git a/backend/internal/httpd/apispec/parity_test.go b/backend/internal/httpd/apispec/parity_test.go index 5bd294104d..e68eaeee64 100644 --- a/backend/internal/httpd/apispec/parity_test.go +++ b/backend/internal/httpd/apispec/parity_test.go @@ -20,7 +20,7 @@ import ( // spec coverage, and the spec can't describe a route that isn't served. func TestRouteSpecParity(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) - router := httpd.NewRouter(config.Config{}, log, nil) + router := httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{}) mounted := map[string]bool{} err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index a23d9dffd7..ba877f9c57 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -6,7 +6,6 @@ package controllers import ( "encoding/json" - "errors" "net/http" "github.com/go-chi/chi/v5" @@ -38,7 +37,7 @@ func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { } projects, err := c.Mgr.List(r.Context()) if err != nil { - writeProjectError(w, r, err) + envelope.WriteError(w, r, err) return } if projects == nil { @@ -59,7 +58,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { } p, err := c.Mgr.Add(r.Context(), in) if err != nil { - writeProjectError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusCreated, ProjectResponse{Project: p}) @@ -72,7 +71,7 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { } got, err := c.Mgr.Get(r.Context(), projectID(r)) if err != nil { - writeProjectError(w, r, err) + envelope.WriteError(w, r, err) return } resp, err := newGetProjectResponse(got) @@ -90,7 +89,7 @@ func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { } result, err := c.Mgr.Remove(r.Context(), projectID(r)) if err != nil { - writeProjectError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, result) @@ -103,27 +102,3 @@ func projectID(r *http.Request) domain.ProjectID { func decodeJSON(r *http.Request, out any) error { return json.NewDecoder(r.Body).Decode(out) } - -// writeProjectError maps a projectsvc.Error to its HTTP status, falling back to -// 500 for an unrecognized kind or a non-projectsvc.Error. -func writeProjectError(w http.ResponseWriter, r *http.Request, err error) { - var pe *projectsvc.Error - if errors.As(err, &pe) { - status := http.StatusInternalServerError - switch pe.Kind { - case "bad_request": - status = http.StatusBadRequest - case "not_found": - status = http.StatusNotFound - case "conflict": - status = http.StatusConflict - case "not_implemented": - status = http.StatusNotImplemented - case "internal": - status = http.StatusInternalServerError - } - envelope.WriteAPIError(w, r, status, pe.Kind, pe.Code, pe.Message, pe.Details) - return - } - envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "INTERNAL_ERROR", "Internal server error", nil) -} diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index 7de640ef50..e5c3e73836 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -58,10 +58,10 @@ func TestProjectsAPI_GetEmptyResultIs500(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ Projects: emptyGetManager{}, - })) + }, httpd.ControlDeps{})) t.Cleanup(srv.Close) @@ -89,10 +89,10 @@ func newTestServer(t *testing.T) *httptest.Server { t.Cleanup(func() { _ = store.Close() }) - srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{ + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ Projects: projectsvc.New(store), - })) + }, httpd.ControlDeps{})) t.Cleanup(srv.Close) @@ -104,7 +104,7 @@ func TestProjectsRoutes_DefaultToStubsWithoutManager(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil)) + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{})) t.Cleanup(srv.Close) diff --git a/backend/internal/httpd/controllers/prs_test.go b/backend/internal/httpd/controllers/prs_test.go index 7d255b9817..dff3deccd5 100644 --- a/backend/internal/httpd/controllers/prs_test.go +++ b/backend/internal/httpd/controllers/prs_test.go @@ -31,7 +31,7 @@ func (f *fakePRService) ResolveComments(_ context.Context, _ string, _ []string) func newPRTestServer(t *testing.T, svc prsvc.ActionManager) *httptest.Server { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{PRs: svc})) + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{PRs: svc}, httpd.ControlDeps{})) t.Cleanup(srv.Close) return srv } diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 35524df79e..f2ef1be1ca 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -15,7 +15,6 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" "github.com/aoagents/agent-orchestrator/backend/internal/ports" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" - sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) const ( @@ -27,6 +26,7 @@ const ( type SessionService interface { List(ctx context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) + SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) Kill(ctx context.Context, id domain.SessionID) (bool, error) @@ -68,7 +68,7 @@ func (c *SessionsController) list(w http.ResponseWriter, r *http.Request) { } sessions, err := c.Svc.List(r.Context(), filter) if err != nil { - writeSessionError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, ListSessionsResponse{Sessions: sessions}) @@ -97,7 +97,7 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { } sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt, AgentRules: in.AgentRules}) if err != nil { - writeSessionError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusCreated, SessionResponse{Session: sess}) @@ -110,7 +110,7 @@ func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) { } sess, err := c.Svc.Get(r.Context(), sessionID(r)) if err != nil { - writeSessionError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sess}) @@ -132,7 +132,7 @@ func (c *SessionsController) rename(w http.ResponseWriter, r *http.Request) { return } if err := c.Svc.Rename(r.Context(), sessionID(r), displayName); err != nil { - writeSessionError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, RenameSessionResponse{OK: true, SessionID: sessionID(r), DisplayName: displayName}) @@ -145,7 +145,7 @@ func (c *SessionsController) restore(w http.ResponseWriter, r *http.Request) { } sess, err := c.Svc.Restore(r.Context(), sessionID(r)) if err != nil { - writeSessionError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, RestoreSessionResponse{OK: true, SessionID: sessionID(r), Session: sess}) @@ -158,7 +158,7 @@ func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) { } freed, err := c.Svc.Kill(r.Context(), sessionID(r)) if err != nil { - writeSessionError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, KillSessionResponse{OK: true, SessionID: sessionID(r), Freed: freed}) @@ -171,7 +171,7 @@ func (c *SessionsController) cleanup(w http.ResponseWriter, r *http.Request) { } cleaned, err := c.Svc.Cleanup(r.Context(), domain.ProjectID(r.URL.Query().Get("project"))) if err != nil { - writeSessionError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, CleanupSessionsResponse{OK: true, Cleaned: cleaned}) @@ -197,7 +197,7 @@ func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { } message := stripUnsafeControlChars(in.Message) if err := c.Svc.Send(r.Context(), sessionID(r), message); err != nil { - writeSessionError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, SendSessionMessageResponse{OK: true, SessionID: sessionID(r), Message: message}) @@ -217,23 +217,9 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROJECT_ID_REQUIRED", "projectId is required", nil) return } - if in.Clean { - active := true - orchestrators, err := c.Svc.List(r.Context(), sessionsvc.ListFilter{ProjectID: in.ProjectID, Active: &active, OrchestratorOnly: true}) - if err != nil { - writeSessionError(w, r, err) - return - } - for _, existing := range orchestrators { - if _, err := c.Svc.Kill(r.Context(), existing.ID); err != nil { - writeSessionError(w, r, err) - return - } - } - } - sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, Kind: domain.KindOrchestrator}) + sess, err := c.Svc.SpawnOrchestrator(r.Context(), in.ProjectID, in.Clean) if err != nil { - writeSessionError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusCreated, SpawnOrchestratorResponse{ @@ -248,7 +234,7 @@ func (c *SessionsController) listOrchestrators(w http.ResponseWriter, r *http.Re } sessions, err := c.Svc.List(r.Context(), sessionsvc.ListFilter{OrchestratorOnly: true}) if err != nil { - writeSessionError(w, r, err) + envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, ListSessionsResponse{Sessions: sessions}) @@ -261,11 +247,11 @@ func (c *SessionsController) getOrchestrator(w http.ResponseWriter, r *http.Requ } sess, err := c.Svc.Get(r.Context(), orchestratorID(r)) if err != nil { - writeSessionError(w, r, err) + envelope.WriteError(w, r, err) return } if sess.Kind != domain.KindOrchestrator { - writeSessionError(w, r, sessionmanager.ErrNotFound) + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "SESSION_NOT_FOUND", "Unknown session", nil) return } envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sess}) @@ -314,20 +300,3 @@ func stripUnsafeControlChars(message string) string { return r }, message) } - -func writeSessionError(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, sessionmanager.ErrNotFound): - envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "SESSION_NOT_FOUND", "Unknown session", nil) - case errors.Is(err, sessionmanager.ErrNotRestorable): - envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_NOT_RESTORABLE", "Session is not restorable", nil) - case errors.Is(err, sessionmanager.ErrTerminated): - envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_TERMINATED", "Session is terminated", nil) - case errors.Is(err, sessionmanager.ErrIncompleteHandle): - envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_INCOMPLETE_HANDLE", "Session is missing runtime or workspace handles", nil) - case errors.Is(err, sessionmanager.ErrProjectNotResolvable): - envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil) - default: - envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "SESSION_OPERATION_FAILED", "Session operation failed", nil) - } -} diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 4d33fbc7d8..706427d75b 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -12,9 +12,9 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" "github.com/aoagents/agent-orchestrator/backend/internal/ports" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" - sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) type fakeSessionService struct { @@ -54,6 +54,22 @@ func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (do return s, nil } +func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) { + if clean { + active := true + existing, err := f.List(ctx, sessionsvc.ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true}) + if err != nil { + return domain.Session{}, err + } + for _, o := range existing { + if _, err := f.Kill(ctx, o.ID); err != nil { + return domain.Session{}, err + } + } + } + return f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) +} + func (f *fakeSessionService) Get(_ context.Context, id domain.SessionID) (domain.Session, error) { return f.sessions[id], nil } @@ -85,7 +101,7 @@ func (f *fakeSessionService) Cleanup(_ context.Context, project domain.ProjectID func (f *fakeSessionService) Rename(_ context.Context, id domain.SessionID, displayName string) error { s, ok := f.sessions[id] if !ok { - return sessionmanager.ErrNotFound + return apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } s.DisplayName = displayName f.sessions[id] = s @@ -100,14 +116,14 @@ func (f *fakeSessionService) Send(_ context.Context, _ domain.SessionID, message func newSessionTestServer(t *testing.T, svc *fakeSessionService) *httptest.Server { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{Sessions: svc})) + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{Sessions: svc}, httpd.ControlDeps{})) t.Cleanup(srv.Close) return srv } func TestSessionsRoutes_DefaultToStubsWithoutService(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil)) + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{})) t.Cleanup(srv.Close) body, status, headers := doRequest(t, srv, "GET", "/api/v1/sessions", "") diff --git a/backend/internal/httpd/envelope/envelope.go b/backend/internal/httpd/envelope/envelope.go index 3e1b2ade65..3768d19fe3 100644 --- a/backend/internal/httpd/envelope/envelope.go +++ b/backend/internal/httpd/envelope/envelope.go @@ -2,9 +2,12 @@ package envelope import ( "encoding/json" + "errors" "net/http" "github.com/go-chi/chi/v5/middleware" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" ) // APIError is the locked wire shape for every non-2xx response. @@ -33,3 +36,33 @@ func WriteAPIError(w http.ResponseWriter, r *http.Request, status int, kind, cod Details: details, }) } + +// WriteError is the single path from any service error to the wire envelope. It +// renders an *apierr.Error (anywhere in the chain) using its Kind, and falls +// back to a 500 for any other error so internal details never leak. This is the +// only place an apierr.Kind is translated into an HTTP status and wire word. +func WriteError(w http.ResponseWriter, r *http.Request, err error) { + var e *apierr.Error + if errors.As(err, &e) { + status, kind := httpStatus(e.Kind) + WriteAPIError(w, r, status, kind, e.Code, e.Message, e.Details) + return + } + WriteAPIError(w, r, http.StatusInternalServerError, "internal", "INTERNAL_ERROR", "Internal server error", nil) +} + +// httpStatus maps a semantic failure Kind to its HTTP status and wire word. +func httpStatus(k apierr.Kind) (int, string) { + switch k { + case apierr.KindInvalid: + return http.StatusBadRequest, "bad_request" + case apierr.KindNotFound: + return http.StatusNotFound, "not_found" + case apierr.KindConflict: + return http.StatusConflict, "conflict" + case apierr.KindInternal: + return http.StatusInternalServerError, "internal" + default: + return http.StatusInternalServerError, "internal" + } +} diff --git a/backend/internal/httpd/logger_test.go b/backend/internal/httpd/logger_test.go index ddd6d308f8..a8e65b4ef7 100644 --- a/backend/internal/httpd/logger_test.go +++ b/backend/internal/httpd/logger_test.go @@ -9,7 +9,7 @@ import ( ) func TestNewRouterAllowsNilLogger(t *testing.T) { - router := NewRouter(config.Config{}, nil, nil) + router := newTestRouter(config.Config{}, nil, nil) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) router.ServeHTTP(rec, req) diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 5d73156d79..1866b0341a 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -17,8 +17,16 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) -// NewRouter builds the root router with the standard middleware stack and the -// health probes mounted. +// ControlDeps carries the daemon-control hooks the router exposes, such as the +// callback that requests a graceful shutdown. +type ControlDeps struct { + RequestShutdown func() +} + +// NewRouterWithControl builds the root router with the standard middleware +// stack, the API surface, and the daemon-control hooks wired from ControlDeps. +// Missing Managers in deps keep routes registered but return OpenAPI-backed 501 +// responses. // // Middleware order (outermost first): // @@ -29,24 +37,6 @@ import ( // // The per-request timeout is deliberately not global: it wraps only bounded // REST routes, never long-lived terminal streams or health probes. -func NewRouter(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) chi.Router { - return NewRouterWithAPI(cfg, log, termMgr, APIDeps{}) -} - -// ControlDeps carries the daemon-control hooks the router exposes, such as the -// callback that requests a graceful shutdown. -type ControlDeps struct { - RequestShutdown func() -} - -// NewRouterWithAPI is the dependency-injected variant. Missing Managers keep -// routes registered but return OpenAPI-backed 501 responses. -func NewRouterWithAPI(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps) chi.Router { - return NewRouterWithControl(cfg, log, termMgr, deps, ControlDeps{}) -} - -// NewRouterWithControl is NewRouterWithAPI plus daemon-control hooks: it mounts -// the same API surface and additionally wires the ControlDeps callbacks. func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps, control ControlDeps) chi.Router { log = loggerOrDefault(log) r := chi.NewRouter() diff --git a/backend/internal/httpd/server.go b/backend/internal/httpd/server.go index a1b8e6157e..6ea67a04f6 100644 --- a/backend/internal/httpd/server.go +++ b/backend/internal/httpd/server.go @@ -29,15 +29,10 @@ type Server struct { shutdownOnce sync.Once } -// New constructs a Server and binds the listener immediately so a port -// conflict fails fast — before any running.json is written. The caller owns -// the returned Server's lifecycle via Run. termMgr may be nil, in which case -// the /mux terminal surface is not mounted. -func New(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) (*Server, error) { - return NewWithDeps(cfg, log, termMgr, APIDeps{}) -} - -// NewWithDeps constructs a Server with API dependencies supplied by the daemon. +// NewWithDeps constructs a Server with API dependencies supplied by the daemon +// and binds the listener immediately so a port conflict fails fast — before any +// running.json is written. The caller owns the returned Server's lifecycle via +// Run. termMgr may be nil, in which case the /mux terminal surface is not mounted. func NewWithDeps(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps) (*Server, error) { log = loggerOrDefault(log) ln, err := net.Listen("tcp", cfg.Addr()) diff --git a/backend/internal/httpd/server_test.go b/backend/internal/httpd/server_test.go index 2b7ba4f3b9..fca87cc57a 100644 --- a/backend/internal/httpd/server_test.go +++ b/backend/internal/httpd/server_test.go @@ -19,7 +19,7 @@ func discardLogger() *slog.Logger { } func TestHealthProbes(t *testing.T) { - router := NewRouter(config.Config{}, discardLogger(), nil) + router := newTestRouter(config.Config{}, discardLogger(), nil) srv := httptest.NewServer(router) defer srv.Close() @@ -51,7 +51,7 @@ func TestServerLifecycle(t *testing.T) { RunFilePath: runPath, } - srv, err := New(cfg, discardLogger(), nil) + srv, err := NewWithDeps(cfg, discardLogger(), nil, APIDeps{}) if err != nil { t.Fatalf("New: %v", err) } @@ -100,7 +100,7 @@ func TestServerShutdownEndpoint(t *testing.T) { RunFilePath: runPath, } - srv, err := New(cfg, discardLogger(), nil) + srv, err := NewWithDeps(cfg, discardLogger(), nil, APIDeps{}) if err != nil { t.Fatalf("New: %v", err) } @@ -159,7 +159,7 @@ func waitForHealth(t *testing.T, base string) { func TestNewFailsOnPortConflict(t *testing.T) { cfg := config.Config{Host: "127.0.0.1", Port: 0, RunFilePath: filepath.Join(t.TempDir(), "r.json")} - first, err := New(cfg, discardLogger(), nil) + first, err := NewWithDeps(cfg, discardLogger(), nil, APIDeps{}) if err != nil { t.Fatalf("first New: %v", err) } @@ -167,7 +167,7 @@ func TestNewFailsOnPortConflict(t *testing.T) { // Re-bind the exact port the first server took. conflict := config.Config{Host: "127.0.0.1", Port: first.boundPort(), RunFilePath: cfg.RunFilePath} - if _, err := New(conflict, discardLogger(), nil); err == nil { + if _, err := NewWithDeps(conflict, discardLogger(), nil, APIDeps{}); err == nil { t.Fatal("New on an already-bound port = nil error, want bind failure") } } diff --git a/backend/internal/httpd/terminal_mux_test.go b/backend/internal/httpd/terminal_mux_test.go index fc7bca5fa7..a044629dda 100644 --- a/backend/internal/httpd/terminal_mux_test.go +++ b/backend/internal/httpd/terminal_mux_test.go @@ -37,7 +37,7 @@ type terminalMuxFrame struct { func dialMux(t *testing.T, mgr *terminal.Manager) (*websocket.Conn, func()) { t.Helper() - router := NewRouter(config.Config{}, discardLogger(), mgr) + router := newTestRouter(config.Config{}, discardLogger(), mgr) ts := httptest.NewServer(router) url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/mux" diff --git a/backend/internal/httpd/testhelpers_test.go b/backend/internal/httpd/testhelpers_test.go new file mode 100644 index 0000000000..ed1d639b2e --- /dev/null +++ b/backend/internal/httpd/testhelpers_test.go @@ -0,0 +1,17 @@ +package httpd + +import ( + "log/slog" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/terminal" +) + +// newTestRouter builds a router with empty API and control deps. It is the +// test-only convenience that used to be the exported NewRouter wrapper; keeping +// it here leaves the package's exported surface to the production constructors. +func newTestRouter(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager) chi.Router { + return NewRouterWithControl(cfg, log, termMgr, APIDeps{}, ControlDeps{}) +} diff --git a/backend/internal/service/project/errors.go b/backend/internal/service/project/errors.go deleted file mode 100644 index 9b61c49fbd..0000000000 --- a/backend/internal/service/project/errors.go +++ /dev/null @@ -1,37 +0,0 @@ -package project - -// Error is the service-level error shape controllers translate into the -// locked HTTP APIError envelope without knowing store internals. -type Error struct { - Kind string - Code string - Message string - Details map[string]any -} - -func (e *Error) Error() string { - if e == nil { - return "" - } - return e.Message -} - -func newError(kind, code, message string, details map[string]any) *Error { - return &Error{Kind: kind, Code: code, Message: message, Details: details} -} - -func badRequest(code, message string, details map[string]any) *Error { - return newError("bad_request", code, message, details) -} - -func notFound(code, message string) *Error { - return newError("not_found", code, message, nil) -} - -func conflict(code, message string, details map[string]any) *Error { - return newError("conflict", code, message, details) -} - -func internal(code, message string) *Error { - return newError("internal", code, message, nil) -} diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index 89cf5a10ba..f19d776b59 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -11,6 +11,7 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" ) // Manager is the controller-facing contract for the /api/v1/projects surface. @@ -46,7 +47,7 @@ func New(store Store) *Service { func (m *Service) List(ctx context.Context) ([]Summary, error) { projects, err := m.store.ListProjects(ctx) if err != nil { - return nil, internal("PROJECTS_LIST_FAILED", "Failed to load projects") + return nil, apierr.Internal("PROJECTS_LIST_FAILED", "Failed to load projects") } out := make([]Summary, 0, len(projects)) for _, row := range projects { @@ -66,10 +67,10 @@ func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, erro } row, ok, err := m.store.GetProject(ctx, string(id)) if err != nil { - return GetResult{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + return GetResult{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project") } if !ok || !row.ArchivedAt.IsZero() { - return GetResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") } p := projectFromRow(row) return GetResult{Status: "ok", Project: &p}, nil @@ -82,7 +83,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return Project{}, err } if !isGitRepo(path) { - return Project{}, badRequest("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil) + return Project{}, apierr.Invalid("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil) } id := defaultProjectID(path) @@ -102,17 +103,17 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { } if existing, ok, err := m.store.FindProjectByPath(ctx, path); err != nil { - return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + return Project{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project") } else if ok { - return Project{}, conflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{ + return Project{}, apierr.Conflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{ "existingProjectId": existing.ID, "suggestedProjectId": string(m.suggestID(ctx, id)), }) } if existing, ok, err := m.store.GetProject(ctx, string(id)); err != nil { - return Project{}, internal("PROJECT_LOAD_FAILED", "Failed to load project") + return Project{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project") } else if ok && existing.ArchivedAt.IsZero() && existing.Path != path { - return Project{}, conflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{ + return Project{}, apierr.Conflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{ "existingProjectId": existing.ID, "suggestedProjectId": string(m.suggestID(ctx, id)), }) @@ -125,7 +126,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { RegisteredAt: time.Now(), } if err := m.store.UpsertProject(ctx, row); err != nil { - return Project{}, err + return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") } return projectFromRow(row), nil } @@ -137,10 +138,10 @@ func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult } ok, err := m.store.ArchiveProject(ctx, string(id), time.Now()) if err != nil { - return RemoveResult{}, internal("PROJECT_REMOVE_FAILED", "Failed to remove project") + return RemoveResult{}, apierr.Internal("PROJECT_REMOVE_FAILED", "Failed to remove project") } if !ok { - return RemoveResult{}, notFound("PROJECT_NOT_FOUND", "Unknown project") + return RemoveResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") } return RemoveResult{ProjectID: id, RemovedStorageDir: false}, nil } @@ -174,12 +175,12 @@ func displayName(row domain.ProjectRecord) string { func normalizePath(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { - return "", badRequest("PATH_REQUIRED", "Repository path is required", nil) + return "", apierr.Invalid("PATH_REQUIRED", "Repository path is required", nil) } if strings.HasPrefix(raw, "~") { home, err := os.UserHomeDir() if err != nil { - return "", badRequest("INVALID_PATH", "Repository path could not be expanded", nil) + return "", apierr.Invalid("INVALID_PATH", "Repository path could not be expanded", nil) } if raw == "~" { raw = home @@ -189,7 +190,7 @@ func normalizePath(raw string) (string, error) { } abs, err := filepath.Abs(raw) if err != nil { - return "", badRequest("INVALID_PATH", "Repository path is invalid", nil) + return "", apierr.Invalid("INVALID_PATH", "Repository path is invalid", nil) } return filepath.Clean(abs), nil } @@ -232,7 +233,7 @@ func validateProjectID(id domain.ProjectID) error { // (e.g. "a..b") passes it yet yields a branch like "ao/a..b-1" that git's // check-ref-format rejects — surfacing as an opaque 500 at spawn time. if raw == "" || raw == "." || strings.Contains(raw, "..") || strings.ContainsAny(raw, `/\`) || !projectIDPattern.MatchString(raw) { - return badRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil) + return apierr.Invalid("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil) } return nil } diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 3dd0208eea..69e8fd8078 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" "github.com/aoagents/agent-orchestrator/backend/internal/service/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) @@ -35,12 +36,12 @@ func gitRepo(t *testing.T) string { func ptr(s string) *string { return &s } -// wantCode asserts err is a *project.Error carrying the given machine code. +// wantCode asserts err is an *apierr.Error carrying the given machine code. func wantCode(t *testing.T, err error, code string) { t.Helper() - var e *project.Error + var e *apierr.Error if !errors.As(err, &e) { - t.Fatalf("error = %v, want *project.Error", err) + t.Fatalf("error = %v, want *apierr.Error", err) } if e.Code != code { t.Fatalf("code = %q, want %q", e.Code, code) diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 226de1d69b..8eefd991f4 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -2,11 +2,13 @@ package session import ( "context" + "errors" "fmt" "strings" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" "github.com/aoagents/agent-orchestrator/backend/internal/ports" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) @@ -28,11 +30,21 @@ type ListFilter struct { Fresh bool } +// commander is the command-side surface Service delegates to: the +// *sessionmanager.Manager in production, a fake in tests. +type commander interface { + Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) + Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error) + Kill(ctx context.Context, id domain.SessionID) (bool, error) + Send(ctx context.Context, id domain.SessionID, message string) error + Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) +} + // Service is the controller-facing session service. It delegates command-side // session operations to the internal sessionmanager.Manager and owns read-model // assembly, including user-facing display status derivation. type Service struct { - manager *sessionmanager.Manager + manager commander store Store } @@ -45,42 +57,63 @@ func New(manager *sessionmanager.Manager, store Store) *Service { func (s *Service) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { rec, err := s.manager.Spawn(ctx, cfg) if err != nil { - return domain.Session{}, err + return domain.Session{}, toAPIError(err) } return s.toSession(ctx, rec) } +// SpawnOrchestrator spawns an orchestrator session for a project. When clean is +// true it first tears down any active orchestrator(s) for that project so the new +// one is the only live coordinator — a business rule that belongs here, not in the +// HTTP controller. +func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) { + if clean { + active := true + existing, err := s.List(ctx, ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true}) + if err != nil { + return domain.Session{}, err + } + for _, orch := range existing { + if _, err := s.Kill(ctx, orch.ID); err != nil { + return domain.Session{}, err + } + } + } + return s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) +} + // Restore relaunches a terminated session and returns the API-facing read model. func (s *Service) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, err := s.manager.Restore(ctx, id) if err != nil { - return domain.Session{}, err + return domain.Session{}, toAPIError(err) } return s.toSession(ctx, rec) } // Kill delegates terminal intent and teardown to the internal manager. func (s *Service) Kill(ctx context.Context, id domain.SessionID) (bool, error) { - return s.manager.Kill(ctx, id) + freed, err := s.manager.Kill(ctx, id) + return freed, toAPIError(err) } // Send delegates agent messaging to the internal manager. func (s *Service) Send(ctx context.Context, id domain.SessionID, message string) error { - return s.manager.Send(ctx, id, message) + return toAPIError(s.manager.Send(ctx, id, message)) } // Rename updates the user-facing session display name. func (s *Service) Rename(ctx context.Context, id domain.SessionID, displayName string) error { displayName = strings.TrimSpace(displayName) if displayName == "" { - return fmt.Errorf("rename %s: display name is required", id) + return apierr.Invalid("DISPLAY_NAME_REQUIRED", "Display name is required", nil) } renamed, err := s.store.RenameSession(ctx, id, displayName, time.Now().UTC()) if err != nil { return fmt.Errorf("rename %s: %w", id, err) } if !renamed { - return fmt.Errorf("rename %s: %w", id, sessionmanager.ErrNotFound) + return apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } return nil } @@ -138,18 +171,40 @@ func matchesSessionFilter(rec domain.SessionRecord, filter ListFilter) bool { return true } -// Get returns one session as an enriched display model, or sessionmanager.ErrNotFound if it is absent. +// Get returns one session as an enriched display model, or an apierr.NotFound +// (SESSION_NOT_FOUND) if it is absent. func (s *Service) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, ok, err := s.store.GetSession(ctx, id) if err != nil { return domain.Session{}, fmt.Errorf("get %s: %w", id, err) } if !ok { - return domain.Session{}, fmt.Errorf("get %s: %w", id, sessionmanager.ErrNotFound) + return domain.Session{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } return s.toSession(ctx, rec) } +// toAPIError maps the session engine's sentinel errors to their REST API +// equivalents; an unrecognized error passes through and surfaces as a 500. +func toAPIError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, sessionmanager.ErrNotFound): + return apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") + case errors.Is(err, sessionmanager.ErrNotRestorable): + return apierr.Conflict("SESSION_NOT_RESTORABLE", "Session is not restorable", nil) + case errors.Is(err, sessionmanager.ErrTerminated): + return apierr.Conflict("SESSION_TERMINATED", "Session is terminated", nil) + case errors.Is(err, sessionmanager.ErrIncompleteHandle): + return apierr.Conflict("SESSION_INCOMPLETE_HANDLE", "Session is missing runtime or workspace handles", nil) + case errors.Is(err, sessionmanager.ErrProjectNotResolvable): + return apierr.Invalid("PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil) + default: + return err + } +} + func (s *Service) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) { pr, ok, err := s.store.GetDisplayPRFactsForSession(ctx, rec.ID) if err != nil { diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index 682841ef01..702f367310 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -8,7 +8,8 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) type fakeStore struct { @@ -98,7 +99,72 @@ func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) { st := newFakeStore() err := (&Service{store: st}).Rename(context.Background(), "mer-404", "Missing") - if !errors.Is(err, sessionmanager.ErrNotFound) { - t.Fatalf("err = %v, want ErrNotFound", err) + var e *apierr.Error + if !errors.As(err, &e) || e.Kind != apierr.KindNotFound || e.Code != "SESSION_NOT_FOUND" { + t.Fatalf("err = %v, want apierr NotFound SESSION_NOT_FOUND", err) + } +} + +// fakeCommander records Kill/Spawn calls so a test can assert the +// clean-orchestrator ordering without wiring a real session engine. +type fakeCommander struct { + killed []domain.SessionID + spawned bool + killsAtSpawn int +} + +func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) { + f.spawned = true + f.killsAtSpawn = len(f.killed) + return domain.SessionRecord{ID: "mer-9", ProjectID: cfg.ProjectID, Kind: cfg.Kind}, nil +} +func (f *fakeCommander) Restore(context.Context, domain.SessionID) (domain.SessionRecord, error) { + return domain.SessionRecord{}, nil +} +func (f *fakeCommander) Kill(_ context.Context, id domain.SessionID) (bool, error) { + f.killed = append(f.killed, id) + return true, nil +} +func (f *fakeCommander) Send(context.Context, domain.SessionID, string) error { return nil } +func (f *fakeCommander) Cleanup(context.Context, domain.ProjectID) ([]domain.SessionID, error) { + return nil, nil +} + +func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) { + st := newFakeStore() + // Two active orchestrators plus an unrelated worker and a terminated + // orchestrator that must be left alone. + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator} + st.sessions["mer-2"] = domain.SessionRecord{ID: "mer-2", ProjectID: "mer", Kind: domain.KindOrchestrator} + st.sessions["mer-3"] = domain.SessionRecord{ID: "mer-3", ProjectID: "mer", Kind: domain.KindWorker} + st.sessions["mer-4"] = domain.SessionRecord{ID: "mer-4", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true} + + fc := &fakeCommander{} + svc := &Service{manager: fc, store: st} + + if _, err := svc.SpawnOrchestrator(context.Background(), "mer", true); err != nil { + t.Fatalf("SpawnOrchestrator: %v", err) + } + + if len(fc.killed) != 2 { + t.Fatalf("killed = %v, want the two active orchestrators", fc.killed) + } + if !fc.spawned || fc.killsAtSpawn != 2 { + t.Fatalf("spawn must run after both kills: spawned=%v killsAtSpawn=%d", fc.spawned, fc.killsAtSpawn) + } +} + +func TestSpawnOrchestratorNoCleanSkipsKills(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator} + + fc := &fakeCommander{} + svc := &Service{manager: fc, store: st} + + if _, err := svc.SpawnOrchestrator(context.Background(), "mer", false); err != nil { + t.Fatalf("SpawnOrchestrator: %v", err) + } + if len(fc.killed) != 0 || !fc.spawned { + t.Fatalf("clean=false must spawn without kills: killed=%v spawned=%v", fc.killed, fc.spawned) } } diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 40b167660e..cf9b5960d9 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -12,7 +12,8 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// Sentinel errors returned by the Session Manager. +// Sentinel errors returned by the Session Manager; callers match them with +// errors.Is. var ( ErrNotFound = errors.New("session: not found") ErrNotRestorable = errors.New("session: not restorable (not terminal)") From 0ffe7145f5201302c3e9f14ebd98241571f87b4a Mon Sep 17 00:00:00 2001 From: codebanditssss Date: Wed, 3 Jun 2026 21:57:38 +0530 Subject: [PATCH 114/250] chore: add gitignore for landing build artifacts --- frontend/src/landing/.gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 frontend/src/landing/.gitignore diff --git a/frontend/src/landing/.gitignore b/frontend/src/landing/.gitignore new file mode 100644 index 0000000000..beb7067f5d --- /dev/null +++ b/frontend/src/landing/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.next/ +next-env.d.ts From 71e05dce4a4900f1b32691bbfb69fe085a6f02c1 Mon Sep 17 00:00:00 2001 From: codebanditssss Date: Wed, 3 Jun 2026 21:57:38 +0530 Subject: [PATCH 115/250] chore: scaffold next.js and tailwind for landing page --- frontend/src/landing/next.config.mjs | 4 + frontend/src/landing/package-lock.json | 1651 +++++++++++++++++++++++ frontend/src/landing/package.json | 23 + frontend/src/landing/postcss.config.mjs | 7 + frontend/src/landing/tsconfig.json | 20 + 5 files changed, 1705 insertions(+) create mode 100644 frontend/src/landing/next.config.mjs create mode 100644 frontend/src/landing/package-lock.json create mode 100644 frontend/src/landing/package.json create mode 100644 frontend/src/landing/postcss.config.mjs create mode 100644 frontend/src/landing/tsconfig.json diff --git a/frontend/src/landing/next.config.mjs b/frontend/src/landing/next.config.mjs new file mode 100644 index 0000000000..4678774e6d --- /dev/null +++ b/frontend/src/landing/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/frontend/src/landing/package-lock.json b/frontend/src/landing/package-lock.json new file mode 100644 index 0000000000..189fd9dd7c --- /dev/null +++ b/frontend/src/landing/package-lock.json @@ -0,0 +1,1651 @@ +{ + "name": "ao-landing-preview", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ao-landing-preview", + "version": "0.0.0", + "dependencies": { + "next": "^15", + "react": "^19", + "react-dom": "^19" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz", + "integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz", + "integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz", + "integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz", + "integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz", + "integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz", + "integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz", + "integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz", + "integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", + "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.16", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", + "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz", + "integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.19", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.19", + "@next/swc-darwin-x64": "15.5.19", + "@next/swc-linux-arm64-gnu": "15.5.19", + "@next/swc-linux-arm64-musl": "15.5.19", + "@next/swc-linux-x64-gnu": "15.5.19", + "@next/swc-linux-x64-musl": "15.5.19", + "@next/swc-win32-arm64-msvc": "15.5.19", + "@next/swc-win32-x64-msvc": "15.5.19", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/src/landing/package.json b/frontend/src/landing/package.json new file mode 100644 index 0000000000..f255222ec5 --- /dev/null +++ b/frontend/src/landing/package.json @@ -0,0 +1,23 @@ +{ + "name": "ao-landing-preview", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3002", + "build": "next build", + "start": "next start -p 3002" + }, + "dependencies": { + "next": "^15", + "react": "^19", + "react-dom": "^19" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/src/landing/postcss.config.mjs b/frontend/src/landing/postcss.config.mjs new file mode 100644 index 0000000000..61e36849cf --- /dev/null +++ b/frontend/src/landing/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/src/landing/tsconfig.json b/frontend/src/landing/tsconfig.json new file mode 100644 index 0000000000..cae02ec533 --- /dev/null +++ b/frontend/src/landing/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From e1d7ce6b0f8c375a18513d3a39a3fe8881a1b9d9 Mon Sep 17 00:00:00 2001 From: codebanditssss Date: Wed, 3 Jun 2026 21:57:38 +0530 Subject: [PATCH 116/250] style: add landing design tokens and global styles --- frontend/src/landing/styles/globals.css | 522 ++++++++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 frontend/src/landing/styles/globals.css diff --git a/frontend/src/landing/styles/globals.css b/frontend/src/landing/styles/globals.css new file mode 100644 index 0000000000..b36d2f7fe1 --- /dev/null +++ b/frontend/src/landing/styles/globals.css @@ -0,0 +1,522 @@ +@import "tailwindcss"; + +:root { + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: var(--font-jetbrains-mono), ui-monospace, "SFMono-Regular", Menlo, monospace; + + /* ── Light mode (default) ─────────────────────────────────────── */ + --color-bg-base: #f5f3f0; + --color-bg-surface: #ffffff; + --color-bg-elevated: #f9f7f5; + --color-bg-subtle: rgba(120, 100, 80, 0.06); + --color-bg-inset: #f0ede9; + --color-bg-sidebar: #f0ede9; + + --color-text-primary: #1c1917; + --color-text-secondary: #57534e; + --color-text-tertiary: #78716c; + + --color-border-default: #d6d3d1; + --color-border-subtle: rgba(0, 0, 0, 0.06); + + --color-accent: #5c64b5; + --color-accent-amber: #d97706; + --color-accent-amber-dim: rgba(217, 119, 6, 0.1); + --color-accent-amber-border: rgba(217, 119, 6, 0.3); + + --color-scrollbar: rgba(0, 0, 0, 0.08); +} + +/* ── Dark mode ────────────────────────────────────────────────── */ +.dark { + --color-bg-base: #121110; + --color-bg-surface: #1a1918; + --color-bg-elevated: #242220; + --color-bg-subtle: #2b2826; + --color-bg-inset: #161514; + --color-bg-sidebar: #161514; + + --color-text-primary: #f0ece8; + --color-text-secondary: #a8a29e; + --color-text-tertiary: #8b8682; + + --color-border-default: rgba(255, 240, 220, 0.14); + --color-border-subtle: rgba(255, 240, 220, 0.08); + + --color-accent: #8b9cf7; + --color-accent-amber: #f97316; + --color-accent-amber-dim: rgba(249, 115, 22, 0.12); + --color-accent-amber-border: rgba(249, 115, 22, 0.4); + + --color-scrollbar: rgba(255, 240, 220, 0.15); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + min-height: 100%; +} + +body { + font-family: var(--font-sans); + background: var(--color-bg-base); + color: var(--color-text-primary); +} + +a { + color: inherit; +} + +/* ── Landing Page ─────────────────────────────────────────────────── */ +.landing-page { + --landing-bg: #121110; + --landing-fg: #f0ece8; + --landing-muted: #a8a29e; + --landing-muted-dim: #57534e; + --landing-accent: #f97316; + --landing-border-subtle: rgba(255, 240, 220, 0.07); + --landing-border-default: rgba(255, 240, 220, 0.13); + --landing-border-strong: rgba(255, 240, 220, 0.24); + --landing-surface: #1a1918; + --landing-card-bg: #1c1b19; + background: var(--landing-bg); + color: var(--landing-fg); + font-family: var(--font-sans, -apple-system, "SF Pro Text", system-ui, sans-serif); +} + +.liquid-glass-solid { + background: var(--landing-accent); + color: #121110; + font-weight: 500; + border: none; +} + +.liquid-glass-solid:hover { + opacity: 0.9; +} + +.landing-card { + background: var(--landing-card-bg); + border: 1px solid var(--landing-border-subtle); + transition: border-color 0.2s, background 0.2s; +} + +.landing-card:hover { + border-color: var(--landing-border-default); +} + +.landing-hero-grid { + background-image: radial-gradient(rgba(255, 240, 220, 0.04) 1px, transparent 1px); + background-size: 24px 24px; + mask-image: radial-gradient(ellipse at center, black 30%, transparent 70%); + -webkit-mask-image: radial-gradient(ellipse at center, black 30%, transparent 70%); +} + +@keyframes landing-fade-rise { + from { + opacity: 0; + transform: translateY(24px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes landing-pulse-dot { + 0%, + 100% { + opacity: 0.4; + } + + 50% { + opacity: 1; + } +} + +.landing-fade-rise { + animation: landing-fade-rise 0.8s ease-out both; +} + +.landing-fade-rise-d1 { + animation: landing-fade-rise 0.8s ease-out 0.2s both; +} + +.landing-fade-rise-d2 { + animation: landing-fade-rise 0.8s ease-out 0.4s both; +} + +.landing-reveal { + opacity: 0; + transform: translateY(32px); + transition: opacity 0.8s ease-out, transform 0.8s ease-out; +} + +.landing-reveal.visible { + opacity: 1; + transform: translateY(0); +} + +.landing-agent-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(134, 239, 172, 0.7); + animation: landing-pulse-dot 2s ease-in-out infinite; +} + +/* Terminal typing animations */ +.landing-line-appear { + animation: landing-fade-rise 0.3s ease-out both; +} + +@keyframes landing-cursor-blink { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0; + } +} + +.landing-cursor-blink { + animation: landing-cursor-blink 0.8s step-end infinite; +} + +/* Hero kanban ticker */ +@keyframes landing-kanban-ticker-in { + from { + opacity: 0; + transform: translateY(2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.landing-kanban-ticker { + display: inline-block; + animation: landing-kanban-ticker-in 0.32s cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Feature demos */ +@keyframes landing-feature-bar { + 0% { + width: 0%; + } + 78% { + width: 100%; + } + 90% { + width: 100%; + } + 91% { + width: 0%; + } + 100% { + width: 0%; + } +} + +.landing-feature-bar { + width: 0%; + animation-name: landing-feature-bar; + animation-iteration-count: infinite; + animation-timing-function: cubic-bezier(0.4, 0.8, 0.6, 1); +} + +@keyframes landing-spin { + to { + transform: rotate(360deg); + } +} + +.landing-spin { + animation: landing-spin 0.9s linear infinite; +} + +@keyframes landing-chip-swap { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.landing-chip-swap { + animation: landing-chip-swap 0.28s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes landing-stream-in { + from { + opacity: 0; + transform: translateY(3px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.landing-stream-line { + animation: landing-stream-in 0.26s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes landing-sse-pulse { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } +} + +.landing-sse-pulse { + animation: landing-sse-pulse 1.6s ease-in-out infinite; +} + +/* Switcher progress bar */ +@keyframes landing-switcher-progress { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +.landing-switcher-progress { + transform: scaleX(0); + animation-name: landing-switcher-progress; + animation-timing-function: linear; + animation-fill-mode: forwards; + animation-iteration-count: 1; +} + +/* Stacked feature panel — front slides up into view, peek fades in behind */ +@keyframes landing-stack-front { + from { + transform: translateY(28px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes landing-stack-peek { + from { + transform: translateY(40px) scale(0.97); + opacity: 0; + } + to { + transform: translateY(0) scale(1); + opacity: 0.45; + } +} + +.landing-stack-front { + animation: landing-stack-front 0.55s cubic-bezier(0.16, 1, 0.3, 1); +} + +.landing-stack-peek { + opacity: 0.45; + animation: landing-stack-peek 0.55s cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Switcher feature cards swap-in */ +@keyframes landing-feat-card-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.landing-feat-card { + animation: landing-feat-card-in 0.35s cubic-bezier(0.16, 1, 0.3, 1); +} + +.landing-feat-card-front { + animation: landing-feat-card-in 0.4s 0.05s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +/* Workflow pipeline pulse */ +@keyframes landing-node-pulse { + 0% { + box-shadow: 0 0 0 0 currentColor; + opacity: 0.6; + } + + 70% { + box-shadow: 0 0 0 10px transparent; + opacity: 0; + } + + 100% { + box-shadow: 0 0 0 0 transparent; + opacity: 0; + } +} + +.landing-node-pulse { + border: 1.5px solid; + animation: landing-node-pulse 1.5s ease-out infinite; +} + +/* Git-graph node pulse (SVG circle — box-shadow doesn't apply, so animate r/opacity) */ +@keyframes landing-graph-pulse { + 0% { + r: 5px; + opacity: 0.6; + } + + 70% { + r: 13px; + opacity: 0; + } + + 100% { + r: 13px; + opacity: 0; + } +} + +.landing-graph-pulse { + transform-box: fill-box; + animation: landing-graph-pulse 1.5s ease-out infinite; +} + +/* ── Landing page spacing protection ───────────────────────────────────────── + Fumadocs bundles its own Tailwind inside @layer fumadocs. Because that sheet + loads after the root layout, @layer fumadocs has higher cascade priority than + root-level @layer utilities. Its universal preflight reset (* { margin:0; + padding:0 }) zeros any spacing utility not present in fumadocs' own bundle. + These unlayered rules (outside any @layer) beat ALL named layers and restore + the correct values for every margin/padding class used in the landing page. */ + +.landing-page .mt-1 { margin-top: calc(var(--spacing) * 1); } +.landing-page .mt-6 { margin-top: calc(var(--spacing) * 6); } +.landing-page .mt-8 { margin-top: calc(var(--spacing) * 8); } +.landing-page .mt-10 { margin-top: calc(var(--spacing) * 10); } +.landing-page .mt-12 { margin-top: calc(var(--spacing) * 12); } +.landing-page .mt-16 { margin-top: calc(var(--spacing) * 16); } +.landing-page .mt-20 { margin-top: calc(var(--spacing) * 20); } + +.landing-page .mb-1 { margin-bottom: calc(var(--spacing) * 1); } +.landing-page .mb-2 { margin-bottom: calc(var(--spacing) * 2); } +.landing-page .mb-3 { margin-bottom: calc(var(--spacing) * 3); } +.landing-page .mb-4 { margin-bottom: calc(var(--spacing) * 4); } +.landing-page .mb-5 { margin-bottom: calc(var(--spacing) * 5); } +.landing-page .mb-6 { margin-bottom: calc(var(--spacing) * 6); } +.landing-page .mb-8 { margin-bottom: calc(var(--spacing) * 8); } +.landing-page .mb-10 { margin-bottom: calc(var(--spacing) * 10); } +.landing-page .mb-12 { margin-bottom: calc(var(--spacing) * 12); } +.landing-page .mb-16 { margin-bottom: calc(var(--spacing) * 16); } +.landing-page .mb-1\.5 { margin-bottom: calc(var(--spacing) * 1.5); } + +.landing-page .ml-1 { margin-left: calc(var(--spacing) * 1); } +.landing-page .ml-2 { margin-left: calc(var(--spacing) * 2); } +.landing-page .ml-1\.5 { margin-left: calc(var(--spacing) * 1.5); } + +.landing-page .mr-1 { margin-right: calc(var(--spacing) * 1); } +.landing-page .mr-1\.5 { margin-right: calc(var(--spacing) * 1.5); } + +.landing-page .p-2 { padding: calc(var(--spacing) * 2); } +.landing-page .p-3 { padding: calc(var(--spacing) * 3); } +.landing-page .p-6 { padding: calc(var(--spacing) * 6); } +.landing-page .p-7 { padding: calc(var(--spacing) * 7); } +.landing-page .p-8 { padding: calc(var(--spacing) * 8); } +.landing-page .p-2\.5 { padding: calc(var(--spacing) * 2.5); } + +.landing-page .pt-10 { padding-top: calc(var(--spacing) * 10); } +.landing-page .pt-32 { padding-top: calc(var(--spacing) * 32); } + +.landing-page .pb-20 { padding-bottom: calc(var(--spacing) * 20); } + +.landing-page .px-2 { padding-left: calc(var(--spacing) * 2); padding-right: calc(var(--spacing) * 2); } +.landing-page .px-3 { padding-left: calc(var(--spacing) * 3); padding-right: calc(var(--spacing) * 3); } +.landing-page .px-4 { padding-left: calc(var(--spacing) * 4); padding-right: calc(var(--spacing) * 4); } +.landing-page .px-5 { padding-left: calc(var(--spacing) * 5); padding-right: calc(var(--spacing) * 5); } +.landing-page .px-6 { padding-left: calc(var(--spacing) * 6); padding-right: calc(var(--spacing) * 6); } +.landing-page .px-8 { padding-left: calc(var(--spacing) * 8); padding-right: calc(var(--spacing) * 8); } +.landing-page .px-3\.5 { padding-left: calc(var(--spacing) * 3.5); padding-right: calc(var(--spacing) * 3.5); } + +.landing-page .py-1 { padding-top: calc(var(--spacing) * 1); padding-bottom: calc(var(--spacing) * 1); } +.landing-page .py-2 { padding-top: calc(var(--spacing) * 2); padding-bottom: calc(var(--spacing) * 2); } +.landing-page .py-3 { padding-top: calc(var(--spacing) * 3); padding-bottom: calc(var(--spacing) * 3); } +.landing-page .py-4 { padding-top: calc(var(--spacing) * 4); padding-bottom: calc(var(--spacing) * 4); } +.landing-page .py-6 { padding-top: calc(var(--spacing) * 6); padding-bottom: calc(var(--spacing) * 6); } +.landing-page .py-8 { padding-top: calc(var(--spacing) * 8); padding-bottom: calc(var(--spacing) * 8); } +.landing-page .py-20 { padding-top: calc(var(--spacing) * 20); padding-bottom: calc(var(--spacing) * 20); } +.landing-page .py-40 { padding-top: calc(var(--spacing) * 40); padding-bottom: calc(var(--spacing) * 40); } +.landing-page .py-1\.5 { padding-top: calc(var(--spacing) * 1.5); padding-bottom: calc(var(--spacing) * 1.5); } +.landing-page .py-2\.5 { padding-top: calc(var(--spacing) * 2.5); padding-bottom: calc(var(--spacing) * 2.5); } +.landing-page .py-3\.5 { padding-top: calc(var(--spacing) * 3.5); padding-bottom: calc(var(--spacing) * 3.5); } + +/* Arbitrary padding values */ +.landing-page .py-\[100px\] { padding-top: 100px; padding-bottom: 100px; } +.landing-page .py-\[120px\] { padding-top: 120px; padding-bottom: 120px; } +.landing-page .pt-\[60px\] { padding-top: 60px; } +.landing-page .pb-\[120px\] { padding-bottom: 120px; } + +/* Border colors — fumadocs * { border-color: var(--color-fd-border) } persists from docs + navigation. In light mode --color-fd-border = #d6d3d1 which looks bright on the dark + landing background, overriding explicit border-[var(--landing-border-*)] utilities. */ +.landing-page .border-\[var\(--landing-border-subtle\)\] { border-color: var(--landing-border-subtle); } +.landing-page .border-\[var\(--landing-border-default\)\] { border-color: var(--landing-border-default); } +.landing-page .border-\[var\(--landing-border-strong\)\] { border-color: var(--landing-border-strong); } + +/* Font weight — fumadocs heading reset zeroes h1-h6 { font-weight: inherit } */ +.landing-page .font-\[680\] { font-weight: 680; } + +/* Font size (clamp values) — fumadocs heading reset zeroes h1-h6 { font-size: inherit } */ +.landing-page .text-\[clamp\(1\.75rem\,4vw\,2\.75rem\)\] { font-size: clamp(1.75rem, 4vw, 2.75rem); } +.landing-page .text-\[clamp\(1\.375rem\,3vw\,2rem\)\] { font-size: clamp(1.375rem, 3vw, 2rem); } +.landing-page .text-\[clamp\(2rem\,4vw\,3rem\)\] { font-size: clamp(2rem, 4vw, 3rem); } +.landing-page .text-\[clamp\(2rem\,5vw\,3\.5rem\)\] { font-size: clamp(2rem, 5vw, 3.5rem); } + +/* Responsive md: utilities — fumadocs bundles .hidden but not all md: variants, + so its higher-priority layer keeps elements hidden / wrong grid at desktop widths. */ +@media (min-width: 768px) { + .landing-page .md\:flex { display: flex; } + .landing-page .md\:block { display: block; } + .landing-page .md\:hidden { display: none; } + + .landing-page .md\:flex-row { flex-direction: row; } + + .landing-page .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .landing-page .md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .landing-page .md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .landing-page .md\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); } + .landing-page .md\:grid-cols-\[1fr_auto_1fr_1fr\] { grid-template-columns: 1fr auto 1fr 1fr; } + + .landing-page .md\:gap-0 { gap: 0; } + .landing-page .md\:gap-6 { gap: calc(var(--spacing) * 6); } + .landing-page .md\:gap-12 { gap: calc(var(--spacing) * 12); } + + .landing-page .md\:items-center { align-items: center; } + .landing-page .md\:items-baseline { align-items: baseline; } + .landing-page .md\:items-start { align-items: flex-start; } + + .landing-page .md\:order-1 { order: 1; } + .landing-page .md\:order-2 { order: 2; } +} From c74fefbaf5cfe8a5bb53d3270b47bae2834ac6b2 Mon Sep 17 00:00:00 2001 From: codebanditssss Date: Wed, 3 Jun 2026 21:57:38 +0530 Subject: [PATCH 117/250] chore: add landing page images and agent logos --- frontend/src/landing/public/ao-logo.svg | 25 ++++++++++++++++++ .../landing/public/docs/logos/claude-code.svg | 1 + .../src/landing/public/docs/logos/codex.svg | 1 + .../src/landing/public/docs/logos/cursor.svg | 1 + .../landing/public/docs/logos/opencode.svg | 1 + .../src/landing/public/hero-dashboard.png | Bin 0 -> 158151 bytes frontend/src/landing/public/og-image.png | Bin 0 -> 1065631 bytes 7 files changed, 29 insertions(+) create mode 100644 frontend/src/landing/public/ao-logo.svg create mode 100644 frontend/src/landing/public/docs/logos/claude-code.svg create mode 100644 frontend/src/landing/public/docs/logos/codex.svg create mode 100644 frontend/src/landing/public/docs/logos/cursor.svg create mode 100644 frontend/src/landing/public/docs/logos/opencode.svg create mode 100644 frontend/src/landing/public/hero-dashboard.png create mode 100644 frontend/src/landing/public/og-image.png diff --git a/frontend/src/landing/public/ao-logo.svg b/frontend/src/landing/public/ao-logo.svg new file mode 100644 index 0000000000..cfe33f5df2 --- /dev/null +++ b/frontend/src/landing/public/ao-logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/landing/public/docs/logos/claude-code.svg b/frontend/src/landing/public/docs/logos/claude-code.svg new file mode 100644 index 0000000000..98163c7444 --- /dev/null +++ b/frontend/src/landing/public/docs/logos/claude-code.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/frontend/src/landing/public/docs/logos/codex.svg b/frontend/src/landing/public/docs/logos/codex.svg new file mode 100644 index 0000000000..c77ccfdd90 --- /dev/null +++ b/frontend/src/landing/public/docs/logos/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/frontend/src/landing/public/docs/logos/cursor.svg b/frontend/src/landing/public/docs/logos/cursor.svg new file mode 100644 index 0000000000..4b6f0ed522 --- /dev/null +++ b/frontend/src/landing/public/docs/logos/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/frontend/src/landing/public/docs/logos/opencode.svg b/frontend/src/landing/public/docs/logos/opencode.svg new file mode 100644 index 0000000000..3f9fd895f5 --- /dev/null +++ b/frontend/src/landing/public/docs/logos/opencode.svg @@ -0,0 +1 @@ +opencode \ No newline at end of file diff --git a/frontend/src/landing/public/hero-dashboard.png b/frontend/src/landing/public/hero-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..fe16635e6ce1b54e6801d594bae427bd511a5824 GIT binary patch literal 158151 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%0A}oqG86b|Z$8LchsL)5HWW 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%0AwYl?5|mo7z6Q;9r>{JdQykPrt-;xS_?$mrW{u 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%0AKy@wHa_;fnck{Dm#D!3+Fv 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�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 literal 0 HcmV?d00001 diff --git a/frontend/src/landing/public/og-image.png b/frontend/src/landing/public/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..1352d7f7ffd868c589db8971e7fc4543e37fdfe5 GIT binary patch literal 1065631 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⪚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

PtpKH2PD5}^6ni8jFRZm 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

K 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μ<%6qh%KoIK0cowq%BRb67DIm=R|8yv^~ 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 Date: Wed, 3 Jun 2026 21:57:38 +0530 Subject: [PATCH 118/250] feat: add github repo stats helper --- frontend/src/landing/lib/github-repo.ts | 58 +++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 frontend/src/landing/lib/github-repo.ts diff --git a/frontend/src/landing/lib/github-repo.ts b/frontend/src/landing/lib/github-repo.ts new file mode 100644 index 0000000000..801a703ad4 --- /dev/null +++ b/frontend/src/landing/lib/github-repo.ts @@ -0,0 +1,58 @@ +export interface GitHubRepoStats { + stars: number; + forks: number; + openIssues: number; + watchers: number; +} + +const FALLBACK_STATS: GitHubRepoStats = { + stars: 6295, + forks: 853, + openIssues: 622, + watchers: 21, +}; + +export async function getGitHubRepoStats(): Promise { + try { + const response = await fetch( + "https://api.github.com/repos/ComposioHQ/agent-orchestrator", + { + next: { revalidate: 3600 }, + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "ao-website", + }, + }, + ); + + if (!response.ok) { + return FALLBACK_STATS; + } + + const data = (await response.json()) as { + stargazers_count?: number; + forks_count?: number; + open_issues_count?: number; + subscribers_count?: number; + }; + + return { + stars: data.stargazers_count ?? FALLBACK_STATS.stars, + forks: data.forks_count ?? FALLBACK_STATS.forks, + openIssues: data.open_issues_count ?? FALLBACK_STATS.openIssues, + watchers: data.subscribers_count ?? FALLBACK_STATS.watchers, + }; + } catch { + return FALLBACK_STATS; + } +} + +export function formatCompactNumber(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}m`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k`; + } + return String(value); +} From d0c053e99ec5bdc9db416707b2cc76f1866d0091 Mon Sep 17 00:00:00 2001 From: codebanditssss Date: Wed, 3 Jun 2026 21:57:38 +0530 Subject: [PATCH 119/250] feat: add landing page section components --- .../src/landing/components/LandingAbout.tsx | 54 ++ .../landing/components/LandingAgentsBar.tsx | 51 ++ .../src/landing/components/LandingCTA.tsx | 33 ++ .../components/LandingDifferentiators.tsx | 64 ++ .../landing/components/LandingFeatures.tsx | 560 ++++++++++++++++++ .../src/landing/components/LandingHero.tsx | 102 ++++ .../landing/components/LandingHowItWorks.tsx | 316 ++++++++++ .../src/landing/components/LandingNav.tsx | 85 +++ .../landing/components/LandingQuickStart.tsx | 44 ++ .../src/landing/components/LandingStats.tsx | 51 ++ .../components/LandingTestimonials.tsx | 172 ++++++ .../landing/components/LandingUseCases.tsx | 235 ++++++++ .../src/landing/components/LandingVideo.tsx | 20 + .../landing/components/LandingWorkflow.tsx | 330 +++++++++++ .../landing/components/PageConstellation.tsx | 155 +++++ .../components/ScrollRevealProvider.tsx | 26 + 16 files changed, 2298 insertions(+) create mode 100644 frontend/src/landing/components/LandingAbout.tsx create mode 100644 frontend/src/landing/components/LandingAgentsBar.tsx create mode 100644 frontend/src/landing/components/LandingCTA.tsx create mode 100644 frontend/src/landing/components/LandingDifferentiators.tsx create mode 100644 frontend/src/landing/components/LandingFeatures.tsx create mode 100644 frontend/src/landing/components/LandingHero.tsx create mode 100644 frontend/src/landing/components/LandingHowItWorks.tsx create mode 100644 frontend/src/landing/components/LandingNav.tsx create mode 100644 frontend/src/landing/components/LandingQuickStart.tsx create mode 100644 frontend/src/landing/components/LandingStats.tsx create mode 100644 frontend/src/landing/components/LandingTestimonials.tsx create mode 100644 frontend/src/landing/components/LandingUseCases.tsx create mode 100644 frontend/src/landing/components/LandingVideo.tsx create mode 100644 frontend/src/landing/components/LandingWorkflow.tsx create mode 100644 frontend/src/landing/components/PageConstellation.tsx create mode 100644 frontend/src/landing/components/ScrollRevealProvider.tsx diff --git a/frontend/src/landing/components/LandingAbout.tsx b/frontend/src/landing/components/LandingAbout.tsx new file mode 100644 index 0000000000..b59c02f449 --- /dev/null +++ b/frontend/src/landing/components/LandingAbout.tsx @@ -0,0 +1,54 @@ +export function LandingAbout() { + return ( +

+ ); +} diff --git a/frontend/src/landing/components/LandingAgentsBar.tsx b/frontend/src/landing/components/LandingAgentsBar.tsx new file mode 100644 index 0000000000..1dcd524287 --- /dev/null +++ b/frontend/src/landing/components/LandingAgentsBar.tsx @@ -0,0 +1,51 @@ +const agents = [ + { + name: "Claude Code", + src: "/docs/logos/claude-code.svg", + alt: "Anthropic", + }, + { + name: "Codex", + src: "/docs/logos/codex.svg", + alt: "OpenAI", + }, + { + name: "Cursor", + src: "/docs/logos/cursor.svg", + alt: "Cursor", + }, + { + name: "Aider", + src: "https://aider.chat/assets/logo.svg", + alt: "Aider", + }, + { + name: "OpenCode", + src: "/docs/logos/opencode.svg", + alt: "OpenCode", + }, +]; + +export function LandingAgentsBar() { + return ( +
+
+ Works with your favorite AI agents +
+
+ {agents.map((agent) => ( +
+ {agent.alt} +
+ {agent.name} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/landing/components/LandingCTA.tsx b/frontend/src/landing/components/LandingCTA.tsx new file mode 100644 index 0000000000..d1b2c3dfe4 --- /dev/null +++ b/frontend/src/landing/components/LandingCTA.tsx @@ -0,0 +1,33 @@ +export function LandingCTA() { + return ( +
+
+

+ Stop babysitting. +

+

+ Start orchestrating. +

+
+ $ npm i -g @aoagents/ao +
+ +
+
+ ); +} diff --git a/frontend/src/landing/components/LandingDifferentiators.tsx b/frontend/src/landing/components/LandingDifferentiators.tsx new file mode 100644 index 0000000000..d104b0b8db --- /dev/null +++ b/frontend/src/landing/components/LandingDifferentiators.tsx @@ -0,0 +1,64 @@ +const rows = [ + { feature: "Web-based dashboard", others: "Native Mac apps only" }, + { feature: "Open source (MIT)", others: "Closed source" }, + { feature: "Multi-agent (Claude, Codex, Aider, OpenCode)", others: "Single agent" }, + { feature: "Auto CI failure recovery", others: "Manual" }, + { feature: "Plugin architecture (7 slots)", others: "Fixed integrations" }, + { feature: "Git worktree isolation", others: "Shared workspace" }, +]; + +export function LandingDifferentiators() { + return ( +
+
+
+ Why Agent Orchestrator +
+

+ The only{" "} + open-source, web-based{" "} + agent orchestrator +

+

+ Conductor, T3 Code, and Codex App are native Mac apps. AO runs in + your browser, works on any OS, and you can self-host or extend it. +

+
+
+ + + + + + + + + + {rows.map((row, i) => ( + + + + + + ))} + +
+ Feature + + AO + + Others +
+ {row.feature} + + ✓ + + {row.others} +
+
+
+ ); +} diff --git a/frontend/src/landing/components/LandingFeatures.tsx b/frontend/src/landing/components/LandingFeatures.tsx new file mode 100644 index 0000000000..a70530f151 --- /dev/null +++ b/frontend/src/landing/components/LandingFeatures.tsx @@ -0,0 +1,560 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +type DemoKind = "parallel" | "recovery" | "plugins" | "dashboard"; + +const features: { n: string; title: string; desc: string; demo: DemoKind }[] = [ + { + n: "01", + title: "Multi-agent execution", + desc: "Run Claude Code, Codex, Cursor, Aider, and OpenCode in parallel. Each agent in its own git worktree, branch, and context.", + demo: "parallel", + }, + { + n: "02", + title: "Autonomous CI + review handling", + desc: "CI fails? The agent reads the logs and pushes a fix. Review comments land? The agent addresses them. You sleep, your agents ship.", + demo: "recovery", + }, + { + n: "03", + title: "Seven swappable slots", + desc: "Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal. Use tmux or process. GitHub or GitLab. Slack or webhooks.", + demo: "plugins", + }, + { + n: "04", + title: "Real-time Kanban + terminal", + desc: "Every agent's state in one view. Attach to any terminal via the browser. SSE updates every 5 seconds. WebSocket for live I/O.", + demo: "dashboard", + }, +]; + +// The feature's animated demo — the stacked back panel + a smaller front peek, +// reused as-is from the original switcher so each card stays rich. +function FeatureDemo({ kind }: { kind: DemoKind }) { + return ( +
+
+ {kind === "parallel" && } + {kind === "recovery" && } + {kind === "plugins" && } + {kind === "dashboard" && } +
+
+ {kind === "parallel" && } + {kind === "recovery" && } + {kind === "plugins" && } + {kind === "dashboard" && } +
+
+ ); +} + +// Sticky offset from the top of the viewport where each card pins (leaves room +// for the fixed nav); each successive card pins STACK_GAP lower so the tops peek. +const BASE_TOP = 120; +const STACK_GAP = 26; + +export function LandingFeatures() { + const cardRefs = useRef<(HTMLDivElement | null)[]>([]); + const [stack, setStack] = useState(false); + + // Scroll-stack only on desktop; on narrow screens cards read as a plain list. + useEffect(() => { + const mq = window.matchMedia("(min-width: 768px)"); + const apply = () => setStack(mq.matches); + apply(); + mq.addEventListener("change", apply); + return () => mq.removeEventListener("change", apply); + }, []); + + // As later cards pin on top, shrink + dim the cards beneath them so the deck + // reads as a stack. CSS transition smooths the steps; rAF throttles scroll. + useEffect(() => { + const els = cardRefs.current; + if (!stack) { + els.forEach((el) => { + if (el) { + el.style.transform = ""; + el.style.opacity = ""; + } + }); + return; + } + let raf = 0; + const update = () => { + raf = 0; + els.forEach((el, i) => { + if (!el) return; + let depth = 0; + for (let j = i + 1; j < els.length; j++) { + const ej = els[j]; + if (ej && ej.getBoundingClientRect().top <= BASE_TOP + j * STACK_GAP + 0.5) { + depth += 1; + } + } + el.style.transform = `scale(${1 - depth * 0.05})`; + el.style.opacity = `${Math.max(1 - depth * 0.16, 0.55)}`; + }); + }; + const onScroll = () => { + if (!raf) raf = requestAnimationFrame(update); + }; + update(); + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll); + return () => { + window.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onScroll); + if (raf) cancelAnimationFrame(raf); + }; + }, [stack]); + + return ( +
+
+ + Features + +
+ +

+ A unified orchestrator that scales. +

+ +
+ {features.map((f, i) => ( +
{ + cardRefs.current[i] = el; + }} + className="landing-card rounded-2xl grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 items-center overflow-hidden" + style={{ + padding: "clamp(1.5rem, 3vw, 2.5rem)", + marginBottom: "1.5rem", + transformOrigin: "center top", + transition: "transform 0.4s ease, opacity 0.4s ease, border-color 0.2s ease", + ...(stack + ? { position: "sticky", top: `${BASE_TOP + i * STACK_GAP}px`, zIndex: i + 1 } + : null), + }} + > +
+
+ {f.n} +
+

+ {f.title} +

+

+ {f.desc} +

+
+ +
+ ))} +
+
+ ); +} + +/* ──────── 01 · Parallel ──────── */ + +function ParallelBack() { + const agents = [ + { name: "claude-code", task: "#42 auth", color: "rgba(255,159,102,0.7)", dur: 3.4, delay: 0 }, + { name: "codex", task: "#43 pagination", color: "rgba(134,239,172,0.65)", dur: 4.2, delay: 0.5 }, + { name: "aider", task: "#44 rate limit", color: "rgba(167,139,250,0.65)", dur: 3.6, delay: 1.0 }, + { name: "opencode", task: "#46 db refactor", color: "rgba(96,165,250,0.65)", dur: 4.8, delay: 0.3 }, + ]; + return ( +
+
+ + 4 sessions · parallel + + + + live + +
+
+ {agents.map((a) => ( +
+
+ + + {a.name} + +
+
+ {a.task} +
+
+
+
+
+ ))} +
+
+ ); +} + +function ParallelFront() { + const fleet = [ + { name: "claude-code", color: "rgba(255,159,102,0.85)" }, + { name: "codex", color: "rgba(134,239,172,0.75)" }, + { name: "aider", color: "rgba(167,139,250,0.75)" }, + { name: "opencode", color: "rgba(96,165,250,0.75)" }, + { name: "cursor", color: "rgba(244,114,182,0.65)" }, + ]; + return ( +
+
+ Fleet · 5 agents +
+
+ {fleet.map((a) => ( +
+ + + {a.name} + +
+ ))} +
+
+ ); +} + +/* ──────── 02 · Recovery ──────── */ + +const recoveryStages: { time: string; text: string; kind: "info" | "fail" | "fix" | "ok" }[] = [ + { time: "10:42", text: "agent.spawn → s-312", kind: "info" }, + { time: "10:43", text: "✗ tests/auth failed", kind: "fail" }, + { time: "10:44", text: "agent.investigate()", kind: "info" }, + { time: "10:44", text: "patch · re-running ci", kind: "fix" }, + { time: "10:45", text: "✓ tests/auth (48/48)", kind: "ok" }, + { time: "10:45", text: "✗ lint failed", kind: "fail" }, + { time: "10:46", text: "patch · eslint --fix", kind: "fix" }, + { time: "10:47", text: "✓ lint passed", kind: "ok" }, + { time: "10:47", text: "● ready to merge", kind: "ok" }, +]; + +function RecoveryBack() { + const [count, setCount] = useState(3); + useEffect(() => { + const id = setInterval(() => { + setCount((c) => (c >= recoveryStages.length ? 3 : c + 1)); + }, 1000); + return () => clearInterval(id); + }, []); + const visible = recoveryStages.slice(0, count); + return ( +
+
+ PR #312 · feat/user-auth + + healing + +
+
+ {visible.map((s, i) => { + const isLast = i === visible.length - 1; + const color = + s.kind === "fail" + ? "text-[rgba(248,113,113,0.85)]" + : s.kind === "ok" + ? "text-[rgba(134,239,172,0.85)]" + : s.kind === "fix" + ? "text-[rgba(251,191,36,0.85)]" + : "text-[var(--landing-muted)]"; + return ( +
+ + {s.time} + + {s.text} +
+ ); + })} +
+
+ ); +} + +function RecoveryFront() { + return ( +
+
+ + before + + + 12/48 +
+
+ + after + + + 48/48 +
+
+ ); +} + +/* ──────── 03 · Plugins ──────── */ + +function PluginsBack() { + const slots = [ + { slot: "agent", values: ["claude-code", "codex", "aider", "opencode"] }, + { slot: "tracker", values: ["github", "linear", "gitlab"] }, + { slot: "runtime", values: ["tmux", "process"] }, + { slot: "workspace", values: ["worktree", "clone"] }, + { slot: "scm", values: ["github", "gitlab"] }, + { slot: "notifier", values: ["slack", "webhook", "desktop"] }, + { slot: "terminal", values: ["iterm2", "web"] }, + ]; + const [tick, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 1600); + return () => clearInterval(id); + }, []); + return ( +
+
+ + agent-orchestrator.yaml + + + 7 slots + +
+
+ {slots.map((s, i) => { + const val = s.values[(tick + i) % s.values.length]; + return ( +
+ + {s.slot}: + + + {val} + +
+ ); + })} +
+
+ ); +} + +function PluginsFront() { + const pairs = [ + { from: "tmux", to: "process" }, + { from: "github", to: "linear" }, + { from: "slack", to: "webhook" }, + { from: "worktree", to: "clone" }, + ]; + const [idx, setIdx] = useState(0); + useEffect(() => { + const id = setInterval(() => setIdx((i) => (i + 1) % pairs.length), 1800); + return () => clearInterval(id); + }, []); + const p = pairs[idx]; + return ( +
+ + swap + +
+ + {p.from} + + + + {p.to} + +
+
+ ); +} + +/* ──────── 04 · Dashboard ──────── */ + +type KanbanCard = { + id: number; + col: 0 | 1 | 2; + title: string; + agent: string; + color: string; +}; + +function DashboardBack() { + const [cards, setCards] = useState([ + { id: 1, col: 0, title: "Add user auth", agent: "claude-code", color: "rgba(255,159,102,0.7)" }, + { id: 2, col: 0, title: "Fix pagination", agent: "codex", color: "rgba(134,239,172,0.65)" }, + { id: 3, col: 1, title: "Add rate limit", agent: "aider", color: "rgba(167,139,250,0.65)" }, + { id: 4, col: 2, title: "Refactor DB", agent: "opencode", color: "rgba(96,165,250,0.65)" }, + ]); + useEffect(() => { + const id = setInterval(() => { + setCards((prev) => { + const advanceable = prev.filter((c) => c.col < 2); + if (advanceable.length === 0) { + return prev.map((c) => ({ ...c, col: 0 as 0 | 1 | 2 })); + } + const oldest = advanceable[0]; + return prev.map((c) => + c.id === oldest.id ? { ...c, col: (c.col + 1) as 0 | 1 | 2 } : c, + ); + }); + }, 2400); + return () => clearInterval(id); + }, []); + const cols = ["Working", "Review", "Merged"]; + return ( +
+
+ + my-saas-app · 4 sessions + + + + sse + +
+
+ {cols.map((name, col) => ( +
+
+ {name} +
+ {cards + .filter((c) => c.col === col) + .map((c) => ( +
+
{c.title}
+
+ + + {c.agent} + +
+
+ ))} +
+ ))} +
+
+ ); +} + +const streamPool = [ + "tests/auth.py::test_login", + "tests/api.py::test_pagination", + "tests/db.py::test_migration", + "tests/queue.py::test_dequeue", + "tests/auth.py::test_logout", + "tests/api.py::test_cursor", + "tests/db.py::test_index", + "tests/queue.py::test_retry", +]; + +function DashboardFront() { + const [stream, setStream] = useState(() => + streamPool.slice(0, 4).map((text, i) => ({ id: i, text, exiting: false })), + ); + const nextRef = useRef(4); + useEffect(() => { + const id = setInterval(() => { + setStream((prev) => { + const marked = prev.map((l, i) => (i === 0 ? { ...l, exiting: true } : l)); + const next = [ + ...marked, + { + id: nextRef.current, + text: streamPool[nextRef.current % streamPool.length], + exiting: false, + }, + ]; + nextRef.current += 1; + return next; + }); + setTimeout(() => { + setStream((prev) => prev.filter((l) => !l.exiting)); + }, 240); + }, 1300); + return () => clearInterval(id); + }, []); + return ( +
+
+ s-003 · attached + tail -f +
+
+ {stream.map((l) => ( +
+ {" "} + {l.text} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/landing/components/LandingHero.tsx b/frontend/src/landing/components/LandingHero.tsx new file mode 100644 index 0000000000..ab820a045a --- /dev/null +++ b/frontend/src/landing/components/LandingHero.tsx @@ -0,0 +1,102 @@ +interface LandingHeroProps { + starsLabel: string; +} + +export function LandingHero({ starsLabel }: LandingHeroProps) { + return ( +
+
+
+ + Open Source · MIT Licensed · {starsLabel} GitHub Stars +
+

+ Run 30 AI agents in parallel. +
+ One dashboard. +

+

+ Agent Orchestrator spawns Claude Code, Codex, Cursor, Aider, and OpenCode + in isolated git worktrees. Each agent gets its own branch, creates PRs, + fixes CI, and addresses reviews autonomously. +

+
+
+ $ npx @aoagents/ao start +
+ + Read Docs + + + View on GitHub + +
+ +
+
+ {/* Laptop screen / lid */} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Agent Orchestrator dashboard — live agent sessions flowing from work to review to merge +
+
+ {/* Laptop base / hinge */} +
+
+
+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/landing/components/LandingHowItWorks.tsx b/frontend/src/landing/components/LandingHowItWorks.tsx new file mode 100644 index 0000000000..77f785a08b --- /dev/null +++ b/frontend/src/landing/components/LandingHowItWorks.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +const DURATION_MS = 3000; + +const steps = [ + { + n: "01", + title: "Configure & assign", + titleEm: "assign", + desc: "Point Agent Orchestrator at your repo with a YAML config. Choose your agent, set up trackers and notifiers. One file, full control.", + tags: ["YAML", "Plugins", "Trackers"], + kind: "cli" as const, + }, + { + n: "02", + title: "Agents work", + titleEm: "work", + desc: "Each agent spawns in an isolated worktree. They write code, create PRs, run tests, and fix failures. Monitor everything from the live dashboard, or let them run.", + tags: ["Worktrees", "Live dashboard", "Parallel"], + kind: "dashboard" as const, + }, + { + n: "03", + title: "PRs land", + titleEm: "land", + desc: "Agents create pull requests, address review comments, fix CI failures, and get them to mergeable state. Your morning starts with merged PRs, not a backlog.", + tags: ["Pull requests", "CI fixes", "Review"], + kind: "prs" as const, + }, +]; + +export function LandingHowItWorks() { + const [active, setActive] = useState(0); + const [progress, setProgress] = useState(0); + const [isDesktop, setIsDesktop] = useState(true); + const pausedRef = useRef(false); + const startRef = useRef(null); + + useEffect(() => { + const mq = window.matchMedia("(min-width: 768px)"); + const apply = () => setIsDesktop(mq.matches); + apply(); + mq.addEventListener("change", apply); + return () => mq.removeEventListener("change", apply); + }, []); + + useEffect(() => { + let raf = 0; + const tick = (now: number) => { + if (startRef.current === null) startRef.current = now; + if (!pausedRef.current) { + const p = Math.min((now - startRef.current) / DURATION_MS, 1); + setProgress(p); + if (p >= 1) { + startRef.current = now; + setActive((a) => (a + 1) % steps.length); + setProgress(0); + } + } else { + startRef.current = now - progress * DURATION_MS; + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active]); + + const select = (i: number) => { + if (i === active) return; + startRef.current = null; + setProgress(0); + setActive(i); + }; + + return ( +
+
+
+ Process +
+

+ Three steps to{" "} + orchestration +

+
+ +
(pausedRef.current = true)} + onMouseLeave={() => (pausedRef.current = false)} + > + {steps.map((step, i) => { + const isActive = i === active; + return ( +
select(i)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + select(i); + } + }} + className="relative min-w-0 cursor-pointer overflow-hidden border-l border-[var(--landing-border-subtle)] pl-7 pr-5 py-2 first:border-l-0 first:pl-0 md:first:pl-7" + style={{ + flex: isDesktop + ? isActive + ? "1 1 0%" + : "0 1 15rem" + : "0 0 auto", + transition: "flex 0.6s cubic-bezier(0.22,1,0.36,1)", + }} + > + {/* Header — always visible */} +
+ {step.n} +
+

+ {step.title.replace(` ${step.titleEm}`, "")}{" "} + + {step.titleEm} + +

+ + {/* Expanding body */} +
+
+ {/* Vertical progress bar */} +
+
+
+ +
+

+ {step.desc} +

+
+ {step.kind === "cli" && } + {step.kind === "dashboard" && } + {step.kind === "prs" && } +
+
+ {step.tags.map((t, ti) => ( + + {ti > 0 && ·} + {t} + + ))} +
+
+
+
+
+ ); + })} +
+
+ ); +} + +function CliDemo() { + return ( +
+
+
+
+
+
+
+
+ ${" "} + ao batch-spawn 42 43 44 45 46 +
+
 
+
⟡ Loading config from agent-orchestrator.yaml
+
⟡ Resolving 5 issues from GitHub
+
⟡ Spawning sessions in worktrees...
+
✓ Session s-001 spawned → issue #42
+
✓ Session s-002 spawned → issue #43
+
✓ Session s-003 spawned → issue #44
+
✓ Session s-004 spawned → issue #45
+
✓ Session s-005 spawned → issue #46
+
 
+
+ + 5 agents working · Dashboard → http://localhost:3000 +
+
+
+ ); +} + +function DashboardDemo() { + return ( +
+
+
+
+
+ my-saas-app · 5 sessions +
+
+ + + + +
+
+ ); +} + +function PrsDemo() { + return ( +
+ {[ + { branch: "feat/user-auth", title: "Add user authentication flow" }, + { branch: "fix/pagination-offset", title: "Fix off-by-one in cursor pagination" }, + { branch: "feat/rate-limiting", title: "Add Redis-backed rate limiter" }, + { branch: "refactor/db-layer", title: "Extract repository pattern from services" }, + ].map((pr) => ( +
+
+
{pr.branch}
+
{pr.title}
+
+
+ ✓ Merged +
+
+ ))} +
+ ); +} + +interface DashCardData { + title: string; + meta: string; + agent: string; + amber?: boolean; + done?: boolean; +} + +function DashColumn({ title, cards }: { title: string; cards: DashCardData[] }) { + return ( +
+
+ {title} +
+ {cards.map((card) => ( +
+
{card.title}
+
{card.meta}
+
+ {card.done ? ( + + ) : ( + + )} + {card.agent} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/landing/components/LandingNav.tsx b/frontend/src/landing/components/LandingNav.tsx new file mode 100644 index 0000000000..b9ed3b2eec --- /dev/null +++ b/frontend/src/landing/components/LandingNav.tsx @@ -0,0 +1,85 @@ +"use client"; + +function XIcon() { + return ( + + ); +} + +function DiscordIcon() { + return ( + + ); +} + +function GithubIcon() { + return ( + + ); +} + +export function LandingNav() { + return ( + + ); +} diff --git a/frontend/src/landing/components/LandingQuickStart.tsx b/frontend/src/landing/components/LandingQuickStart.tsx new file mode 100644 index 0000000000..cd8b60b4a7 --- /dev/null +++ b/frontend/src/landing/components/LandingQuickStart.tsx @@ -0,0 +1,44 @@ +const steps = [ + { num: "STEP 01", title: "Install", desc: "One command. No dependencies beyond Node.js.", cmd: "npm i -g @aoagents/ao" }, + { num: "STEP 02", title: "Configure", desc: "Create an agent-orchestrator.yaml. Pick your agents, tracker, and notifiers.", cmd: "ao start" }, + { num: "STEP 03", title: "Launch", desc: "Assign issues and watch agents spawn.", cmd: "ao batch-spawn 1 2 3" }, +]; + +export function LandingQuickStart() { + return ( +
+
+
+ Get started in 60 seconds +
+

+ Three commands to{" "} + launch +

+
+
+ {steps.map((s) => ( +
+
+ {s.num} +
+

+ {s.title} +

+

+ {s.desc} +

+
+ $ {s.cmd} +
+
+ ))} +
+ +
+ ); +} diff --git a/frontend/src/landing/components/LandingStats.tsx b/frontend/src/landing/components/LandingStats.tsx new file mode 100644 index 0000000000..823ba517ac --- /dev/null +++ b/frontend/src/landing/components/LandingStats.tsx @@ -0,0 +1,51 @@ +import type { GitHubRepoStats } from "@/lib/github-repo"; + +interface LandingStatsProps { + stats: GitHubRepoStats; +} + +export function LandingStats({ stats }: LandingStatsProps) { + const cards = [ + { number: stats.stars.toLocaleString(), label: "GitHub Stars" }, + { number: stats.forks.toLocaleString(), label: "Forks" }, + { number: stats.openIssues.toLocaleString(), label: "Open Issues" }, + { number: stats.watchers.toLocaleString(), label: "Watchers" }, + ]; + + return ( +
+
+ {cards.map((stat) => ( +
+
+ {stat.number} +
+
+ {stat.label} +
+
+ ))} +
+
+ + + {stats.stars.toLocaleString()} + stars on GitHub + +
+
+ + Built with itself — this repo is managed by Agent Orchestrator +
+
+
+ ); +} diff --git a/frontend/src/landing/components/LandingTestimonials.tsx b/frontend/src/landing/components/LandingTestimonials.tsx new file mode 100644 index 0000000000..a555bbaebe --- /dev/null +++ b/frontend/src/landing/components/LandingTestimonials.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const testimonials = [ + { + quote: + "Set up 12 agents on our backlog before lunch. By end of day, 8 PRs were merged.", + img: "https://i.pravatar.cc/120?img=13", + name: "Staff Engineer", + role: "Series B Startup", + }, + { + quote: + "The auto CI recovery alone saves me hours a week. Agents fix their own broken tests. I just review and merge.", + img: "https://i.pravatar.cc/120?img=32", + name: "Solo Founder", + role: "Indie SaaS", + }, + { + quote: + "We went from 3 PRs/day to 15 PRs/day. The plugin system means we swapped in GitLab and Linear without changing our workflow.", + img: "https://i.pravatar.cc/120?img=8", + name: "Eng Lead", + role: "20-person team", + }, +]; + +const ROTATE_MS = 5500; + +export function LandingTestimonials() { + const [active, setActive] = useState(0); + const [show, setShow] = useState(true); + const [paused, setPaused] = useState(false); + + const change = (next: number) => { + if (next === active) return; + setShow(false); + window.setTimeout(() => { + setActive(next); + setShow(true); + }, 240); + }; + + useEffect(() => { + if (paused) return; + const t = window.setTimeout( + () => change((active + 1) % testimonials.length), + ROTATE_MS, + ); + return () => window.clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, paused]); + + const t = testimonials[active]; + + return ( +
+
+
+ What engineers say +
+

+ Trusted by builders +

+
+ +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + > + {/* Quote — fades on change */} +
+
+ “{t.quote}” +
+
+ + {/* Bottom row — author cluster on the left, step counter on the right */} +
+
+
+ {testimonials.map((item, i) => { + const isActive = i === active; + const size = isActive ? 56 : 44; + return ( + + ); + })} +
+ + {/* Vertical divider */} +
+ + {/* Author — fades on change */} +
+
+ {t.name} +
+
+ {t.role} +
+
+
+ + {/* Step counter — fills the right side */} +
+ + {String(active + 1).padStart(2, "0")} + + + / {String(testimonials.length).padStart(2, "0")} + +
+
+
+
+ ); +} diff --git a/frontend/src/landing/components/LandingUseCases.tsx b/frontend/src/landing/components/LandingUseCases.tsx new file mode 100644 index 0000000000..9107aa2120 --- /dev/null +++ b/frontend/src/landing/components/LandingUseCases.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useEffect, useRef, type PointerEvent as ReactPointerEvent } from "react"; + +const dim = "text-[var(--landing-muted-dim)]"; +const fg = "text-[var(--landing-fg)]/80"; +const ok = "text-[rgba(134,239,172,0.8)]"; + +type UseCase = { + eyebrow: string; + title: string; + desc: string; + prefix: "$" | "⟡"; + cmd: string; + outcome: string; +}; + +// Real, grounded use cases — real ao commands, reaction keys, and lifecycle states. +const cases: UseCase[] = [ + { + eyebrow: "Backlog", + title: "Clear it overnight", + desc: "One agent per issue, each in its own git worktree, all running at once.", + prefix: "$", + cmd: "ao batch-spawn 142 143 144 145", + outcome: "4 worktrees · 4 PRs", + }, + { + eyebrow: "CI recovery", + title: "Self-healing builds", + desc: "A check goes red; the agent reads the logs, pushes a fix, and waits for green.", + prefix: "⟡", + cmd: "reaction · ci-failed", + outcome: "ci_failed → mergeable", + }, + { + eyebrow: "Review loop", + title: "Answers its own reviews", + desc: "Comments land; the agent addresses each one and re-requests review.", + prefix: "⟡", + cmd: "reaction · changes-requested", + outcome: "changes_requested → approved", + }, + { + eyebrow: "Migration", + title: "Grinds through the long ones", + desc: "Hand one agent a sweeping change and let it work file by file until tests pass.", + prefix: "$", + cmd: "ao spawn 305 --agent claude-code", + outcome: "23 files · tests green", + }, + { + eyebrow: "Per-role", + title: "Right model per job", + desc: "Claude Code orchestrates, Codex does the work. Pick the tool per task.", + prefix: "$", + cmd: "ao spawn 88 --agent codex", + outcome: "codex #88 · claude-code #91", + }, + { + eyebrow: "Multi-project", + title: "Every repo, one screen", + desc: "Register all your repos and supervise their agents from a single dashboard.", + prefix: "$", + cmd: "ao start", + outcome: "3 projects · one dashboard", + }, +]; + +const N = cases.length; +const THETA = 360 / N; +const RADIUS = 440; +const CARD_W = 360; +const CARD_H = 440; + +export function LandingUseCases() { + const viewportRef = useRef(null); + const ringRef = useRef(null); + const cardRefs = useRef<(HTMLDivElement | null)[]>([]); + + const angle = useRef(0); + const dragging = useRef(false); + const paused = useRef(false); + const reduced = useRef(false); + const start = useRef({ x: 0, a: 0 }); + + // rAF loop — rotate the ring and fade/scale each card by how far it faces the + // camera. Imperative (no setState) so 60fps stays smooth and re-render-free. + useEffect(() => { + reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + let raf = 0; + const loop = () => { + if (!dragging.current && !paused.current && !reduced.current) { + angle.current += 0.12; + } + const a = angle.current; + if (ringRef.current) { + ringRef.current.style.transform = `translateZ(-${RADIUS}px) rotateY(${a}deg)`; + } + cardRefs.current.forEach((el, i) => { + if (!el) return; + const facing = Math.cos(((i * THETA + a) * Math.PI) / 180); + const vis = Math.max(facing, 0); + el.style.opacity = `${0.2 + 0.8 * vis}`; + el.style.transform = `rotateY(${i * THETA}deg) translateZ(${RADIUS}px) scale(${0.9 + 0.1 * vis})`; + }); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + const onPointerDown = (e: ReactPointerEvent) => { + dragging.current = true; + start.current = { x: e.clientX, a: angle.current }; + e.currentTarget.setPointerCapture(e.pointerId); + if (viewportRef.current) viewportRef.current.style.cursor = "grabbing"; + }; + const onPointerMove = (e: ReactPointerEvent) => { + if (!dragging.current) return; + angle.current = start.current.a + (e.clientX - start.current.x) * 0.4; + }; + const onPointerUp = () => { + dragging.current = false; + if (viewportRef.current) viewportRef.current.style.cursor = "grab"; + }; + + return ( +
+
+
+ Use cases +
+

+ One orchestrator, many jobs +

+

+ Point AO at the work and walk away — drag to explore what a single + run can do. +

+
+ +
(paused.current = true)} + onMouseLeave={() => { + paused.current = false; + onPointerUp(); + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + className="landing-reveal relative mx-auto select-none" + style={{ + perspective: "1900px", + height: `${CARD_H + 80}px`, + maxWidth: "1120px", + cursor: "grab", + touchAction: "pan-y", + WebkitMaskImage: + "linear-gradient(to right, transparent, #000 16%, #000 84%, transparent)", + maskImage: + "linear-gradient(to right, transparent, #000 16%, #000 84%, transparent)", + }} + > +
+ {cases.map((c, i) => ( +
{ + cardRefs.current[i] = el; + }} + style={{ + position: "absolute", + left: "50%", + top: "50%", + width: `${CARD_W}px`, + height: `${CARD_H}px`, + marginLeft: `-${CARD_W / 2}px`, + marginTop: `-${CARD_H / 2}px`, + backfaceVisibility: "hidden", + }} + > +
+
+ {c.eyebrow} +
+

+ {c.title} +

+

+ {c.desc} +

+
+
+ {c.prefix}{" "} + {c.cmd} +
+
+ → {c.outcome} +
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/landing/components/LandingVideo.tsx b/frontend/src/landing/components/LandingVideo.tsx new file mode 100644 index 0000000000..07058074f1 --- /dev/null +++ b/frontend/src/landing/components/LandingVideo.tsx @@ -0,0 +1,20 @@ +export function LandingVideo() { + return ( +
+
+ + See it in action + +
+
+