diff --git a/dashboard/DEPLOYMENT.md b/dashboard/DEPLOYMENT.md index 1ccdfbf..5cc64f9 100644 --- a/dashboard/DEPLOYMENT.md +++ b/dashboard/DEPLOYMENT.md @@ -105,7 +105,7 @@ The dashboard includes these production-ready features: - ✅ Error boundary - ✅ CSRF protection with token refresh - ✅ Service CRUD operations -- ✅ Real-time log streaming (SSE) +- ✅ Deployment log retrieval (JSON polling — no SSE; see backend README) - ✅ Audit logging - ✅ Structured logging - ✅ Database migrations diff --git a/dashboard/README.md b/dashboard/README.md index c97209c..810038a 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -23,7 +23,7 @@ dashboard/ - **Service Registry**: Manage all ByteLyst services (trading, notes, clock, etc.) - **Deployment Orchestration**: Trigger deployments via existing bash scripts - **Health Monitoring**: Real-time health checks for all services with caching -- **Deployment History**: Audit trail of all deployments with log streaming +- **Deployment History**: Audit trail of all deployments with captured logs (JSON-polled by the web client; no SSE) - **Cross-Navigation**: One-click link to Platform Admin dashboard - **Hermes Mission Control**: Read-only mock dashboard for portfolio-wide execution, task ledger, product health, history, agents, and settings - **Testing**: Vitest for backend, React Testing Library for frontend @@ -50,11 +50,9 @@ dashboard/ - Validated path parameters, query parameters, and request bodies - Strict validation on update operations to prevent accidental field changes -### Deployment Log Streaming -- Added SSE endpoint for real-time log streaming (`GET /api/deployments/:id/logs`) -- Frontend EventSource integration with cleanup function -- Automatic polling for running deployments (1-second interval) -- Proper connection cleanup on client disconnect +### Deployment Logs +- Endpoint `GET /api/deployments/:id/logs` returns the full captured stdout/stderr + current status as a single JSON payload (admin only). +- The web client polls this endpoint while a deployment is `running`. There is intentionally no SSE/WebSocket stream — the previous attempt with `fastify-sse-v2` was incompatible with Fastify 5 and was removed. If a real-time stream is needed later, implement it explicitly via `reply.raw` and update this section in the same change. ### Security Enhancements - Added rate limiting: 100 requests per minute per IP @@ -163,7 +161,7 @@ Production deployments use `https://api.bytelyst.com/devops` for `NEXT_PUBLIC_DE - `GET /api/deployments` - Recent deployments (with `?limit=` query param) - `GET /api/deployments/service/:serviceId` - Deployments for specific service - `GET /api/deployments/:id` - Single deployment -- `GET /api/deployments/:id/logs` - Stream deployment logs via SSE +- `GET /api/deployments/:id/logs` - Get captured deployment logs as JSON (web client polls this; no SSE) - `POST /api/deployments/trigger/:serviceId` - Trigger deployment (admin only) ### Health diff --git a/dashboard/REVIEW_ACTIONS.md b/dashboard/REVIEW_ACTIONS.md index 791ba3a..7be3bd4 100644 --- a/dashboard/REVIEW_ACTIONS.md +++ b/dashboard/REVIEW_ACTIONS.md @@ -36,10 +36,8 @@ Backend has 12 modules (`services`, `deployments`, `health`, `audit`, `backup`, - Action: add `*.test.ts` for at least `auth`, `csrf`, `deployments/orchestrator`, and `health` repository before adding more features. Mirror the style of . - Add `pnpm test:coverage` to CI and fail under a threshold (start at 50 %, raise over time). -### 4. SSE deployment-log streaming is disabled -`backend/src/server.ts` and `backend/src/modules/deployments/routes.ts` contain `TODO: fastify-sse-v2 has compatibility issues with Fastify 5`, with the SSE plugin commented out. The README still advertises real-time log streaming and the frontend code in imports `LogViewer`, so the user-facing feature is silently broken. - -- Action: pin a Fastify-5-compatible SSE library (`@fastify/eventsource`, `fastify-sse-v2 >= 5`, or a small handcrafted handler using `reply.raw`) and re-enable the route, OR remove the SSE claims from `README.md` / `ENDPOINTS.md` until it ships. Choose one — do not leave the gap. +### 4. SSE deployment-log streaming is disabled — RESOLVED (removed) +The TODO has been resolved by **removing the SSE claim**, not by shipping it: the `fastify-sse-v2` dependency is gone from `backend/package.json`, the commented-out import + plugin registration are gone from `backend/src/server.ts`, and the deployment-log endpoint is now documented as JSON-polled. The web client never used `EventSource` (`web/src/lib/api.ts` already polls `/api/deployments/:id/logs` via the normal `apiRequest` helper), so no UI change was required. README/DEPLOYMENT.md updated to match. If a real-time stream is wanted later, ship it explicitly via `reply.raw` and update the docs in the same change. ### 5. Documentation drift - `README.md` says "Web port: 3000" but `docker-compose.yml` exposes web as `3049:3000`. diff --git a/dashboard/backend/package.json b/dashboard/backend/package.json index 52db53c..21761c8 100644 --- a/dashboard/backend/package.json +++ b/dashboard/backend/package.json @@ -26,7 +26,6 @@ "@fastify/swagger-ui": "^5.2.1", "dotenv": "^16.4.5", "fastify": "^5.2.1", - "fastify-sse-v2": "^4.2.2", "jose": "^6.1.2", "zod": "^3.24.1" }, diff --git a/dashboard/backend/src/modules/deployments/routes.ts b/dashboard/backend/src/modules/deployments/routes.ts index 688967a..9294d62 100644 --- a/dashboard/backend/src/modules/deployments/routes.ts +++ b/dashboard/backend/src/modules/deployments/routes.ts @@ -44,8 +44,10 @@ export async function deploymentRoutes(fastify: FastifyInstance) { return reply.send(deployment); }); - // Get deployment logs (admin only; SSE disabled due to Fastify 5 compatibility) - // TODO: Re-enable SSE when fastify-sse-v2 supports Fastify 5 + // Get deployment logs (admin only). Returns the captured stdout/stderr + + // current status as a single JSON payload. The web client polls this for + // running deployments — there is intentionally no SSE/streaming variant + // (see server.ts for the full rationale). fastify.get('/deployments/:id/logs', { preHandler: async (req) => requireAdmin(req), }, async (req, reply) => { diff --git a/dashboard/backend/src/server.ts b/dashboard/backend/src/server.ts index 08b4e84..53cb801 100644 --- a/dashboard/backend/src/server.ts +++ b/dashboard/backend/src/server.ts @@ -15,7 +15,6 @@ import { codeQualityRoutes } from './modules/code-quality/routes.js'; import { cosmosConfigRoutes } from './modules/cosmos-config/routes.js'; import { hermesOpsRoutes } from './modules/hermes-ops/routes.js'; import { vmRoutes } from './modules/vm/routes.js'; -// import sse from 'fastify-sse-v2'; import rateLimit from '@fastify/rate-limit'; import swagger from '@fastify/swagger'; import swaggerUi from '@fastify/swagger-ui'; @@ -24,9 +23,12 @@ const fastify = Fastify({ logger: true, }); -// Register SSE plugin -// TODO: fastify-sse-v2 has compatibility issues with Fastify 5 -// await fastify.register(sse); +// NOTE: there is no Server-Sent-Events log stream. `fastify-sse-v2 ^4` is not +// compatible with Fastify 5 and was never functional here. The deployment-log +// endpoint (`GET /api/deployments/:id/logs`) returns JSON; the web client +// polls it. If a real-time stream is wanted later, ship it explicitly via +// `reply.raw` or a Fastify-5-compatible plugin and update the docs in the +// same change. // Register rate limiting await fastify.register(rateLimit, { diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 613c2d9..80b8c04 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -36,9 +36,6 @@ importers: fastify: specifier: ^5.2.1 version: 5.8.5 - fastify-sse-v2: - specifier: ^4.2.2 - version: 4.2.2(fastify@5.8.5) jose: specifier: ^6.1.2 version: 6.2.3 @@ -1330,9 +1327,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.29: resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} engines: {node: '>=6.0.0'} @@ -1356,9 +1350,6 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -1586,9 +1577,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -1604,17 +1592,9 @@ packages: fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - fastify-plugin@4.5.1: - resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} - fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - fastify-sse-v2@4.2.2: - resolution: {integrity: sha512-/XFZ7uyc/9C6ANabIs2bwymS0d3B2ZiJEcu4r/czpqYOEVSn+znKNrx0TraHPZkdhy2v0QNpIdYbgeLHBixMeA==} - peerDependencies: - fastify: '>=4' - fastify@5.8.5: resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} @@ -1670,9 +1650,6 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - get-iterator@1.0.2: - resolution: {integrity: sha512-v+dm9bNVfOYsY1OrhaCrmyOcYoSeVvbt+hHZ0Au+T+p1y+0Uyj9aMaGIeUTT6xdpRbWzDeYKvfOslPhggQMcsg==} - get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -1727,9 +1704,6 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1805,12 +1779,6 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - it-pushable@1.4.2: - resolution: {integrity: sha512-vVPu0CGRsTI8eCfhMknA7KIBqqGFolbRx+1mbQ6XuZ7YCz995Qj7L4XUviwClFunisDq96FdxzF5FnAbw15afg==} - - it-to-stream@1.0.0: - resolution: {integrity: sha512-pLULMZMAB/+vbdvbZtebC0nWBTbG581lk6w8P7DfIIIKUfa8FbY7Oi0FxZcFPbxvISs7A9E+cMpLDBc1XhpAOA==} - jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -2100,13 +2068,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - p-defer@3.0.0: - resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} - engines: {node: '>=8'} - - p-fifo@1.0.0: - resolution: {integrity: sha512-IjoCxXW48tqdtDFz6fqo5q1UfFVjjVZe8TC1QRflvNUJtNfCUhxOUw6MOVZhDPjqhSzc26xKdugsO17gmzd5+A==} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -2226,10 +2187,6 @@ packages: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -2366,9 +2323,6 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2513,9 +2467,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3852,8 +3803,6 @@ snapshots: balanced-match@4.0.4: {} - base64-js@1.5.1: {} - baseline-browser-mapping@2.10.29: {} brace-expansion@1.1.15: @@ -3879,11 +3828,6 @@ snapshots: buffer-equal-constant-time@1.0.1: {} - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -4114,8 +4058,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-fifo@1.3.2: {} - fast-json-stable-stringify@2.1.0: {} fast-json-stringify@6.4.0: @@ -4135,17 +4077,8 @@ snapshots: fast-uri@3.1.2: {} - fastify-plugin@4.5.1: {} - fastify-plugin@5.1.0: {} - fastify-sse-v2@4.2.2(fastify@5.8.5): - dependencies: - fastify: 5.8.5 - fastify-plugin: 4.5.1 - it-pushable: 1.4.2 - it-to-stream: 1.0.0 - fastify@5.8.5: dependencies: '@fastify/ajv-compiler': 4.0.5 @@ -4209,8 +4142,6 @@ snapshots: gensync@1.0.0-beta.2: {} - get-iterator@1.0.2: {} - get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -4274,8 +4205,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} - ignore@5.3.2: {} ignore@7.0.5: {} @@ -4336,19 +4265,6 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - it-pushable@1.4.2: - dependencies: - fast-fifo: 1.3.2 - - it-to-stream@1.0.0: - dependencies: - buffer: 6.0.3 - fast-fifo: 1.3.2 - get-iterator: 1.0.2 - p-defer: 3.0.0 - p-fifo: 1.0.0 - readable-stream: 3.6.2 - jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -4630,13 +4546,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - p-defer@3.0.0: {} - - p-fifo@1.0.0: - dependencies: - fast-fifo: 1.3.2 - p-defer: 3.0.0 - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -4748,12 +4657,6 @@ snapshots: react@19.2.6: {} - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - real-require@0.2.0: {} real-require@1.0.0: {} @@ -4906,10 +4809,6 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -5032,8 +4931,6 @@ snapshots: dependencies: punycode: 2.3.1 - util-deprecate@1.0.2: {} - vite-node@3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4): dependencies: cac: 6.7.14 diff --git a/docs/hermes_dashboard_v2_roadmap.md b/docs/hermes_dashboard_v2_roadmap.md index 8e53a25..f661250 100644 --- a/docs/hermes_dashboard_v2_roadmap.md +++ b/docs/hermes_dashboard_v2_roadmap.md @@ -123,7 +123,7 @@ This is the biggest operational asymmetry and the reason half the ops-panel warn - [x] **P0:** Fix the CI workspace path (`${{ gitea.workspace }}`) in `.gitea/workflows/ci.yml`, `DEPLOYMENT.md`, `scripts/deploy-hotcopy.sh` (currently point at non-existent `/opt/bytelyst/bytelyst-devops-tools/...`). - [x] **P0:** Replace the no-op `lint` echo with real linting (`next lint` for web, minimal ESLint for backend); make `pnpm lint` fail on bad code. - [x] **P1:** Add tests for `auth`, `csrf`, `deployments/orchestrator`, `health`, **and `hermes-ops`**; add `pnpm test:coverage` gate. *(35 new unit tests; v8 coverage thresholds gated on the six tested files in `backend/vitest.config.ts` (≥85% lines/funcs/stmts, ≥65% branches), wired into Gitea CI as a dedicated step. Today's actuals: ≥95% lines on every gated file. Ratchet up as more modules get tested.)* -- [ ] **P1:** Resolve the SSE TODO — either ship a Fastify-5-compatible log-stream or remove the SSE claim from docs/UI. +- [x] **P1:** Resolve the SSE TODO — either ship a Fastify-5-compatible log-stream or remove the SSE claim from docs/UI. *(Chose **remove**: dropped `fastify-sse-v2` dep, deleted commented-out plugin import + TODO from `server.ts` and `deployments/routes.ts`, rewrote the README/DEPLOYMENT.md "Log Streaming" section as "Logs (JSON-polled, no SSE)". Web client already polls `/deployments/:id/logs` via `apiRequest` — no UI change needed. If a real-time stream is wanted later, implement via `reply.raw` and update docs in the same change.)* - [ ] **P1:** Fix doc drift (web port 3000 vs 3049; endpoint URLs; merge duplicate deployment docs). - [ ] **P1:** Document the docker-socket + host-log/script mount privilege surface (the backend reads cross-user/host paths — blast radius must be written down; consider an allow-list wrapper over the raw socket). - [ ] **P2:** Structured backend logging (pino → stdout); wire E2E (`hermes.spec.ts`) into CI with a started stack.