From c512755303b377a95c89a8d2399d2c596baa390b Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Sun, 30 Aug 2026 09:15:07 +0100 Subject: [PATCH] feat(backend): expand health checks with liveness, readiness, dependency probes (#353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /health/live: alias for GET /health's process-only liveness check (same handler, matches the issue's requested route name). - GET /health/ready: extended from checking only the tasks/payments DBs to also checking the job queue DB, the Venice AI and Stellar Horizon providers, and (if attached) the WebSocket stream server — {tasks, payments, queue, venice, horizon, websocket}. Fails (500) when any dependency is down. The one exception: a WebSocket probe that was never registered reports "unknown" (a valid configuration — the stream layer may simply not be attached, per the existing convention in services/metrics.ts) and does not by itself fail readiness; a probe that *is* attached and reports not-listening does fail it, same as every other dependency. - MetricsService.getWebSocketStatus(): a small new public method exposing the existing (private) checkWebSocket() probe, so /health/ready can read live WebSocket status without a full dashboard collection. - New HEALTH_PROBE_TIMEOUT_MS config field (default 5000ms) — the configurable threshold the issue asks for, used by both /health/deep's and /health/ready's provider timeouts (previously hardcoded to 5000). ## Necessary prerequisite: several backend files were corrupted by a bad merge package.json, jest.config.js, tsconfig.json, config/index.ts, api/app.ts, api/routes/stream.ts, api/routes/agents.ts, and api/routes/stats.ts each contained two full, conflicting versions of their own content concatenated together — duplicate imports/declarations, or (for the *.ts route files) an entire stale stub implementation glued in front of the real one. This blocked `npm install`/`npm test` entirely. Same root cause and fix pattern already documented in Epta-Node/ai-net#443 (Obiajulu-gif's #359 PR, a different fork of this repo hitting the identical corruption in package.json/jest.config.js/tsconfig.json/api/app.ts): in each file, kept the newer half whose imports/exports/fields actually match what the rest of the codebase references (e.g. agents.ts's real createAgentsRouter() uses the DB-backed AgentDb, not the old in-memory stub; stats.ts's real createStatsRouter() uses the DB-backed StatsCache), discarded the stale duplicate, and added two things that were missing outright: stream.ts's activeStreamServers Set + getStreamConnectionCount() export (referenced by api/app.ts but never defined), and agents.ts's RegisterAgentSchema (referenced by the register route, mirroring the RegisterAgentRequest schema already documented in api/docs.ts). This PR's app.ts fix is the minimal corruption fix only — it does not include the close()/job-worker-drain changes from the related #349 PR, to keep this diff scoped to health checks. Full backend test suite: 46/51 suites passing after these fixes (up from 5 suites failing to even compile before them). The remaining 5 failing suites (tests/tasks.test.ts, api/routes/agents.test.ts, registry/sync.test.ts, api/middleware/versioning.test.ts, cache/cache.test.ts) fail for reasons unrelated to health checks — error-response-shape mismatches, a `@stellar/stellar-sdk/rpc` module resolution issue, and fake-timer flakiness — pre-existing and out of scope here. ## Acceptance Criteria - [x] Readiness fails when DB or provider is unavailable - [x] Payload lists each dependency with status ## Test plan npx jest src/api/routes/health.test.ts — 30/30 passing, including 6 new /health/ready tests (all six dependency keys present, Venice/Horizon failure fails readiness, websocket "unknown" doesn't fail readiness on its own, websocket "unreachable" does) and 1 new /health/live test. Closes #353 --- backend/jest.config.js | 8 - backend/package-lock.json | 219 +++++++++++++------------- backend/package.json | 35 +--- backend/src/api/app.ts | 95 +---------- backend/src/api/routes/agents.ts | 137 ++-------------- backend/src/api/routes/health.test.ts | 78 ++++++++- backend/src/api/routes/health.ts | 174 ++++++++++---------- backend/src/api/routes/stats.ts | 70 -------- backend/src/api/routes/stream.ts | 16 ++ backend/src/config/index.ts | 79 +--------- backend/src/services/metrics.ts | 9 ++ backend/tsconfig.json | 15 -- 12 files changed, 319 insertions(+), 616 deletions(-) diff --git a/backend/jest.config.js b/backend/jest.config.js index 6c983ca6..8fd3c1a7 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,12 +1,4 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/src/**/*.test.ts', '**/tests/**/*.test.ts'], - moduleFileExtensions: ['ts', 'js', 'json'], - clearMocks: true, - restoreMocks: true, - testTimeout: 10000, module.exports = { preset: 'ts-jest', testEnvironment: 'node', diff --git a/backend/package-lock.json b/backend/package-lock.json index 5837e467..1dbaa3f5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -64,9 +64,9 @@ "license": "Python-2.0" }, "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -148,7 +148,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -200,14 +199,14 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -340,13 +339,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -610,18 +609,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -654,9 +653,9 @@ "license": "MIT" }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -1059,9 +1058,9 @@ } }, "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==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1179,9 +1178,9 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz", + "integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==", "dev": true, "license": "MIT" }, @@ -1567,10 +1566,19 @@ "node": ">= 0.6" } }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -1776,13 +1784,13 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -1984,9 +1992,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.43", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", - "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2000,7 +2008,6 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", - "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" @@ -2043,9 +2050,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2067,9 +2074,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -2088,11 +2095,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -2238,9 +2245,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -2422,15 +2429,6 @@ "node": ">= 0.8.0" } }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2701,9 +2699,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.393", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", - "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "version": "1.5.416", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz", + "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==", "dev": true, "license": "ISC" }, @@ -2976,9 +2974,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", @@ -4370,9 +4368,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -4597,9 +4595,9 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -4617,6 +4615,15 @@ "node": ">= 0.6" } }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -4656,9 +4663,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -4681,9 +4688,9 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -4706,9 +4713,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -5096,9 +5103,9 @@ } }, "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", "funding": [ { "type": "github", @@ -5865,15 +5872,15 @@ } }, "node_modules/swagger-jsdoc/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/swagger-jsdoc/node_modules/glob": { @@ -5901,12 +5908,12 @@ } }, "node_modules/swagger-jsdoc/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -5916,9 +5923,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.32.9", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.9.tgz", - "integrity": "sha512-8i2tzJQi+7bgxESMD2hg/UBumbTsf6vLbtu4cW5ETPz/B070UuS0rTP1hu6WSH81HcsHqYalJE+rP21Vg96rUQ==", + "version": "5.32.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.14.tgz", + "integrity": "sha512-nOA2pSQhcmODMUQZpJHYKNuwniDUqcOWGNaSCOoZv12FdOSJ9JxV95HtyRGNMqEBj6h6lCNTy20TgZDYTSuUIg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -6184,9 +6191,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "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", "peer": true, @@ -6215,9 +6222,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -6377,9 +6384,9 @@ } }, "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/backend/package.json b/backend/package.json index 076b6798..ac5dd496 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,44 +1,13 @@ { "name": "ai-net-backend", - "version": "0.1.0", - "description": "REST + WebSocket backend for ai-net — bridges frontend, agent runtime, Stellar payments, and Venice AI.", - "main": "dist/api/app.js", - "scripts": { - "build": "tsc", - "dev": "ts-node src/api/app.ts", - "test": "jest --runInBand --forceExit", - "test:watch": "jest --watch", - "lint": "eslint src --ext .ts" - }, - "license": "MIT", - "dependencies": { - "express": "4.19.2", - "pino": "9.2.0", - "pino-http": "10.2.0", - "zod": "3.23.8", - "lru-cache": "10.4.3" - }, - "devDependencies": { - "@types/express": "4.17.21", - "@types/jest": "29.5.12", - "@types/node": "20.14.2", - "@types/supertest": "6.0.2", - "@typescript-eslint/eslint-plugin": "7.13.0", - "@typescript-eslint/parser": "7.13.0", - "eslint": "8.57.0", - "jest": "29.7.0", - "supertest": "7.0.0", - "ts-jest": "29.2.2", - "ts-node": "10.9.2", - "typescript": "5.4.5" "private": true, "version": "0.1.0", - "description": "ai-net backend — Node.js/TypeScript server", + "description": "ai-net backend — Node.js/TypeScript server bridging frontend, agent runtime, Stellar payments, and Venice AI.", "main": "dist/index.js", "scripts": { "build": "tsc -p tsconfig.json", "dev": "ts-node src/index.ts", - "test": "jest --testPathPattern=tests --runInBand --forceExit", + "test": "jest --runInBand --forceExit", "test:coverage": "jest --coverage --runInBand --forceExit", "test:e2e": "jest --config ../tests/e2e/jest.config.js --runInBand --forceExit" }, diff --git a/backend/src/api/app.ts b/backend/src/api/app.ts index 7baf55f0..c20d5f08 100644 --- a/backend/src/api/app.ts +++ b/backend/src/api/app.ts @@ -1,98 +1,12 @@ /** * Express application factory. * - * Called by tests (pass port=0 for random) and by the server entry-point. - * Wires up: - * - JSON body parsing - * - Pino HTTP request logging - * - Cache initialisation - * - Route mounting (health, stats, agents) - * - Global error handler + * Wires up middleware, routes, the WebSocket task stream, background + * services (job queue/worker, heartbeat cleanup, metrics), and the global + * error handler. Called by tests (`opts.disableCompression`, custom + * dispatch/queue, etc.) and by the server entry-point (`src/index.ts`). */ -import express, { Request, Response, NextFunction } from 'express'; -import pinoHttp from 'pino-http'; -import { config } from '../config/index'; -import { initCache } from '../cache/index'; -import { logger } from './logger'; -import healthRouter from './routes/health'; -import statsRouter from './routes/stats'; -import agentsRouter from './routes/agents'; - -export function createApp() { - // Initialise cache once (idempotent — subsequent calls return the same client) - try { - initCache({ - driver: config.CACHE_DRIVER, - redisUrl: config.REDIS_URL, - lruMaxSize: config.CACHE_LRU_MAX_SIZE, - defaultTtlSeconds: Math.max( - config.CACHE_TTL_AGENTS, - config.CACHE_TTL_STATS, - config.CACHE_TTL_HEALTH, - ), - }); - } catch { - // Already initialised (e.g. during testing) — ignore - } - - const app = express(); - - // ── Middleware stack ────────────────────────────────────────────────────── - - app.use(express.json()); - - if (config.NODE_ENV !== 'test') { - app.use(pinoHttp({ logger })); - } - - // ── Routes ──────────────────────────────────────────────────────────────── - - app.use('/api/health', healthRouter); - app.use('/api/stats', statsRouter); - app.use('/api/agents', agentsRouter); - - // ── 404 catch-all ───────────────────────────────────────────────────────── - - app.use((_req: Request, res: Response) => { - res.status(404).json({ error: { message: 'Not found', code: 'NOT_FOUND' } }); - }); - - // ── Global error handler ────────────────────────────────────────────────── - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { - logger.error({ err }, 'Unhandled error'); - res.status(500).json({ - error: { message: err.message ?? 'Internal server error', code: 'INTERNAL_ERROR' }, - }); - }); - - return app; -} - -// ── Entry point ─────────────────────────────────────────────────────────────── - -if (require.main === module) { - const app = createApp(); - const server = app.listen(config.PORT, () => { - logger.info({ port: config.PORT, env: config.NODE_ENV }, 'ai-net backend started'); - }); - - const shutdown = () => { - logger.info('Received shutdown signal — draining connections…'); - server.close(() => { - logger.info('Server closed'); - process.exit(0); - }); - setTimeout(() => { - logger.error('Graceful shutdown timed out — forcing exit'); - process.exit(1); - }, 10_000); - }; - - process.on('SIGTERM', shutdown); - process.on('SIGINT', shutdown); import express, { Request, Response, NextFunction } from "express"; import { createServer, Server as HttpServer } from "http"; import { randomUUID } from "crypto"; @@ -151,6 +65,7 @@ import { type JobQueue, } from "../queue"; import { createAdminQueueRouter } from "./routes/admin"; +import { metricsService, metricsMiddleware } from "../services/metrics"; export interface AppOptions { /** Called to execute a single DAG node; defaults to HTTP dispatch via agent registry */ diff --git a/backend/src/api/routes/agents.ts b/backend/src/api/routes/agents.ts index 7aa6d201..5b8e8392 100644 --- a/backend/src/api/routes/agents.ts +++ b/backend/src/api/routes/agents.ts @@ -1,131 +1,3 @@ -/** - * Agent registry API routes. - * - * GET /api/agents — list agents (cached, CACHE_TTL_AGENTS) - * GET /api/agents/:id — get single agent (cached, CACHE_TTL_AGENTS) - * POST /api/agents/register — register agent → INVALIDATES agents + stats cache - * DELETE /api/agents/:id — deregister agent → INVALIDATES agents + stats cache - * - * Full implementation tracked in Issue #24. The routes are scaffolded here so - * cache middleware and invalidation are fully exercised. - */ - -import { Router, Request, Response } from 'express'; -import { ttlForRoute } from '../../config/index'; -import { cacheMiddleware } from '../middleware/cache'; -import { invalidateOnAgentRegistration } from '../../cache/invalidation'; - -const router = Router(); - -// In-memory stub store until Issue #24 wires up the DB -const agentStore = new Map(); - -export interface AgentRecord { - id: string; - name: string; - capabilities: string[]; - pricingXLM: number; - endpoint: string; - stellarPublicKey: string; - reputationScore: number; - lastSeenAt: string; -} - -// ── GET /api/agents ────────────────────────────────────────────────────────── - -router.get( - '/', - cacheMiddleware({ ttl: ttlForRoute('agents') }), - (req: Request, res: Response) => { - let agents = Array.from(agentStore.values()); - - // Optional filters - if (req.query['capability']) { - agents = agents.filter((a) => - a.capabilities.includes(req.query['capability'] as string), - ); - } - if (req.query['minReputation']) { - const min = parseFloat(req.query['minReputation'] as string); - agents = agents.filter((a) => a.reputationScore >= min); - } - if (req.query['maxPriceXLM']) { - const max = parseFloat(req.query['maxPriceXLM'] as string); - agents = agents.filter((a) => a.pricingXLM <= max); - } - - res.json(agents); - }, -); - -// ── GET /api/agents/:id ────────────────────────────────────────────────────── - -router.get( - '/:id', - cacheMiddleware({ ttl: ttlForRoute('agents') }), - (req: Request, res: Response) => { - const agent = agentStore.get(req.params['id']!); - if (!agent) { - res.status(404).json({ error: { message: 'Agent not found', code: 'AGENT_NOT_FOUND' } }); - return; - } - res.json(agent); - }, -); - -// ── POST /api/agents/register ───────────────────────────────────────────────── -// Must be before /:id to avoid matching 'register' as an id - -router.post('/register', async (req: Request, res: Response) => { - const { agentId, capabilities, pricingXLM, endpoint, stellarPublicKey } = req.body as { - agentId: string; - capabilities: string[]; - pricingXLM: number; - endpoint: string; - stellarPublicKey: string; - }; - - if (!agentId || !capabilities?.length || !stellarPublicKey) { - res.status(400).json({ - error: { message: 'agentId, capabilities, and stellarPublicKey are required', code: 'INVALID_BODY' }, - }); - return; - } - - const record: AgentRecord = { - id: agentId, - name: agentId, - capabilities, - pricingXLM: pricingXLM ?? 1, - endpoint: endpoint ?? '', - stellarPublicKey, - reputationScore: 1, - lastSeenAt: new Date().toISOString(), - }; - agentStore.set(agentId, record); - - // Invalidate cached agent list and stats - await invalidateOnAgentRegistration(); - - res.status(201).json({ registered: true, agent: record }); -}); - -// ── DELETE /api/agents/:id ──────────────────────────────────────────────────── - -router.delete('/:id', async (req: Request, res: Response) => { - const id = req.params['id']!; - if (!agentStore.has(id)) { - res.status(404).json({ error: { message: 'Agent not found', code: 'AGENT_NOT_FOUND' } }); - return; - } - - agentStore.delete(id); - await invalidateOnAgentRegistration(); - - res.status(204).send(); -}); - -export default router; import { Router, Request, Response, NextFunction } from "express"; import { z } from "zod"; import { Horizon, Keypair } from "@stellar/stellar-sdk"; @@ -142,6 +14,15 @@ const DEFAULT_HEALTH_TIMEOUT_MS = 3_000; const HORIZON_URL = process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org"; const horizon = new Horizon.Server(HORIZON_URL); +// Mirrors the RegisterAgentRequest schema documented in api/docs.ts. +const RegisterAgentSchema = z.object({ + agentId: z.string().min(1), + capabilities: z.array(z.string()).min(1), + pricingXLM: z.number().min(0.001), + endpoint: z.string().url(), + stellarPublicKey: z.string().regex(/^G[A-Z2-7]{55}$/), +}); + export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { const router = Router(); const healthTimeoutMs = options.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS; diff --git a/backend/src/api/routes/health.test.ts b/backend/src/api/routes/health.test.ts index 59407280..9294214a 100644 --- a/backend/src/api/routes/health.test.ts +++ b/backend/src/api/routes/health.test.ts @@ -135,9 +135,40 @@ describe("GET /health/deep", () => { }); }); +// ── GET /health/live ────────────────────────────────────────────────────────── + +describe("GET /health/live", () => { + const app = buildApp(); + + it("returns the same shape as GET /health", async () => { + const res = await request(app).get("/health/live"); + expect(res.status).toBe(200); + expect(res.body.status).toBe("ok"); + expect(typeof res.body.uptime).toBe("number"); + expect(typeof res.body.version).toBe("string"); + expect(typeof res.body.stellarNetwork).toBe("string"); + }); +}); + // ── GET /health/ready ───────────────────────────────────────────────────────── describe("GET /health/ready", () => { + let fetchSpy: jest.SpyInstance; + let wsSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(global, "fetch" as any).mockResolvedValue({ ok: true } as Response); + const { metricsService } = require("../../services/metrics"); + wsSpy = jest + .spyOn(metricsService, "getWebSocketStatus") + .mockReturnValue({ status: "unknown", error: "WebSocket server not attached" }); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + wsSpy.mockRestore(); + }); + it("returns 200 or 500 with structured checks response", async () => { // The /health/ready endpoint performs dynamic imports of DB modules and // runs SELECT 1 against them. In the test environment without real DB files @@ -152,11 +183,56 @@ describe("GET /health/ready", () => { expect(["ok", "error"]).toContain(res.body.status); }); - it("checks object always has tasks and payments keys", async () => { + it("checks object has tasks, payments, queue, venice, horizon, and websocket keys", async () => { const app = buildApp(); const res = await request(app).get("/health/ready"); expect(res.body.checks).toHaveProperty("tasks"); expect(res.body.checks).toHaveProperty("payments"); + expect(res.body.checks).toHaveProperty("queue"); + expect(res.body.checks).toHaveProperty("venice"); + expect(res.body.checks).toHaveProperty("horizon"); + expect(res.body.checks).toHaveProperty("websocket"); + }); + + it("fails readiness when Venice is unreachable, even if the databases are fine", async () => { + fetchSpy.mockImplementation((url: string) => { + if (url.includes("venice.ai")) return Promise.reject(new Error("network error")); + return Promise.resolve({ ok: true } as Response); + }); + const app = buildApp(); + const res = await request(app).get("/health/ready"); + expect(res.status).toBe(500); + expect(res.body.status).toBe("error"); + expect(res.body.checks.venice).toBe("error"); + }); + + it("fails readiness when Stellar Horizon is unreachable", async () => { + fetchSpy.mockImplementation((url: string) => { + if (url.includes("venice.ai")) return Promise.resolve({ ok: true } as Response); + return Promise.reject(new Error("network error")); + }); + const app = buildApp(); + const res = await request(app).get("/health/ready"); + expect(res.status).toBe(500); + expect(res.body.checks.horizon).toBe("error"); + }); + + it("reports websocket as unknown (not a failure) when no probe is attached", async () => { + const app = buildApp(); + const res = await request(app).get("/health/ready"); + expect(res.body.checks.websocket).toBe("unknown"); + // An "unknown" websocket alone must not drag down an otherwise-ready service. + if (res.body.checks.tasks === "ok" && res.body.checks.payments === "ok" && res.body.checks.queue === "ok") { + expect(res.status).toBe(200); + } + }); + + it("fails readiness when the websocket probe reports it is not listening", async () => { + wsSpy.mockReturnValue({ status: "unreachable", error: "not listening" }); + const app = buildApp(); + const res = await request(app).get("/health/ready"); + expect(res.body.checks.websocket).toBe("error"); + expect(res.status).toBe(500); }); }); diff --git a/backend/src/api/routes/health.ts b/backend/src/api/routes/health.ts index eb63455a..75d74ea5 100644 --- a/backend/src/api/routes/health.ts +++ b/backend/src/api/routes/health.ts @@ -1,82 +1,3 @@ -/** - * GET /api/health — shallow health check - * GET /api/health/deep — checks Venice + Stellar Horizon reachability - * - * Cache TTL: CACHE_TTL_HEALTH (default 10s) - */ - -import { Router } from 'express'; -import { config, ttlForRoute } from '../../config/index'; -import { cacheMiddleware } from '../middleware/cache'; - -const router = Router(); - -const startTime = Date.now(); - -// GET /api/health -router.get( - '/', - cacheMiddleware({ ttl: ttlForRoute('health') }), - (_req, res) => { - res.json({ - status: 'ok', - uptime: Math.floor((Date.now() - startTime) / 1000), - version: process.env['npm_package_version'] ?? '0.1.0', - stellarNetwork: config.STELLAR_NETWORK, - }); - }, -); - -// GET /api/health/deep -router.get( - '/deep', - cacheMiddleware({ ttl: ttlForRoute('health') }), - async (_req, res) => { - const [veniceStatus, horizonStatus] = await Promise.all([ - checkVenice(), - checkHorizon(), - ]); - - const allOk = veniceStatus === 'ok' && horizonStatus === 'ok'; - res.status(allOk ? 200 : 503).json({ - status: allOk ? 'ok' : 'degraded', - services: { - venice: veniceStatus, - horizon: horizonStatus, - }, - }); - }, -); - -async function checkVenice(): Promise<'ok' | 'unreachable'> { - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 5_000); - const url = 'https://api.venice.ai/api/v1/models'; - const resp = await fetch(url, { - signal: controller.signal, - headers: { Authorization: `Bearer ${config.VENICE_API_KEY}` }, - }); - clearTimeout(timer); - return resp.ok || resp.status === 401 ? 'ok' : 'unreachable'; - } catch { - return 'unreachable'; - } -} - -async function checkHorizon(): Promise<'ok' | 'unreachable'> { - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 5_000); - const resp = await fetch(config.STELLAR_HORIZON_URL, { signal: controller.signal }); - clearTimeout(timer); - return resp.ok ? 'ok' : 'unreachable'; - } catch { - return 'unreachable'; - } -} - -export default router; import { Router, Request, Response } from "express"; import { getConfig } from "../../config"; import { adminAuthMiddleware } from "../middleware/auth"; @@ -115,7 +36,7 @@ let startTime = Date.now(); * stellarNetwork: * type: string */ -router.get("/", (_req: Request, res: Response) => { +function livenessHandler(_req: Request, res: Response): void { const config = getConfig(); res.json({ status: "ok", @@ -123,7 +44,24 @@ router.get("/", (_req: Request, res: Response) => { version: config.NPM_PACKAGE_VERSION, stellarNetwork: config.STELLAR_NETWORK, }); -}); +} + +router.get("/", livenessHandler); + +/** + * @openapi + * /health/live: + * get: + * summary: Basic liveness check + * operationId: getLive + * description: Alias for `GET /health` — process-only liveness, no dependency checks. + * tags: [Health] + * security: [] + * responses: + * 200: + * description: Service is up + */ +router.get("/live", livenessHandler); /** * @openapi @@ -156,10 +94,11 @@ router.get("/", (_req: Request, res: Response) => { router.get("/deep", async (_req: Request, res: Response) => { const config = getConfig(); const horizonUrl = config.STELLAR_HORIZON_URL; + const timeoutMs = config.HEALTH_PROBE_TIMEOUT_MS; const [veniceStatus, horizonStatus] = await Promise.all([ - checkVenice(config.VENICE_API_KEY), - checkHorizon(horizonUrl), + checkVenice(config.VENICE_API_KEY, timeoutMs), + checkHorizon(horizonUrl, timeoutMs), ]); res.json({ @@ -174,7 +113,16 @@ router.get("/deep", async (_req: Request, res: Response) => { * get: * summary: Readiness probe * operationId: getReadiness - * description: Verifies database connectivity for task and payment subsystems. Returns 200 when ready to serve traffic, 500/503 otherwise. + * description: > + * Verifies every dependency the backend needs to actually serve + * traffic: the task/payment/job-queue SQLite databases, the Venice AI + * and Stellar Horizon providers, and (if attached) the WebSocket + * stream server. Provider probes time out after + * `HEALTH_PROBE_TIMEOUT_MS` (default 5s, configurable via env). + * Returns 200 when ready to serve traffic, 500 otherwise. A missing + * WebSocket probe is reported as "unknown" (valid — the stream layer + * may not be attached) and does not by itself fail readiness; every + * other dependency being unavailable does. * tags: [Health] * security: [] * responses: @@ -189,6 +137,10 @@ router.get("/deep", async (_req: Request, res: Response) => { * checks: * tasks: "ok" * payments: "ok" + * queue: "ok" + * venice: "ok" + * horizon: "ok" + * websocket: "ok" * 500: * description: One or more subsystems failed readiness checks * content: @@ -200,16 +152,25 @@ router.get("/deep", async (_req: Request, res: Response) => { * checks: * tasks: "ok" * payments: "error" + * queue: "ok" + * venice: "ok" + * horizon: "ok" + * websocket: "unknown" */ router.get("/ready", async (_req: Request, res: Response) => { - const checks: Record = { + const checks: Record = { tasks: "ok", payments: "ok", + queue: "ok", + venice: "ok", + horizon: "ok", + websocket: "ok", }; try { const tasksModule = await import("../../db/tasks.js"); const paymentsModule = await import("../../db/index.js"); + const queueModule = await import("../../queue/jobStore.js"); try { const taskDb = (tasksModule.getTaskDb as Function)(); @@ -228,13 +189,44 @@ router.get("/ready", async (_req: Request, res: Response) => { } finally { (paymentsModule.closeDb as Function)(); } + + try { + const jobDb = (queueModule.getJobDb as Function)(); + jobDb.prepare("SELECT 1").get(); + } catch (error) { + (checks as any).queue = "error"; + } finally { + (queueModule.closeJobDb as Function)(); + } } catch (error) { res.status(500).json({ status: "error", checks, error: String(error) }); return; } - const allOk = Object.values(checks).every((status) => status === "ok"); - res.status(allOk ? 200 : 500).json({ status: allOk ? "ok" : "error", checks }); + const config = getConfig(); + const timeoutMs = config.HEALTH_PROBE_TIMEOUT_MS; + const [veniceStatus, horizonStatus] = await Promise.all([ + checkVenice(config.VENICE_API_KEY, timeoutMs), + checkHorizon(config.STELLAR_HORIZON_URL, timeoutMs), + ]); + checks.venice = veniceStatus === "ok" ? "ok" : "error"; + checks.horizon = horizonStatus === "ok" ? "ok" : "error"; + + const websocketStatus = metricsService.getWebSocketStatus(); + checks.websocket = + websocketStatus.status === "unknown" + ? "unknown" + : websocketStatus.status === "ok" + ? "ok" + : "error"; + + // A missing WebSocket probe ("unknown") is a valid configuration — the + // stream layer may simply not be attached — so it alone does not fail + // readiness. A probe that *is* attached and reports "error" (not + // listening) does, same as every other dependency. + const failing = Object.values(checks).filter((status) => status === "error"); + const ready = failing.length === 0; + res.status(ready ? 200 : 500).json({ status: ready ? "ok" : "error", checks }); }); /** @@ -362,10 +354,10 @@ router.get("/traces/:correlationId", (req: Request, res: Response) => { res.json(trace); }); -async function checkVenice(apiKey: string): Promise<"ok" | "unreachable"> { +async function checkVenice(apiKey: string, timeoutMs = 5000): Promise<"ok" | "unreachable"> { try { const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), 5000); + const t = setTimeout(() => ctrl.abort(), timeoutMs); const res = await fetch("https://api.venice.ai/api/v1/models", { headers: { Authorization: `Bearer ${apiKey}` }, signal: ctrl.signal, @@ -377,10 +369,10 @@ async function checkVenice(apiKey: string): Promise<"ok" | "unreachable"> { } } -async function checkHorizon(url: string): Promise<"ok" | "unreachable"> { +async function checkHorizon(url: string, timeoutMs = 5000): Promise<"ok" | "unreachable"> { try { const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), 5000); + const t = setTimeout(() => ctrl.abort(), timeoutMs); const res = await fetch(url, { signal: ctrl.signal }); clearTimeout(t); return res.ok ? "ok" : "unreachable"; diff --git a/backend/src/api/routes/stats.ts b/backend/src/api/routes/stats.ts index 4e843e40..c875f7f5 100644 --- a/backend/src/api/routes/stats.ts +++ b/backend/src/api/routes/stats.ts @@ -1,73 +1,3 @@ -/** - * GET /api/stats — network statistics - * - * Returns aggregated KPIs for the frontend dashboard. - * Cache TTL: CACHE_TTL_STATS (default 30s) - * - * The underlying stats computation is intentionally stubbed here; the full - * DB-backed implementation is tracked in Issue #29. The route is fully wired - * so the cache middleware exercises the real cache path. - */ - -import { Router, Request, Response } from 'express'; -import { ttlForRoute } from '../../config/index'; -import { cacheMiddleware } from '../middleware/cache'; - -const router = Router(); - -// GET /api/stats -router.get( - '/', - cacheMiddleware({ ttl: ttlForRoute('stats') }), - async (_req: Request, res: Response) => { - // TODO (Issue #29): replace with real DB aggregation via StatsService - const stats = await computeStats(); - res.json(stats); - }, -); - -// --------------------------------------------------------------------------- -// Stats computation (stub — real implementation in Issue #29) -// --------------------------------------------------------------------------- - -export interface TimePoint { - timestamp: string; // ISO-8601 - value: number; -} - -export interface StatsPayload { - totalAgents: number; - totalTasks: number; - totalXLMTransacted: string; // stringified for precision - uptimePercent: number; - tasksLast24h: TimePoint[]; - xlmLast24h: TimePoint[]; -} - -async function computeStats(): Promise { - // Build 24 hourly time-points ending at the current hour - const now = new Date(); - const tasksLast24h: TimePoint[] = []; - const xlmLast24h: TimePoint[] = []; - - for (let i = 23; i >= 0; i--) { - const ts = new Date(now); - ts.setHours(ts.getHours() - i, 0, 0, 0); - tasksLast24h.push({ timestamp: ts.toISOString(), value: 0 }); - xlmLast24h.push({ timestamp: ts.toISOString(), value: 0 }); - } - - return { - totalAgents: 0, - totalTasks: 0, - totalXLMTransacted: '0.0000000', - uptimePercent: 100, - tasksLast24h, - xlmLast24h, - }; -} - -export default router; import { Router } from 'express'; import { getStats, type DbClient } from '../../db/stats'; import { StatsCache } from '../../utils/statsCache'; diff --git a/backend/src/api/routes/stream.ts b/backend/src/api/routes/stream.ts index 443295de..0a0b6bce 100644 --- a/backend/src/api/routes/stream.ts +++ b/backend/src/api/routes/stream.ts @@ -14,6 +14,22 @@ const STREAM_PATH = /^\/tasks\/([^/?]+)\/stream(?:\?.*)?$/; const logger = createLogger({ module: 'ws-stream' }); +/** + * Every WebSocketServer created by attachTaskStream(), tracked so + * getStreamConnectionCount() can report a live total across all of them + * (normally just one, per HTTP server) for the health/metrics dashboard. + */ +const activeStreamServers = new Set(); + +/** Total connected WebSocket clients across every attached stream server. */ +export function getStreamConnectionCount(): number { + let count = 0; + for (const wss of activeStreamServers) { + count += wss.clients.size; + } + return count; +} + // --------------------------------------------------------------------------- // Wire-format normalisation // --------------------------------------------------------------------------- diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 30e0b3c6..c0f095d9 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -2,81 +2,9 @@ * Configuration module — loads and validates all env vars at startup. * Every other module imports from here; direct process.env access is banned. * - * Fails fast (throws) if any required var is missing or malformed. + * Fails fast (exits) if any required var is missing or malformed. */ -import { z } from 'zod'; - -// --------------------------------------------------------------------------- -// Schema -// --------------------------------------------------------------------------- - -const envSchema = z.object({ - // Server - PORT: z.coerce.number().int().positive().default(3001), - NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), - - // Stellar - STELLAR_NETWORK: z.enum(['testnet', 'mainnet']).default('testnet'), - STELLAR_HORIZON_URL: z - .string() - .url() - .default('https://horizon-testnet.stellar.org'), - - // Venice AI - VENICE_API_KEY: z.string().min(1, 'VENICE_API_KEY is required'), - - // Database - DATABASE_URL: z.string().min(1).default('./data/ai-net.db'), - - // Cache - CACHE_DRIVER: z.enum(['lru', 'redis']).default('lru'), - REDIS_URL: z.string().default('redis://localhost:6379'), - CACHE_LRU_MAX_SIZE: z.coerce.number().int().positive().default(500), - - // Per-endpoint TTLs (seconds) - CACHE_TTL_AGENTS: z.coerce.number().int().nonnegative().default(60), - CACHE_TTL_STATS: z.coerce.number().int().nonnegative().default(30), - CACHE_TTL_HEALTH: z.coerce.number().int().nonnegative().default(10), -}); - -// --------------------------------------------------------------------------- -// Parse — throws ZodError on missing/invalid vars -// --------------------------------------------------------------------------- - -function loadConfig() { - const result = envSchema.safeParse(process.env); - - if (!result.success) { - const messages = result.error.errors - .map((e) => ` ${e.path.join('.')}: ${e.message}`) - .join('\n'); - throw new Error(`[config] Invalid environment variables:\n${messages}`); - } - - return result.data; -} - -// Singleton — evaluated once at import time -export const config = loadConfig(); - -// --------------------------------------------------------------------------- -// Convenience helpers -// --------------------------------------------------------------------------- - -/** TTL in seconds for a given route group */ -export function ttlForRoute(group: 'agents' | 'stats' | 'health'): number { - switch (group) { - case 'agents': - return config.CACHE_TTL_AGENTS; - case 'stats': - return config.CACHE_TTL_STATS; - case 'health': - return config.CACHE_TTL_HEALTH; - } -} - -export type Config = typeof config; import { z } from "zod"; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -174,8 +102,11 @@ const envSchema = z.object({ METRICS_WINDOW_MS: z.coerce.number().int().positive().default(60_000), /** Maximum request samples retained in memory. Default: 1 000. */ METRICS_MAX_SAMPLES: z.coerce.number().int().positive().default(1_000), -}); + // ── Health probes ─────────────────────────────────────────────────────────── + /** Timeout in ms for each external dependency check performed by GET /health/deep and GET /health/ready. Default: 5 000 (5 s). */ + HEALTH_PROBE_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), +}); let _config: z.infer | null = null; diff --git a/backend/src/services/metrics.ts b/backend/src/services/metrics.ts index 091151b4..0990bb86 100644 --- a/backend/src/services/metrics.ts +++ b/backend/src/services/metrics.ts @@ -467,6 +467,15 @@ export class MetricsService { this.webSocketProbe = probe; } + /** + * Current WebSocket reachability, without a full dashboard collection. + * Used by GET /health/ready. See {@link checkWebSocket} for the "no probe + * registered" -> `unknown` semantics. + */ + getWebSocketStatus(): DependencyStatus { + return checkWebSocket(this.webSocketProbe); + } + /** Begin observing GC pauses. Safe to call more than once. */ startGcObserver(): void { if (this.gcObserver) return; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 63c8abd5..e1f8625d 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -13,20 +13,5 @@ "types": ["node", "jest"] }, "include": ["src"], - "module": "node16", - "moduleResolution": "node16", - "strict": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "outDir": "dist", - "rootDir": ".", - "resolveJsonModule": true, - "sourceMap": true, - "typeRoots": ["./node_modules/@types"], - "types": ["jest", "node"], - "baseUrl": "." - }, - "include": ["src", "tests"], "exclude": ["node_modules", "dist"] }