From 73dc835403af1f7a7157b3e4a76da3e8753f31a7 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:20:08 -0300 Subject: [PATCH 01/20] docs(web): Specify the daemon-supervised web console Co-Authored-By: Claude --- .specs/features/web-daemon/design.md | 185 +++++++++ .specs/features/web-daemon/spec.md | 316 +++++++++++++++ .specs/features/web-daemon/tasks.md | 577 +++++++++++++++++++++++++++ 3 files changed, 1078 insertions(+) create mode 100644 .specs/features/web-daemon/design.md create mode 100644 .specs/features/web-daemon/spec.md create mode 100644 .specs/features/web-daemon/tasks.md diff --git a/.specs/features/web-daemon/design.md b/.specs/features/web-daemon/design.md new file mode 100644 index 0000000..f8614bd --- /dev/null +++ b/.specs/features/web-daemon/design.md @@ -0,0 +1,185 @@ +# Web daemon design + +**Spec**: `.specs/features/web-daemon/spec.md` +**Status**: Draft (revised after review: HTTP moved from the daemon process into a supervised child) + +--- + +## Architecture Overview + +The daemon supervises one web child process. The child is today's in-process server (`createUiRoutes` + the shared listener) started from `dist/web/child.js`. Web commands stop hosting HTTP: they ensure the daemon, call `web.ensure` with their build identity, and open or print the URL. When the build differs, the daemon replaces only the child. Sessions and the daemon process are never involved. The in-process server survives as the fallback. + +```mermaid +graph TD + C[codedeck review / setup / usage --web / ui] --> L[launchWebPage] + L -->|web.ensure port?, build| D[Daemon] + D --> S[WebSupervisor] + S -->|spawn node dist/web/child.js --web-child| W[web child] + W -->|stdout: one JSON handshake line| S + S -->|stdin pipe; EOF = exit| W + W --> R[createUiRoutes + listenWebServer] + R -->|usage.query over IPC| D + L -->|other error or daemon down| P[startWebServer in-process] + L --> B[openBrowser or print URL] +``` + +--- + +## Code Reuse Analysis + +### Existing Components to Leverage + +| Component | Location | How to Use | +| --- | --- | --- | +| Route table | `src/cli/commands/ui.ts:21-48` (`createUiRoutes`) | The web child and the in-process fallback serve it with default dependencies. | +| Listener, security, dispatch | `src/web/server.ts:79-196` | Split `startWebServer` into `listenWebServer` (listen + security + close, no signals, no browser) and the existing wrapper. | +| Token, Host, cookie, Origin checks | `src/web/security.ts:39-106` | Extend the policy with `api` and page-credential rules. | +| Usage query with fallback | `src/cli/commands/usage.ts:88-128` (`fetchUsageQuery`) | The child's usage route uses it unchanged. | +| Browser opener, port parser | `src/web/server.ts:47-77` | `launchWebPage` and the commands. | +| Daemon spawn | `src/daemon/ipc.ts:197-220` (`ensureDaemonStarted`) | Unchanged; also the model for spawning the child. | +| Daemon entry guard | `src/daemon/daemon.ts` bottom (`--daemon` argv check) | Same pattern for the child (`--web-child`). | +| Shutdown | `src/daemon/daemon.ts:1854` (`runShutdown`) | Sends SIGTERM to the child first, without awaiting. | +| Usage filter resolution | `src/cli/commands/usage.ts:214-243` | The resolved values become URL query parameters. | +| Setup refresh hook | `globalThis.setupPage.refreshCatalog()` (`src/web/setup-page.ts`) | The page calls it itself on `refresh=1`. | + +### Integration Points + +| System | Integration Method | +| --- | --- | +| IPC protocol | New method `web.ensure { port?, build?, entry? }` → `{ baseUrl, port, token }`. Errors `WEB_LISTEN_FAILED` (details `{ port }`), `WEB_START_FAILED`, `WEB_BAD_ENTRY`. | +| Daemon log | The supervisor's `log(line)` appends to `paths.daemonLog`. | +| Build output | `tsc` emits `src/web/child.ts` to `dist/web/child.js`; no build script change. | + +--- + +## Components + +### `checkWebRequest` credential rules (modify) + +- **Location**: `src/web/security.ts` +- **Interface**: `checkWebRequest(request, response, security, policy: { htmlPage?: boolean; api?: boolean }): boolean` +- **Behavior**: Host check first (unchanged). POST keeps cookie + Origin. `api` requires a cookie equal to the token for every method, else 403 `forbidden`. `htmlPage` GET: valid single `t` → set cookie + 303 without `t` (unchanged); else valid cookie → pass; else 403 body `open this page with codedeck ui`. `dispatchRequest` passes `api: route?.kind === "api"`. + +### Dispatch isolation and `listenWebServer` (modify) + +- **Location**: `src/web/server.ts` +- **Interfaces**: + - `listenWebServer({ routes, port, fallbackToEphemeral?, serverFactory? }): Promise` + - `ListeningWebServer = { server; port; baseUrl; security; close(): Promise }` + - `startWebServer` keeps its signature and behavior and delegates the listen to `listenWebServer`. +- **Behavior**: `dispatchRequest` runs the handler in `try` and attaches `.catch` to a returned thenable. On failure: headers not sent → 500 JSON `{ error }`, else `response.end()`. `listenWebServer` retries once on port 0 when `EADDRINUSE` and `fallbackToEphemeral`. `startWebServer` gains an optional `fallbackToEphemeral` passed through, for the CLI fallback. + +### Setup routes return their promises (modify) + +- **Location**: `src/web/setup-routes.ts:566-575` +- **Behavior**: Replace `void catalogRoute(...)`, `void refreshRoute(...)`, `void mutationRoute(...)` with `return ...` so the dispatcher sees rejections. + +### Review by repo (modify) + +- **Location**: `src/cli/commands/review.ts` (handler + in-process command path), `src/web/review-page.ts` +- **Behavior**: `/api/review` requires an absolute `repo` (400 messages per WD-34/35; 404 for `not a git repository`). `ReviewDeps.root` is removed. Until T13, the in-process `review` command opens `/review?repo=` so every commit works. The page reads `repo` from `location.search`. Without it, the page shows `Open this page with codedeck review inside a repository.` and fetches nothing. With it, the page appends `&repo=` to every fetch and puts it in the draft key: `codedeck-review:::`. The draft scan filters by the current repo prefix. + +### `/usage` interval and `/setup` refresh (modify) + +- **Location**: `src/web/usage-routes.ts`, `src/web/setup-page.ts` +- **Behavior**: `/usage` passes `search.get("interval") ?? options.page?.interval`. The setup page calls `refreshCatalog()` once after the initial load when `refresh=1`. + +### `computeBuildId` (new) + +- **Location**: `src/daemon/build-id.ts` +- **Interfaces**: `distRootFor(moduleUrl: string): string` (two directories above the module file). `computeBuildId(root: string): string` (newest `mtimeMs` of `*.js` under `root`, recursive, decimal string, `"0"` if none). + +### Web child entry (new) + +- **Location**: `src/web/child.ts` +- **Interfaces**: + - `runWebChild(options: { port?: number; stdin: Readable; stdout: Writable; listen?: typeof listenWebServer; routes?: () => WebRoute[]; build?: string; exit?: (code: number) => void }): Promise` + - Main guard: `if (process.argv.includes("--web-child"))` parses `--port ` and calls `runWebChild` with process streams. +- **Behavior**: Listens with `port ?? 3100` and `fallbackToEphemeral: port === undefined`. On success writes `{"port","token","build"}\n`. On listen failure writes `{"error":{"message","port"}}\n` and exits 1. On stdin `end`/`close` or SIGTERM: `server.close()`, `server.closeAllConnections()`, exit 0 without waiting for the close callback. `process.stdout` gets an `error` listener that ignores `EPIPE`. Build defaults to `computeBuildId(distRootFor(import.meta.url))`. + +### `WebSupervisor` (new) + +- **Location**: `src/daemon/web-supervisor.ts` +- **Interfaces**: + - `new WebSupervisor({ spawnChild?: (entry: string, args: string[]) => WebChildProcess; defaultEntry?: string; entryExists?: (path: string) => boolean; log: (line: string) => void; startTimeoutMs?: number; stopTimeoutMs?: number })` + - `ensure(params: { port?: number; build?: string; entry?: string }): Promise` + - `close(): void` + - `WebChildProcess = { stdin: Writable; stdout: Readable; kill(signal?): boolean; once("exit", cb) }` + - `WebEnsureError extends Error { code: "WEB_LISTEN_FAILED" | "WEB_START_FAILED" | "WEB_BAD_ENTRY"; details?: { port: number } }` + - `spawnChild(entry: string, args: string[])`: the entry is the script path +- **Behavior**: + - `entry` given and (not absolute, not ending in `/web/child.js`, or `!entryExists(entry)`) → `WEB_BAD_ENTRY`, nothing spawned. + - Child identity is `(entry ?? defaultEntry, build)`. Running and (`build` and `entry` both absent, or both equal to the child's) → stored result. A request that omits `build` but sends the same `entry` also reuses. + - Running and `build` or `entry` differs → SIGTERM, wait ≤ 3000 ms for `exit`, SIGKILL if still alive, then start from the requested entry. + - Starting → the shared in-flight promise (a restart shares it too). + - Start → spawn `entry` with `--web-child` and `--port ` when given. Buffer stdout until the first `\n` within 5000 ms, then `stdout.resume()` and discard the rest. Valid line with numeric `port` and string `token` → store `{ baseUrl: "http://127.0.0.1:", port, token, build, entry }`, log `web listening port=`. `error` line → `WEB_LISTEN_FAILED` with details. Invalid JSON, missing fields, exit, or timeout before a line → kill, `WEB_START_FAILED`. + - A later child `exit` → state none, log `web child exited code=`. + - `close()` sends SIGTERM and returns. +- **Default spawn**: `spawn(process.execPath, [entry, ...args], { stdio: ["pipe", "pipe", fd] })`, where `fd` is `fs.openSync(path.join(paths.logsDir, "web-child.log"), "a")`, closed in the parent after spawn. `defaultEntry` = `fileURLToPath(new URL("../web/child.js", import.meta.url))`. +- **Constraint**: imports nothing from `src/web/` or `src/cli/` (WD-28). + +### Daemon wiring (modify) + +- **Location**: `src/daemon/daemon.ts`, `src/daemon/protocol.ts` +- **Behavior**: `web.ensure` lazily builds `this.web = new WebSupervisor({ log })` (constructor option `webSupervisor` for tests), awaits `ensure`, sends `{ result }` or `{ error: { code, message, details } }`. `runShutdown` calls `this.web?.close()` before the drain. Protocol adds `"web.ensure"`, `WebEnsureParams`, `WebEnsureResult`. + +### `launchWebPage` (new) + +- **Location**: `src/cli/web-launch.ts` +- **Interface**: `launchWebPage(options: { path; query?: Record; title; port?: number; open: boolean }, deps?: { client?; openBrowser?; log?; startServer?; build? }): Promise` +- **Behavior**: + 1. `client.ensureDaemonStarted()`. Throws → fallback with the WD-45 message. + 2. `web.ensure { port?, build, entry }`, with build defaulting to `computeBuildId(distRootFor(import.meta.url))` and entry to `fileURLToPath(new URL("../web/child.js", import.meta.url))`. `WEB_LISTEN_FAILED` → print WD-22 and return 1. Any other error → fallback with the WD-44 message. + 3. Explicit `port` ≠ result port → WD-21 notice. + 4. URL = `new URL(path, baseUrl)` + query entries + `t`. Print ` on <url>` (after opening, or instead of opening with `open: false`), or the WD-19 line when the opener fails. Return 0. + - Fallback: `startServer({ routes: createUiRoutes(), port: port ?? DEFAULT_WEB_PORT, fallbackToEphemeral: port === undefined, initialPath, title, open })` where `initialPath` is `path` plus `?` + `new URLSearchParams(query)` only when the query is non-empty. Then return 0 with the process kept alive by the server. +- **IPC error codes**: `IpcClient.request` rejects with `ipcError(res.error)`, an `Error` carrying `code` and `details` (src/daemon/ipc.ts:11). `launchWebPage` reads `error.code` and `error.details.port`. + +### Command rewiring (modify) + +- `ui`: `launchWebPage({ path: "/", title: "CodeDeck UI" })`. +- `review`: `launchWebPage({ path: "/review", query: { repo: process.cwd() }, title: "CodeDeck review" })`. +- `setup`: TTY check only in the `--tui` branch. Web branch → `launchWebPage({ path: "/setup", query: refresh ? { refresh: "1" } : {} })`. Delete `createSetupCommandRoutes`. Stop defaulting a missing port to 3100. +- `usage --web`: `launchWebPage({ path: "/usage", query })`. +- Commander defaults `String(DEFAULT_WEB_PORT)` on `--port` are removed from ui and review so an omitted port stays undefined. + +--- + +## Error Handling Strategy + +| Error Scenario | Handling | User Impact | +| --- | --- | --- | +| Invalid `--port` | Parse before IPC, exit 1 | Port error | +| Explicit port busy | `WEB_LISTEN_FAILED` → exit 1 | `Failed to listen on 127.0.0.1:<p>: ...` | +| Default port busy | Child uses an ephemeral port | URL on another port | +| Route throws or rejects | 500 JSON | Page error state | +| Child crashes | State reset, next `web.ensure` respawns | Rerun the command | +| Child hangs at start | 5000 ms timeout, kill, `WEB_START_FAILED` → fallback | Terminal stays attached with a notice | +| Old daemon, daemon shutting down, socket error | Fallback | Same | +| Daemon killed with SIGKILL | Child sees stdin EOF and exits | Port freed | +| API call without cookie | 403 | Rerun the command | + +--- + +## Risks & Concerns + +| Concern | Location (file:line) | Impact | Mitigation | +| --- | --- | --- | --- | +| Setup routes discard promises | `src/web/setup-routes.ts:566-575` | Unhandled rejection kills the child | T3 returns them; T2 catches them. A crash now costs only the child. | +| `startWebServer` exits on SIGINT/SIGTERM | `src/web/server.ts:135-159` | Would be wrong in a long-lived host | The child uses `listenWebServer` and owns its own SIGTERM path. The daemon never loads the web stack (WD-28). | +| Existing tests hit API routes without a cookie | `tests/usage-web.test.ts`, `tests/setup-web.test.ts`, `tests/web-cli.test.ts`, `tests/review.test.ts`, `tests/review-command.test.ts`, `tests/web-server.test.ts` | WD-29 breaks them | T1 bootstraps the cookie in these tests. | +| Tests pinning the old setup and usage command paths | `tests/setup-wizard.test.ts:1190-1210` (no-TTY error), `tests/setup-cli-contract.test.ts:709-717, 764-798` (refresh injection), `tests/usage-cli.test.ts:300-333` (`startServer` spies) | Break at rewiring | T14 and T15 rewrite them against an injected launcher. | +| Two dist trees on one daemon (this machine: global `codedeck` in nvm `node_modules`, `codedeck-dev` from the checkout) | `web.ensure` identity | Without `entry`, the child would always come from the daemon's tree and restart on every command | `entry` in `web.ensure`; alternating trees restart the child, repeated commands reuse it. | +| Child writes to stdout after the handshake | `src/web/child.ts` | A full pipe would block the child | Supervisor drains stdout; child ignores `EPIPE`. | + +--- + +## Tech Decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| HTTP host | Supervised child process | Isolates crashes from the daemon, and a restart on build change touches no session. The reviewer's blocker (legacy `daemon.stop` ignoring `ifIdle`) and the "always busy" problem disappear. | +| Child → daemon handshake | One JSON line on stdout | Simplest channel the daemon already has with a spawned child. | +| Orphan prevention | Child exits on stdin EOF | Works on SIGKILL of the daemon, unlike signal forwarding. | +| Usage in child | Existing `fetchUsageQuery` over IPC | No new data path. | +| Review draft key | Prefix with `repo` | One origin now serves many repos. | diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md new file mode 100644 index 0000000..d1a9b06 --- /dev/null +++ b/.specs/features/web-daemon/spec.md @@ -0,0 +1,316 @@ +# Web daemon specification + +## Problem Statement + +Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP server inside the CLI process, and all of them default to port 3100. A second web command fails with `EADDRINUSE` while the first one runs (observed 2026-09-24: `review --port 3188` running, `usage --web --port 3188` printed `Failed to listen on 127.0.0.1:3188: listen EADDRINUSE` and exited 1). The daemon already lives across commands, so it should own the one web server: start it, hand out its URL, and restart it when the code changes. + +## Goals + +- [ ] One web server, supervised by the daemon, serves every page; running `review`, `setup`, `usage --web`, and `ui` in any order opens pages on the same origin without a port conflict. +- [ ] Web commands return to the shell after opening or printing the URL instead of holding the terminal. +- [ ] A web server failure never stops the daemon or touches a session. +- [ ] After `npm run build`, the next web command serves pages from the new build without restarting the daemon. + +## Current state (verified) + +- `startWebServer` (src/web/server.ts:92-173) binds 127.0.0.1, creates a per-start token after listen, opens or prints `?t=<token>`, and installs SIGINT/SIGTERM handlers that call `process.exit(0)` (src/web/server.ts:135-159). +- `checkWebRequest` (src/web/security.ts:39-60) checks Host on every request, requires cookie + Origin only on POST, and bootstraps the cookie from a valid `t` on HTML GETs. The 303 removes only `t` and keeps other query parameters (src/web/security.ts:99-104). API GETs need no cookie. +- `dispatchRequest` calls the route handler with no error handling (src/web/server.ts:175-196). The setup routes discard their async promises with `void` (src/web/setup-routes.ts:566-575). +- `createUiRoutes` (src/cli/commands/ui.ts:21-48) composes home, review, setup, and usage routes into one table. With default dependencies, usage goes through `fetchUsageQuery`, which uses daemon IPC and falls back to read-only SQLite (src/cli/commands/usage.ts:88-128). +- The review handler reads the repo from `process.cwd()` at request time (src/cli/commands/review.ts:44). The page fetches `api/review?ref=` only (src/web/review-page.ts:627-629). Review drafts live in localStorage under `codedeck-review:<file>:<line>`, without the repo (src/web/review-page.ts:209-210). +- `usage --web` resolves CLI filters into page options (src/cli/commands/usage.ts:214-243). `/usage` reads `period`, `repo`, `model`, `agent`, `since`, `until`, `by` from the query but not `interval` (src/web/usage-routes.ts:43-62). +- `setup` web requires a TTY on stdin and stdout before it starts the server (src/cli/commands/setup.ts:1269-1274), defaults a missing `--port` to 3100 (src/cli/commands/setup.ts:1291), and `--refresh` injects a catalog refresh script (src/cli/commands/setup.ts:1235-1251). +- `daemon.stop` ignores params and runs `handleShutdown`, which marks every active session `interrupted` and kills its process tree (src/daemon/daemon.ts:1352-1355, 1854-1874). This feature does not change `daemon.stop`. +- IPC errors carry only `code`, `message`, `details` (src/daemon/protocol.ts:267). Unknown methods answer `UNKNOWN_METHOD` (src/daemon/daemon.ts:1358). + +## Out of Scope + +| Feature | Reason | +| --- | --- | +| Restarting the daemon itself when its build is stale | A daemon stop interrupts sessions (src/daemon/daemon.ts:1863-1874). The web child restart covers page staleness. | +| New pages (live sessions, logs over SSE) | This feature moves hosting; new pages build on it later. | +| Persisting the web token across web child restarts | A restart invalidates old tabs; rerunning the command gives a fresh link. | +| Listing recent repositories on the review page | Review stays single-repo, chosen by the command's cwd. | +| Changing page visuals or setup/usage API payloads | Pages and JSON contracts stay as they are except where listed below. | +| Remote access | The server stays on 127.0.0.1. | +| Setup `--tui`, setup batch flags, usage TUI/JSON/single-run/backfill paths | They do not start a web server and keep their current behavior. | + +--- + +## Assumptions & Open Questions + +| Assumption / decision | Chosen default | Rationale | Confirmed? | +| --- | --- | --- | --- | +| Where HTTP runs | In a web child process the daemon spawns from the `dist/web/child.js` of the requesting CLI (`entry` in `web.ensure`), or from its own dist tree when `entry` is absent. The daemon process never listens on HTTP itself. | A crash or hang in the web stack cannot reach the daemon, and a fresh child loads the newest code from disk. | n | +| When the child starts | Lazily, on the first `web.ensure` IPC request. | No open port unless someone uses the web console. | n | +| Child lifetime | Until the daemon exits, the child crashes, or a build change restarts it. The child exits when its stdin pipe from the daemon closes. | A daemon killed with SIGKILL must not leave an orphan holding port 3100. | n | +| Child startup handshake | The child prints one JSON line on stdout: `{ "port", "token", "build" }` on success or `{ "error": { "message", "port" } }` on listen failure. The daemon buffers until the first newline, waits up to 5000 ms, then keeps draining and discarding stdout. | Simple and testable; draining keeps a later write from blocking on a full pipe. | n | +| Child stderr | Appended to `~/.run-agent/logs/web-child.log`. | The exit code alone does not explain a crash. | n | +| Build identity | The newest mtime (ms) of `.js` files under the dist root (the directory two levels above the module file), as a decimal string, `"0"` when none. The child computes it at start; the CLI computes it over its own dist root and sends it in `web.ensure` together with `entry`, the absolute path of its own `dist/web/child.js`. The daemon identifies a child by `(entry, build)`. | `tsc` rewrites every emitted file on a full build and only changed files in watch mode; the newest mtime covers both. | n | +| Build or entry mismatch | The daemon stops the running child and starts one from the requested `entry` before answering. | The child restart touches no session, and each CLI gets the pages of its own tree. | +| Two dist trees sharing one daemon (global install and dev checkout, the setup on this machine) | Alternating commands from the two trees restart the child each time; repeated commands from one tree reuse it. Accepted. | The cost is a new token, not lost work, and each tree serves its own pages. | n | +| Command lifetime | Web commands exit 0 after opening or printing the URL. Ctrl+C no longer closes the server. | The server belongs to the daemon. | n | +| Preferred port | 3100, or the command's `--port`, honored only when the daemon starts a child. | Keeps the existing flag meaningful. | n | +| Default port busy | The child falls back to an OS-assigned port and reports it. | A foreign process on 3100 must not break the console. | n | +| Explicit `--port` busy | Report the listen error, exit 1, start nothing. | An explicit port is a request, not a hint. | n | +| Server already running on another port | Use it and print a notice naming the actual port. | Restarting would invalidate open tabs. | n | +| Usage data in the child | The child uses the default `fetchUsageQuery` (IPC `usage.query`, read-only SQLite fallback). | Reuses the existing path; the daemon is alive while the child runs. | n | +| Review repository | The command sends its cwd as `repo`; the page forwards `repo` to `/api/review`. Without `repo`, the page shows `Open this page with codedeck review inside a repository.` and makes no API request. | The child's cwd is meaningless for review. `codedeck ui` has no repo to pass. | n | +| API GET auth | Every `/api/*` request requires the session cookie. | A long-lived server that reads any repo by path must not answer unauthenticated local callers. | n | +| Page GET without token or cookie | 403 with the text `open this page with codedeck ui`. | Without the cookie the page's API calls fail anyway. | n | +| Usage filters from CLI flags | The command resolves flags into concrete query parameters on the URL. | The child cannot see the command's cwd or flags. | n | +| `setup --refresh` | Opens `/setup?refresh=1`; the page refreshes the catalog once on load. | Replaces per-command script injection. | n | +| Setup TTY requirement | Removed for the web path, kept for `--tui`. | A browser page does not need the terminal. | n | +| Daemon cannot host | Any `web.ensure` failure other than an explicit-port listen error (unknown method from an older daemon, shutdown in progress, child start timeout, socket error) or a daemon start failure makes the command serve in-process, as today, with a one-line notice. | The web console must keep working during the upgrade and when the daemon is broken. | n | +| Concurrent daemon starts | Two web commands can still race `ensureDaemonStarted` as today. No new lock. | Existing behavior; this feature adds no daemon restarts. | n | + +**Open questions:** none. All resolved or logged above. + +--- + +## User Stories + +### P1: Daemon supervises one web server ⭐ MVP + +**User Story**: As a CodeDeck user, I want every web command to open pages from one daemon-supervised server so that I can open review, setup, and usage at the same time. + +**Why P1**: This removes the port conflict, which is the reason for the feature. + +**Acceptance Criteria**: + +1. WHEN the daemon receives `web.ensure` and no web child runs THEN the daemon SHALL spawn the web child from the request's `entry` (or its own `dist/web/child.js` when `entry` is absent) and return `{ baseUrl, port, token }` from its handshake. WD-01 +2. WHEN the daemon receives `web.ensure` with the same `entry` and `build` as the running child THEN the daemon SHALL return the running child's `baseUrl`, `port`, and `token` without spawning. WD-02 +3. WHEN two `web.ensure` requests arrive before the first handshake completes THEN the daemon SHALL spawn exactly one child and return the same result to both. WD-03 +4. IF `web.ensure` has no `port` and 3100 is in use THEN the child SHALL listen on an OS-assigned port and the daemon SHALL return that port. WD-04 +5. IF `web.ensure` has an explicit `port` and that port is in use THEN the daemon SHALL return an error with code `WEB_LISTEN_FAILED`, the listen error as `message`, and `{ port }` as `details`. WD-05 +6. IF the child exits before its handshake, sends no handshake line within 5000 ms, or sends a first line that is not JSON with a numeric `port` and a string `token` (or an `error` object) THEN the daemon SHALL kill it and return an error with code `WEB_START_FAILED`. WD-06 +7. WHEN the web child exits after its handshake THEN the daemon SHALL mark the web server stopped, append `web child exited code=<code>` to the daemon log, and spawn a new child on the next `web.ensure`. WD-07 +8. The web child SHALL serve the `createUiRoutes` table: `/`, `/review`, `/api/review`, `/setup`, the `/api/setup/*` routes, `/usage`, and `/api/usage`. WD-08 +9. WHEN the web child's stdin reaches end of stream or it receives SIGTERM THEN the child SHALL stop listening, close all open connections, and exit with code 0 without waiting for in-flight requests. WD-09 +10. WHEN the daemon shuts down while a web child runs THEN the daemon SHALL send SIGTERM to the child without waiting for it to exit. WD-10 +11. WHEN a web child completes its handshake THEN the daemon SHALL append `web listening port=<port>` to the daemon log, without the token. WD-11 + +**Independent Test**: Drive `web.ensure` through the daemon seam with an injected spawn that returns a fake child; assert one spawn for repeated and concurrent calls, the error codes, and the log lines. Run the child entry function with injected stdin/stdout and a port-0 listener; assert the handshake line, the served routes, and exit on stdin end. + +--- + +### P1: Web commands delegate to the daemon ⭐ MVP + +**User Story**: As a CodeDeck user, I want `review`, `setup`, `usage --web`, and `ui` to open the daemon's page and return me to the shell. + +**Why P1**: Without this the daemon server has no entry point. + +**Acceptance Criteria**: + +1. WHEN `codedeck ui` runs THEN the command SHALL ensure the daemon, call `web.ensure` with its local build, and open `<baseUrl>/?t=<token>`. WD-12 +2. WHEN `codedeck review` runs THEN the command SHALL open `<baseUrl>/review?repo=<cwd>&t=<token>`. WD-13 +3. WHEN `codedeck setup` runs without batch or `--tui` flags THEN the command SHALL open `<baseUrl>/setup?t=<token>` without requiring a TTY. WD-14 +4. WHEN `codedeck setup --refresh` runs THEN the command SHALL open `<baseUrl>/setup?refresh=1&t=<token>`. WD-15 +5. WHEN `codedeck usage --web` runs THEN the command SHALL open `<baseUrl>/usage?<filters>&t=<token>`, where the filters are the non-empty values of `period`, `repo`, `model`, `agent`, `since`, `until` resolved from its flags and cwd, plus `by` and `interval` from its options. WD-16 +6. WHEN a web command opens the URL THEN the command SHALL print `<title> on <url>` and exit with code 0. WD-17 +7. WHEN a web command runs with `--no-open` THEN the command SHALL print `<title> on <url>` without opening a browser and exit with code 0. WD-18 +8. IF the browser cannot be opened THEN the command SHALL print `Could not open a browser, visit <url> manually.` and exit with code 0. WD-19 +9. WHEN a web command runs with `--port <n>` THEN the command SHALL send `n` as `port` in `web.ensure`; WHEN `--port` is omitted THEN the command SHALL send no `port`. WD-20 +10. IF `web.ensure` returns a port different from an explicit `--port` THEN the command SHALL print `CodeDeck web is already running on port <port>` before the URL line. WD-21 +11. IF `web.ensure` returns `WEB_LISTEN_FAILED` THEN the command SHALL print `Failed to listen on 127.0.0.1:<port>: <message>` and exit with code 1. WD-22 +12. IF `--port` is not an integer from 1 through 65535 THEN the command SHALL report the port error, exit with code 1, and send no IPC request. WD-23 + +**Independent Test**: Run each command with an injected launcher or IPC client and browser opener; assert the `web.ensure` params, the opened URL, the printed lines, and the exit code. + +--- + +### P1: Web failures stay isolated ⭐ MVP + +**User Story**: As a CodeDeck user, I want a broken web page to leave my running agents and the rest of the console alone. + +**Why P1**: A long-lived server must survive one bad request. + +**Acceptance Criteria**: + +1. IF a route handler throws synchronously THEN the server SHALL respond 500 with `{ "error": "<message>" }` when headers are not yet sent. WD-24 +2. IF a route handler returns a rejected promise THEN the server SHALL respond 500 with `{ "error": "<message>" }` when headers are not yet sent. WD-25 +3. IF a route handler fails after headers are sent THEN the server SHALL end the response without writing a second status line and keep serving later requests. WD-26 +4. The setup API route handlers SHALL return their async work to the dispatcher instead of discarding it. WD-27 +5. The daemon process SHALL NOT import `src/web/server.ts` or open an HTTP listener. WD-28 + +**Independent Test**: Register throwing, rejecting, and late-failing routes on a port-0 server and assert the responses; make a real setup mutation route throw and assert a 500 instead of an unhandled rejection. + +--- + +### P1: Long-lived server authentication ⭐ MVP + +**User Story**: As a CodeDeck user, I want the web server to answer only my browser session. + +**Why P1**: The server now outlives the command and reads any repository path it is given. + +**Acceptance Criteria**: + +1. IF an `/api/*` request lacks a cookie whose value equals the current token THEN the server SHALL respond 403 `forbidden` before the route handler runs. WD-29 +2. IF an HTML page GET carries neither a valid `t` nor a valid cookie THEN the server SHALL respond 403 with the body `open this page with codedeck ui`. WD-30 +3. WHEN an HTML page GET carries a valid `t` THEN the server SHALL set the cookie and respond 303 to the same path and remaining query without `t`. WD-31 +4. The server SHALL keep the existing Host check and the POST Origin check. WD-32 + +**Independent Test**: Request an API route with and without the cookie, a page with no credentials, and `/review?repo=%2Fx&t=<token>`; assert 403, 403 with the message, and 303 to `/review?repo=%2Fx` with `Set-Cookie`. + +--- + +### P1: Review reads the requested repository ⭐ MVP + +**User Story**: As a CodeDeck user, I want `codedeck review` in any repo to show that repo's changes even though the server started elsewhere. + +**Why P1**: Without it, review shows the server's cwd. + +**Acceptance Criteria**: + +1. WHEN `/api/review` receives `repo=<path>` THEN the handler SHALL load the review from `<path>`. WD-33 +2. IF `/api/review` receives no `repo` parameter THEN the handler SHALL respond 400 with `{ "error": "repo query parameter is required" }`. WD-34 +3. IF `repo` is not an absolute path THEN the handler SHALL respond 400 with `{ "error": "repo must be an absolute path" }`. WD-35 +4. IF loading `repo` fails with a `not a git repository` error THEN the handler SHALL respond 404 with that error. WD-36 +5. WHEN the review page loads with `repo` in its query THEN the page SHALL send that `repo` value on every `/api/review` request. WD-37 +6. WHEN the review page loads without `repo` THEN the page SHALL show `Open this page with codedeck review inside a repository.` and make no `/api/review` request. WD-38 +7. The review page SHALL include `repo` in every draft localStorage key. WD-39 + +**Independent Test**: Call the review handler with an injected loader and each `repo` variant; run the page script in `node:vm` with a fake `fetch` and `localStorage` and assert the requested URL, the no-repo message, and the draft keys. + +--- + +### P2: Web child restarts on build change + +**User Story**: As a developer iterating on pages, I want a web command after `npm run build` to serve the new pages without restarting the daemon by hand. + +**Why P2**: The server works without it, but every page change would need a manual restart. + +**Acceptance Criteria**: + +1. The web child SHALL compute its build identity at start and include it as `build` in its handshake. WD-40 +2. WHEN the daemon receives `web.ensure` whose `build` or `entry` differs from the running child's THEN the daemon SHALL send SIGTERM to the running child, wait up to 3000 ms for it to exit, send SIGKILL if it has not, spawn a new child, and return the new child's result. WD-41 +3. WHEN the daemon receives `web.ensure` without `build` while a child runs, and the request either omits `entry` or sends the running child's `entry`, THEN the daemon SHALL return the running child's result. WD-42 +4. WHILE the daemon restarts the web child the daemon SHALL leave every session row and session process untouched. WD-43 + +**Independent Test**: With a fake spawn, ensure with build `a`, then with build `b`; assert SIGTERM to the first child, a second spawn, and the second child's token in the result; seed a `working` session and assert its row is unchanged. + +--- + +### P2: In-process fallback + +**User Story**: As a CodeDeck user, I want the web console to open even when the daemon cannot host it. + +**Why P2**: This covers the upgrade from a daemon without `web.ensure` and a broken daemon. + +**Acceptance Criteria**: + +1. IF `web.ensure` fails with any error other than `WEB_LISTEN_FAILED` THEN the command SHALL print `CodeDeck daemon cannot host the web console (<code>); serving from this process.` and start the pages in-process with the full route table, an initial path whose query is encoded with `URLSearchParams`, and an ephemeral-port fallback when `--port` was omitted. WD-44 +2. IF `ensureDaemonStarted` fails THEN the command SHALL print `CodeDeck daemon is unavailable; serving from this process.` and start the pages in-process. WD-45 +3. WHILE a command serves in-process the command SHALL keep running until SIGINT or SIGTERM, with the current `startWebServer` behavior. WD-46 + +**Independent Test**: Drive a web command with an IPC client that answers `UNKNOWN_METHOD`, then `SERVICE_UNAVAILABLE`, then one whose start fails; assert the in-process server factory runs with the initial path and query, and the message prints. + +--- + +## Edge Cases + +- IF `interval` is present in the `/usage` query THEN the page SHALL use it as the polling interval with the existing `Math.max(1, Number(value) || 2)` normalization. WD-47 +- WHEN `/setup` loads with `refresh=1` THEN the page SHALL send `POST /api/setup/catalog/refresh` exactly once after its initial load. WD-48 +- IF `web.ensure` carries an `entry` that is not absolute, does not end in `/web/child.js`, or does not exist THEN the daemon SHALL return an error with code `WEB_BAD_ENTRY` and spawn nothing. WD-49 +- WHEN the web child writes to stderr THEN the daemon SHALL append it to `~/.run-agent/logs/web-child.log`. WD-50 + +--- + +## Implicit-requirement sweep + +| Dimension | Resolution | +| --- | --- | +| Input validation & bounds | WD-23 (port), WD-34 to WD-36 (repo), WD-47 (interval), WD-49 (entry), WD-06 (handshake shape). | +| Failure / partial-failure states | WD-05, WD-06, WD-07, WD-22, WD-24 to WD-26, WD-44, WD-45. | +| Idempotency / retry / duplicates | WD-02, WD-03, WD-07. | +| Auth boundaries & rate limits | WD-29 to WD-32. Rate limits N/A because the server binds loopback and requires the token. | +| Concurrency / ordering | WD-03; concurrent daemon starts logged in Assumptions. | +| Data lifecycle / expiry | WD-09, WD-10, WD-41; the token dies with its child (Out of Scope: persistence). Draft keys per repo: WD-39. | +| Observability | WD-07 and WD-11 (daemon log), WD-50 (child stderr); command lines in WD-17, WD-21, WD-22, WD-44, WD-45. | +| External-dependency failure | WD-19 (browser), WD-45 (daemon start), WD-06 (child start). | +| State-transition integrity | Web child: none → starting → running (WD-01, WD-03); starting → none on failure (WD-05, WD-06); running → none on exit (WD-07); running → restarting → running on build change (WD-41). Sessions untouched: WD-43. | + +--- + +## External Dependencies + +| Resource | Identifier | System | Verified | Evidence | +| --- | --- | --- | --- | --- | +| Node listen error code for a busy port | EADDRINUSE | Node.js | yes | observed 2026-09-24: `usage --web --port 3188` printed `listen EADDRINUSE: address already in use 127.0.0.1:3188` | +| daemon error code for an unknown IPC method | UNKNOWN_METHOD | repo | yes | src/daemon/daemon.ts:1358 | +| daemon error code while shutting down | SERVICE_UNAVAILABLE | repo | yes | src/daemon/daemon.ts:579 | +| current review page API call | api/review?ref= | repo | yes | src/web/review-page.ts:629 | +| new daemon error code for a busy explicit port | WEB_LISTEN_FAILED | repo | yes | defined in WD-05; implemented in src/daemon/daemon.ts | +| new daemon error code for a failed child start | WEB_START_FAILED | repo | yes | defined in WD-06; implemented in src/daemon/daemon.ts | +| new daemon error code for an invalid child entry | WEB_BAD_ENTRY | repo | yes | defined in WD-49; implemented in src/daemon/web-supervisor.ts | +| web child stderr log under the logs dir | ~/.run-agent/logs/web-child.log | repo | yes | src/config/paths.ts:47 (logsDir) | +| page URLs built by the commands | <baseUrl>/?t=<token> | repo | yes | src/web/server.ts:121-123 (URL + token construction reused) | +| review URL | <baseUrl>/review?repo=<cwd>&t=<token> | repo | yes | src/web/server.ts:121-123 | +| setup URL | <baseUrl>/setup?t=<token> | repo | yes | src/web/server.ts:121-123 | +| setup refresh URL | <baseUrl>/setup?refresh=1&t=<token> | repo | yes | src/web/server.ts:121-123 | +| usage URL | <baseUrl>/usage?<filters>&t=<token> | repo | yes | src/web/server.ts:121-123 | + +## Requirement Traceability + +| Requirement ID | Story | Phase | Status | +| --- | --- | --- | --- | +| WD-01 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-02 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-03 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-04 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-05 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-06 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-07 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-08 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-09 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-10 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-11 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-12 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-13 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-14 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-15 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-16 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-17 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-18 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-19 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-20 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-21 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-22 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-23 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-24 | P1: Web failures stay isolated | Tasks | Pending | +| WD-25 | P1: Web failures stay isolated | Tasks | Pending | +| WD-26 | P1: Web failures stay isolated | Tasks | Pending | +| WD-27 | P1: Web failures stay isolated | Tasks | Pending | +| WD-28 | P1: Web failures stay isolated | Tasks | Pending | +| WD-29 | P1: Long-lived server authentication | Tasks | Pending | +| WD-30 | P1: Long-lived server authentication | Tasks | Pending | +| WD-31 | P1: Long-lived server authentication | Tasks | Pending | +| WD-32 | P1: Long-lived server authentication | Tasks | Pending | +| WD-33 | P1: Review reads the requested repository | Tasks | Pending | +| WD-34 | P1: Review reads the requested repository | Tasks | Pending | +| WD-35 | P1: Review reads the requested repository | Tasks | Pending | +| WD-36 | P1: Review reads the requested repository | Tasks | Pending | +| WD-37 | P1: Review reads the requested repository | Tasks | Pending | +| WD-38 | P1: Review reads the requested repository | Tasks | Pending | +| WD-39 | P1: Review reads the requested repository | Tasks | Pending | +| WD-40 | P2: Web child restarts on build change | Tasks | Pending | +| WD-41 | P2: Web child restarts on build change | Tasks | Pending | +| WD-42 | P2: Web child restarts on build change | Tasks | Pending | +| WD-43 | P2: Web child restarts on build change | Tasks | Pending | +| WD-44 | P2: In-process fallback | Tasks | Pending | +| WD-45 | P2: In-process fallback | Tasks | Pending | +| WD-46 | P2: In-process fallback | Tasks | Pending | +| WD-47 | Edge cases | Tasks | Pending | +| WD-48 | Edge cases | Tasks | Pending | +| WD-49 | Edge cases | Tasks | Pending | +| WD-50 | Edge cases | Tasks | Pending | + +**Coverage:** 50 total, 50 mapped to tasks (see tasks.md), 0 unmapped. + +--- + +## Success Criteria + +- [ ] `codedeck review`, then `codedeck setup`, then `codedeck usage --web` all print URLs on the same port and each exits 0. +- [ ] Killing the web child leaves every session running, and the next web command serves pages again. +- [ ] After `npm run build`, the next web command prints a URL whose page comes from the new build, and `codedeck ps` shows the same sessions as before. diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md new file mode 100644 index 0000000..6c12dfd --- /dev/null +++ b/.specs/features/web-daemon/tasks.md @@ -0,0 +1,577 @@ +# Web daemon tasks + +## Execution Protocol (MANDATORY -- do not skip) + +Implement these tasks with the `tlc-spec-driven` skill: **activate it by name and follow its Execute flow and Critical Rules.** Do not search for skill files by filesystem path. The skill is the source of truth for the full flow (per-task cycle, sub-agent delegation, adequacy review, Verifier, discrimination sensor). + +**If the skill cannot be activated, STOP and tell the user - do not proceed without it.** + +--- + +**Design**: `.specs/features/web-daemon/design.md` +**Status**: Draft (revised after review) + +--- + +## Test Coverage Matrix + +> Generated from codebase, project guidelines, and spec - confirm before Execute. Guidelines found: `CLAUDE.md` (scoped vitest runs, never the full suite), `vitest.config.ts`. + +| Code Layer | Required Test Type | Coverage Expectation | Location Pattern | Run Command | +| --- | --- | --- | --- | --- | +| Web server / security (`src/web/server.ts`, `src/web/security.ts`) | integration (real loopback listener on port 0) | Every WD AC in the task, happy + error paths | `tests/web-*.test.ts` | `npx vitest run tests/<file>` | +| Web routes and page scripts (`src/web/*-routes.ts`, `src/web/*-page.ts`, review handler) | unit (handler calls, page script in `node:vm`) | 1:1 to the task's WD ACs | `tests/review.test.ts`, `tests/usage-web.test.ts`, `tests/setup-web.test.ts`, `tests/setup-page.test.ts` | `npx vitest run tests/<file>` | +| Web child and supervisor (`src/web/child.ts`, `src/daemon/web-supervisor.ts`) | unit with injected streams / fake child | All branches; 1:1 to WD ACs | `tests/web-child.test.ts`, `tests/web-supervisor.test.ts` | `npx vitest run tests/<file>` | +| Daemon IPC handlers (`src/daemon/daemon.ts`) | unit through `tests/helpers/daemon-seam.ts` | 1:1 to the task's WD ACs | `tests/daemon-web.test.ts` | `npx vitest run tests/<file>` | +| Pure helpers (`build-id`, `web-launch`) | unit with injected fakes | All branches; 1:1 to WD ACs | `tests/<module>.test.ts` | `npx vitest run tests/<file>` | +| CLI commands (`src/cli/commands/*.ts`) | unit with injected launcher / client | 1:1 to the task's WD ACs | `tests/web-cli.test.ts`, `tests/review-command.test.ts`, `tests/setup-cli-contract.test.ts`, `tests/setup-wizard.test.ts`, `tests/usage-cli.test.ts` | `npx vitest run tests/<file>` | +| Docs | none | build gate only | - | - | + +## Gate Check Commands + +> Generated from codebase - confirm before Execute. + +| Gate Level | When to Use | Command | +| --- | --- | --- | +| Quick | Every task | `npx vitest run <each test file of the task>` (one run per file) + `npx tsc --noEmit` | +| Full | Last task of each phase | Quick gate over every test file touched in the phase, one file per run | +| Build | Last task | `npm run build`, `scripts/pty-gate.sh`, then a manual smoke run against `dist/` | + +--- + +## Execution Plan + +### Phase 1: Web server core + +``` +T1 → T2 → T3 → T4 +``` + +### Phase 2: Page and route inputs + +``` +T5 → T6 → T7 +``` + +### Phase 3: Supervised web child + +``` +T8 → T9 → T10 → T11 +``` + +### Phase 4: CLI entry + +``` +T12 → T13 → T14 → T15 → T16 +``` + +--- + +## Task Breakdown + +### T1: Require credentials on API routes and page GETs + +**What**: Extend `checkWebRequest` with the `api` policy and the page-credential 403; pass `api` from `dispatchRequest`. +**Where**: `src/web/security.ts` +**Depends on**: None +**Reuses**: `hasValidActionCredentials` cookie parsing +**Requirement**: WD-29, WD-30, WD-31, WD-32 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] API GET without cookie → 403 `forbidden`; with cookie → handler runs +- [ ] Page GET with no `t` and no cookie → 403 body `open this page with codedeck ui` +- [ ] `/review?repo=%2Fx&t=<token>` → 303 to `/review?repo=%2Fx` + `Set-Cookie`; page GET with cookie → 200 +- [ ] Host and POST Origin tests still pass +- [ ] Tests that call API routes or pages bootstrap the cookie first: `tests/usage-web.test.ts`, `tests/setup-web.test.ts`, `tests/web-cli.test.ts`, `tests/review.test.ts`, `tests/review-command.test.ts`, `tests/web-server.test.ts` +- [ ] Gate check passes: `npx vitest run tests/web-security.test.ts` and each updated file, one run per file; `npx tsc --noEmit` + +**Tests**: integration +**Gate**: quick + +**Commit**: `feat(web): Require the session cookie on API routes` + +--- + +### T2: Isolate route handler failures + +**What**: Dispatch catches sync throws and rejected thenables: 500 JSON, or end the response when headers were sent. +**Where**: `src/web/server.ts` +**Depends on**: T1 +**Reuses**: `dispatchRequest` +**Requirement**: WD-24, WD-25, WD-26 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] Throwing route → 500 `{ "error": "<message>" }` +- [ ] Rejecting async route → 500 `{ "error": "<message>" }` +- [ ] Route that writes headers then throws → response ends, and the next request still answers +- [ ] Gate check passes: `npx vitest run tests/web-server.test.ts`; `npx tsc --noEmit` + +**Tests**: integration +**Gate**: quick + +**Commit**: `fix(web): Answer 500 when a route handler fails` + +--- + +### T3: Return setup route promises to the dispatcher + +**What**: Replace the `void` calls in the setup API routes with `return`. +**Where**: `src/web/setup-routes.ts` +**Depends on**: T2 +**Reuses**: T2 dispatch isolation +**Requirement**: WD-27 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] With an injected `saveConfig` that throws inside `mutationRoute`, `POST /api/setup/apply` answers 500 or the route's own error JSON, and no `unhandledRejection` fires (listener spy) +- [ ] Existing setup web tests pass +- [ ] Gate check passes: `npx vitest run tests/setup-web.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `fix(setup): Return setup route promises to the web dispatcher` + +--- + +### T4: Extract listenWebServer without signals + +**What**: Add `listenWebServer` (listen, security, close, optional ephemeral fallback on `EADDRINUSE`); `startWebServer` delegates to it. +**Where**: `src/web/server.ts` +**Depends on**: T3 +**Reuses**: listen/close code in `startWebServer` +**Requirement**: WD-04 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] `listenWebServer` leaves `process.listenerCount("SIGINT")` and `("SIGTERM")` unchanged +- [ ] Busy port + `fallbackToEphemeral: true` → listens on another port and reports it +- [ ] Busy port without fallback → rejects with the listen error +- [ ] `startWebServer({ fallbackToEphemeral: true })` on a busy port serves on another port +- [ ] Existing `startWebServer` tests pass unchanged +- [ ] Gate check passes: `npx vitest run tests/web-server.test.ts`, `npx vitest run tests/web-security.test.ts`, `npx vitest run tests/setup-web.test.ts`; `npx tsc --noEmit` + +**Tests**: integration +**Gate**: full + +**Commit**: `ref(web): Split listening from the command server lifecycle` + +--- + +### T5: Scope review to the repo query parameter + +**What**: `/api/review` requires an absolute `repo`; the in-process `review` command opens `/review?repo=<cwd>`; the page forwards `repo`, shows the no-repo message, and keys drafts by repo. +**Where**: `src/cli/commands/review.ts` and `src/web/review-page.ts` (one working slice; splitting leaves `codedeck review` broken between commits) +**Depends on**: None (previous phase complete) +**Reuses**: `createReviewHandler`, the page's `URLSearchParams(location.search)` at `src/web/review-page.ts:627` +**Requirement**: WD-33, WD-34, WD-35, WD-36, WD-37, WD-38, WD-39 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] `repo=/abs/path` → loader called with `/abs/path` +- [ ] Missing `repo` → 400 `repo query parameter is required`; relative → 400 `repo must be an absolute path`; `not a git repository` → 404 +- [ ] Page script in `node:vm` with `?repo=%2Ftmp%2Fx&ref=HEAD` fetches a URL whose `repo` is `/tmp/x` +- [ ] Page script without `repo` renders `Open this page with codedeck review inside a repository.` and calls `fetch` zero times +- [ ] Draft saved under repo `/tmp/x` uses a key starting `codedeck-review:/tmp/x:`, and a draft from another repo is not loaded +- [ ] The in-process review command's initial path is `/review?repo=<cwd>` +- [ ] Gate check passes: `npx vitest run tests/review.test.ts`, `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(review): Scope the review page to a repo query parameter` + +--- + +### T6: Accept interval on the usage page URL + +**What**: `/usage` passes the `interval` query value to the page renderer. +**Where**: `src/web/usage-routes.ts` +**Depends on**: T5 +**Reuses**: `renderUsagePage` interval normalization +**Requirement**: WD-47 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] `/usage?interval=5` renders a page configured for a 5 second poll +- [ ] `/usage?interval=0` renders the normalized 2 seconds +- [ ] Gate check passes: `npx vitest run tests/usage-web.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(usage): Read the polling interval from the page URL` + +--- + +### T7: Refresh the setup catalog from the page URL + +**What**: The setup page calls `refreshCatalog()` once after the initial load when `refresh=1`. +**Where**: `src/web/setup-page.ts` +**Depends on**: T6 +**Reuses**: `globalThis.setupPage.refreshCatalog` +**Requirement**: WD-48 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] With `refresh=1`, exactly one `POST /api/setup/catalog/refresh` after the initial state and catalog loads +- [ ] Without it, zero refresh POSTs +- [ ] Gate check passes: `npx vitest run tests/setup-page.test.ts`, `npx vitest run tests/review.test.ts`, `npx vitest run tests/usage-web.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: full + +**Commit**: `feat(setup): Refresh the catalog when the page URL asks for it` + +--- + +### T8: Compute the build identity + +**What**: Add `distRootFor` and `computeBuildId`. +**Where**: `src/daemon/build-id.ts` (new) +**Depends on**: None (previous phase complete) +**Reuses**: none +**Requirement**: WD-40 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] Newest `.js` mtime under a temp tree, nested dirs included, non-`.js` ignored; `"0"` for an empty dir +- [ ] `distRootFor("file:///x/dist/daemon/daemon.js")` → `/x/dist` +- [ ] Gate check passes: `npx vitest run tests/build-id.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(daemon): Add a build identity from the dist tree` + +--- + +### T9: Add the web child entry + +**What**: `runWebChild` listens with the UI routes, prints the handshake line, and exits on stdin EOF or SIGTERM; `--web-child` main guard. +**Where**: `src/web/child.ts` (new) +**Depends on**: T8 +**Reuses**: `listenWebServer`, `createUiRoutes`, `computeBuildId` +**Requirement**: WD-04, WD-08, WD-09, WD-40 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] With a port-0 listen, stdout gets one line `{ port, token, build }` and `/`, `/review`, `/setup`, `/usage` answer 200 after the token redirect +- [ ] No port → `fallbackToEphemeral: true`; explicit port → `false` +- [ ] Listen failure → line `{ "error": { "message", "port" } }` and exit code 1 +- [ ] Ending stdin closes the server (new connection refused) and calls exit with 0, even with a request still in flight (a route that never responds) +- [ ] `process.stdout` `EPIPE` does not throw +- [ ] Gate check passes: `npx vitest run tests/web-child.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(web): Add a web child process entry` + +--- + +### T10: Add the web supervisor + +**What**: `WebSupervisor.ensure` / `close` over an injected spawn: one child, shared in-flight start, handshake timeout, error codes, exit handling, build-change restart, log lines. +**Where**: `src/daemon/web-supervisor.ts` (new) +**Depends on**: T9 +**Reuses**: spawn pattern from `src/daemon/ipc.ts:197-211` +**Requirement**: WD-01, WD-02, WD-03, WD-05, WD-06, WD-07, WD-11, WD-28, WD-41, WD-42, WD-49, WD-50 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] First `ensure` spawns once and resolves `{ baseUrl, port, token }` from the fake handshake +- [ ] Same build again → no spawn; no build → no spawn; two concurrent calls → one spawn, same result +- [ ] Explicit port → `--port <n>` in args; error line → `WEB_LISTEN_FAILED` with `details.port` +- [ ] Exit before handshake → `WEB_START_FAILED`; no line in `startTimeoutMs` (fake timers) → child killed and `WEB_START_FAILED` +- [ ] Handshake split across two stdout chunks parses; non-JSON first line or a line missing `token` → child killed and `WEB_START_FAILED` +- [ ] After the handshake, further stdout data is consumed (the stream is flowing) +- [ ] `entry` spawned as the script; relative entry, entry not ending in `/web/child.js`, or missing entry → `WEB_BAD_ENTRY`, no spawn +- [ ] Same build with a different `entry` → old child stopped, new child spawned from the new entry +- [ ] Default spawn opens `logs/web-child.log` in append mode for stderr (checked via the injected spawn options factory or a spawn spy) +- [ ] Child exit after handshake → log `web child exited code=<code>`; next `ensure` spawns again +- [ ] Different build → SIGTERM to the old child, SIGKILL after `stopTimeoutMs` if it has not exited, then the new child's result +- [ ] Log gets `web listening port=<port>` and never the token +- [ ] Static check: `src/daemon/web-supervisor.ts` and `src/daemon/daemon.ts` import nothing from `../web/` or `../cli/` +- [ ] Gate check passes: `npx vitest run tests/web-supervisor.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(daemon): Supervise a web child process` + +--- + +### T11: Serve web.ensure from the daemon + +**What**: Add `web.ensure` to the protocol and daemon, map supervisor errors to IPC errors, and stop the child on shutdown. +**Where**: `src/daemon/daemon.ts` (plus the types in `src/daemon/protocol.ts`) +**Depends on**: T10 +**Reuses**: `WebSupervisor`, `runShutdown` +**Requirement**: WD-01, WD-05, WD-06, WD-10, WD-43 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] `web.ensure` via the seam with an injected supervisor returns its result +- [ ] Supervisor `WebEnsureError` → IPC error with the same `code`, `message`, `details` +- [ ] `handleShutdown` calls `close()` on the supervisor before marking sessions +- [ ] A seeded `working` session is unchanged after `web.ensure` calls that restart the child +- [ ] Gate check passes: `npx vitest run tests/daemon-web.test.ts`, `npx vitest run tests/web-supervisor.test.ts`, `npx vitest run tests/web-child.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: full + +**Commit**: `feat(daemon): Host the web console through web.ensure` + +--- + +### T12: Add launchWebPage + +**What**: The shared web entry: ensure daemon, `web.ensure` with the local build, URL build, open or print, port notice, listen failure, in-process fallback. +**Where**: `src/cli/web-launch.ts` (new) +**Depends on**: None (previous phase complete) +**Reuses**: `IpcClient`, `openBrowser`, `startWebServer`, `createUiRoutes`, `computeBuildId` +**Requirement**: WD-17, WD-18, WD-19, WD-20, WD-21, WD-22, WD-44, WD-45, WD-46 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] Success → opens `<baseUrl><path>?<query>&t=<token>`, prints `<title> on <url>`, returns 0; `web.ensure` params carry `build` and an absolute `entry` ending in `/web/child.js` +- [ ] `open: false` → prints the URL line, opener not called, returns 0 +- [ ] Opener false → prints `Could not open a browser, visit <url> manually.`, returns 0 +- [ ] `port` given → sent; omitted → no `port` key; result on another port → `CodeDeck web is already running on port <port>` +- [ ] `WEB_LISTEN_FAILED` → `Failed to listen on 127.0.0.1:<port>: <message>`, returns 1, no fallback +- [ ] `UNKNOWN_METHOD` and `SERVICE_UNAVAILABLE` → WD-44 line with the code, `startServer` called with the full route table, `initialPath` = path + encoded query (a repo with a space and `&` round-trips; no trailing `?` for an empty query), and `fallbackToEphemeral: true` when the port was omitted +- [ ] `ensureDaemonStarted` throws → WD-45 line and `startServer` called +- [ ] Gate check passes: `npx vitest run tests/web-launch.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(cli): Open web pages through the daemon` + +--- + +### T13: Rewire ui and review to launchWebPage + +**What**: `ui` opens `/`, `review` opens `/review?repo=<cwd>`; drop the `"3100"` commander defaults. +**Where**: `src/cli/commands/review.ts` and `src/cli/commands/ui.ts` +**Depends on**: T12 +**Reuses**: `launchWebPage`, `parseWebPort` +**Requirement**: WD-12, WD-13, WD-20, WD-23 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] `ui` → `launchWebPage` with path `/`, no port when `--port` is omitted +- [ ] `review` → path `/review`, `repo` = cwd +- [ ] Invalid `--port` → exit 1, launcher not called +- [ ] Gate check passes: `npx vitest run tests/web-cli.test.ts`, `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(cli): Open ui and review from the daemon web server` + +--- + +### T14: Rewire setup to launchWebPage + +**What**: Setup's web branch calls `launchWebPage` without a TTY requirement or a port default; `--refresh` adds `refresh=1`; delete `createSetupCommandRoutes`. +**Where**: `src/cli/commands/setup.ts` +**Depends on**: T13 +**Reuses**: `launchWebPage` +**Requirement**: WD-14, WD-15, WD-20 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] Non-TTY `setup` → launcher called with `/setup`, no port, exit 0 +- [ ] `setup --refresh` → query `{ refresh: "1" }` +- [ ] `setup --tui` without a TTY still fails with `<cli> setup needs a terminal` +- [ ] `tests/setup-wizard.test.ts:1190-1210` moved to the `--tui` case; `tests/setup-cli-contract.test.ts` refresh-injection cases rewritten to assert the launcher query +- [ ] Batch flag tests unchanged +- [ ] Gate check passes: `npx vitest run tests/setup-cli-contract.test.ts`, `npx vitest run tests/setup-wizard.test.ts`, `npx vitest run tests/web-cli.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(setup): Open the setup page from the daemon web server` + +--- + +### T15: Rewire usage --web to launchWebPage + +**What**: `usage --web` builds the resolved filter query and calls `launchWebPage` with `/usage`. +**Where**: `src/cli/commands/usage.ts` +**Depends on**: T14 +**Reuses**: `buildUsageQueryParams`, `launchWebPage` +**Requirement**: WD-16 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] `usage --web --today --repo x --by model --interval 5` → query has `period=today`, `repo=x`, `by=model`, `interval=5`, no empty keys +- [ ] `tests/usage-cli.test.ts:300-333` rewritten against the injected launcher; run-id, backfill, and `--web --tui` tests unchanged +- [ ] Gate check passes: `npx vitest run tests/usage-cli.test.ts`, `npx vitest run tests/web-cli.test.ts`, `npx vitest run tests/setup-cli-contract.test.ts`, `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: full + +**Commit**: `feat(usage): Open the usage page from the daemon web server` + +--- + +### T16: Document the web IPC method and smoke-test the build + +**What**: Describe `web.ensure`, the web child, and the fallback in the protocol doc; build and smoke-test against `dist/`. +**Where**: `docs/protocol.md` +**Depends on**: T15 +**Reuses**: existing doc tone (Portuguese) +**Requirement**: WD-08 + +**Tools**: + +- MCP: NONE +- Skill: NONE + +**Done when**: + +- [ ] Doc covers `web.ensure` params, result, errors, child restart on build change, and the fallback +- [ ] `npm run build` and `scripts/pty-gate.sh` pass +- [ ] Smoke run with an isolated `RUN_AGENT_DIR`: `review --no-open`, `setup --no-open`, `usage --web --no-open` each exit 0 and print URLs on one port; curl of each page after the token redirect answers 200. After the run, the isolated daemon is stopped +- [ ] Killing the web child, then `ui --no-open`, prints a working URL +- [ ] `touch dist/web/child.js`, then `review --no-open` again → a different token, and `ps` shows the same sessions as before + +**Tests**: none +**Gate**: build + +**Commit**: `docs(web): Document the daemon web console protocol` + +--- + +## Phase Execution Map + +``` +Phase 1 → Phase 2 → Phase 3 → Phase 4 + +Phase 1: T1 → T2 → T3 → T4 +Phase 2: T5 → T6 → T7 +Phase 3: T8 → T9 → T10 → T11 +Phase 4: T12 → T13 → T14 → T15 → T16 +``` + +## Diagram-Definition Cross-Check + +| Task | Depends On (task body) | Diagram Shows | Status | +| --- | --- | --- | --- | +| T1 | None | start | ✅ | +| T2 | T1 | T1 → T2 | ✅ | +| T3 | T2 | T2 → T3 | ✅ | +| T4 | T3 | T3 → T4 | ✅ | +| T5 | None (previous phase) | phase start | ✅ | +| T6 | T5 | T5 → T6 | ✅ | +| T7 | T6 | T6 → T7 | ✅ | +| T8 | None (previous phase) | phase start | ✅ | +| T9 | T8 | T8 → T9 | ✅ | +| T10 | T9 | T9 → T10 | ✅ | +| T11 | T10 | T10 → T11 | ✅ | +| T12 | None (previous phase) | phase start | ✅ | +| T13 | T12 | T12 → T13 | ✅ | +| T14 | T13 | T13 → T14 | ✅ | +| T15 | T14 | T14 → T15 | ✅ | +| T16 | T15 | T15 → T16 | ✅ | + +## Test Co-location Validation + +| Task | Code Layer Created/Modified | Matrix Requires | Task Says | Status | +| --- | --- | --- | --- | --- | +| T1 | Web security | integration | integration | ✅ | +| T2 | Web server | integration | integration | ✅ | +| T3 | Web routes | unit | unit | ✅ | +| T4 | Web server | integration | integration | ✅ | +| T5 | Review handler + page script | unit | unit | ✅ | +| T6 | Usage route | unit | unit | ✅ | +| T7 | Setup page script | unit | unit | ✅ | +| T8 | Pure helper | unit | unit | ✅ | +| T9 | Web child | unit | unit | ✅ | +| T10 | Supervisor | unit | unit | ✅ | +| T11 | Daemon handler | unit | unit | ✅ | +| T12 | Pure helper | unit | unit | ✅ | +| T13 | CLI commands | unit | unit | ✅ | +| T14 | CLI command | unit | unit | ✅ | +| T15 | CLI command | unit | unit | ✅ | +| T16 | Docs | none | none | ✅ | From d99df927bbb90ad24e3a2d32c7e306e9bfc8adc5 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:22:04 -0300 Subject: [PATCH 02/20] feat(web): Require the session cookie on API routes and pages The web server is about to outlive the command that starts it, so API routes now need the session cookie on every method and pages need the token or the cookie. Tests send the cookie through a shared helper. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 8 ++-- .specs/features/web-daemon/tasks.md | 13 +++--- src/web/security.ts | 27 +++++++++--- src/web/server.ts | 2 +- tests/helpers/web-session.ts | 19 +++++++++ tests/review-command.test.ts | 7 ++-- tests/setup-web.test.ts | 7 +--- tests/usage-web.test.ts | 15 +++---- tests/web-cli.test.ts | 31 +++++++------- tests/web-security.test.ts | 64 +++++++++++++++++++++++++++-- tests/web-server.test.ts | 7 ++-- 11 files changed, 147 insertions(+), 53 deletions(-) create mode 100644 tests/helpers/web-session.ts diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index d1a9b06..bd81675 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -282,10 +282,10 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-26 | P1: Web failures stay isolated | Tasks | Pending | | WD-27 | P1: Web failures stay isolated | Tasks | Pending | | WD-28 | P1: Web failures stay isolated | Tasks | Pending | -| WD-29 | P1: Long-lived server authentication | Tasks | Pending | -| WD-30 | P1: Long-lived server authentication | Tasks | Pending | -| WD-31 | P1: Long-lived server authentication | Tasks | Pending | -| WD-32 | P1: Long-lived server authentication | Tasks | Pending | +| WD-29 | P1: Long-lived server authentication | Tasks | Verified | +| WD-30 | P1: Long-lived server authentication | Tasks | Verified | +| WD-31 | P1: Long-lived server authentication | Tasks | Verified | +| WD-32 | P1: Long-lived server authentication | Tasks | Verified | | WD-33 | P1: Review reads the requested repository | Tasks | Pending | | WD-34 | P1: Review reads the requested repository | Tasks | Pending | | WD-35 | P1: Review reads the requested repository | Tasks | Pending | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index 6c12dfd..fbc27bb 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -84,15 +84,16 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] API GET without cookie → 403 `forbidden`; with cookie → handler runs -- [ ] Page GET with no `t` and no cookie → 403 body `open this page with codedeck ui` -- [ ] `/review?repo=%2Fx&t=<token>` → 303 to `/review?repo=%2Fx` + `Set-Cookie`; page GET with cookie → 200 -- [ ] Host and POST Origin tests still pass -- [ ] Tests that call API routes or pages bootstrap the cookie first: `tests/usage-web.test.ts`, `tests/setup-web.test.ts`, `tests/web-cli.test.ts`, `tests/review.test.ts`, `tests/review-command.test.ts`, `tests/web-server.test.ts` -- [ ] Gate check passes: `npx vitest run tests/web-security.test.ts` and each updated file, one run per file; `npx tsc --noEmit` +- [x] API GET without cookie → 403 `forbidden`; with cookie → handler runs +- [x] Page GET with no `t` and no cookie → 403 body `open this page with codedeck ui` +- [x] `/review?repo=%2Fx&t=<token>` → 303 to `/review?repo=%2Fx` + `Set-Cookie`; page GET with cookie → 200 +- [x] Host and POST Origin tests still pass +- [x] Tests that call API routes or pages bootstrap the cookie first: `tests/usage-web.test.ts`, `tests/setup-web.test.ts`, `tests/web-cli.test.ts`, `tests/review.test.ts`, `tests/review-command.test.ts`, `tests/web-server.test.ts` +- [x] Gate check passes: `npx vitest run tests/web-security.test.ts` and each updated file, one run per file; `npx tsc --noEmit` **Tests**: integration **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(web): Require the session cookie on API routes` diff --git a/src/web/security.ts b/src/web/security.ts index fc8529f..e075764 100644 --- a/src/web/security.ts +++ b/src/web/security.ts @@ -9,9 +9,11 @@ export interface WebSecurity { export interface WebRoutePolicy { htmlPage?: boolean; + api?: boolean; } export const WEB_FORBIDDEN_MESSAGE = "forbidden"; +export const WEB_PAGE_FORBIDDEN_MESSAGE = "open this page with codedeck ui"; export function createWebSecurity(port: number): WebSecurity { const token = randomBytes(32).toString("hex"); @@ -50,20 +52,35 @@ export function checkWebRequest( return false; } + if (policy.api && !hasSessionCookie(request, security)) { + reject(response); + return false; + } + if (policy.htmlPage) { response.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); - if (request.method === "GET" && redirectWithSessionCookie(request, response, security)) return false; + if (request.method === "GET") { + if (redirectWithSessionCookie(request, response, security)) return false; + if (!hasSessionCookie(request, security)) { + reject(response, WEB_PAGE_FORBIDDEN_MESSAGE); + return false; + } + } } return true; } -function hasValidActionCredentials(request: IncomingMessage, security: WebSecurity): boolean { +function hasSessionCookie(request: IncomingMessage, security: WebSecurity): boolean { const cookie = request.headers.cookie ?.split(";") .map((part) => part.trim()) .find((part) => part.startsWith(`${security.cookieName}=`)); - if (cookie?.slice(security.cookieName.length + 1) !== security.token) return false; + return cookie?.slice(security.cookieName.length + 1) === security.token; +} + +function hasValidActionCredentials(request: IncomingMessage, security: WebSecurity): boolean { + if (!hasSessionCookie(request, security)) return false; const origin = request.headers.origin; const host = request.headers.host; @@ -106,7 +123,7 @@ function redirectWithSessionCookie( return true; } -function reject(response: ServerResponse): void { +function reject(response: ServerResponse, message = WEB_FORBIDDEN_MESSAGE): void { response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); - response.end(WEB_FORBIDDEN_MESSAGE); + response.end(message); } diff --git a/src/web/server.ts b/src/web/server.ts index 93878b4..42145ed 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -185,7 +185,7 @@ function dispatchRequest( } const route = routes.find((candidate) => candidate.path === pathname); - if (!checkWebRequest(request, response, security, { htmlPage: route?.kind === "page" })) return; + if (!checkWebRequest(request, response, security, { htmlPage: route?.kind === "page", api: route?.kind === "api" })) return; if (!route) { response.writeHead(404, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify({ error: "not found" })); diff --git a/tests/helpers/web-session.ts b/tests/helpers/web-session.ts new file mode 100644 index 0000000..54ae6d2 --- /dev/null +++ b/tests/helpers/web-session.ts @@ -0,0 +1,19 @@ +// Web pages and API routes need the session cookie that the token URL +// bootstraps. Tests skip the redirect and send the cookie directly. +export interface WebSessionHandle { + port: number; + security: { token: string }; +} + +export function sessionCookie(handle: WebSessionHandle): string { + return `codedeck_ui_token_${handle.port}=${handle.security.token}`; +} + +export function sessionFetch(handle: WebSessionHandle | undefined) { + if (!handle) throw new Error("web server was not started"); + return (url: string, init: RequestInit = {}) => + fetch(url, { + ...init, + headers: { cookie: sessionCookie(handle), ...(init.headers as Record<string, string> | undefined) }, + }); +} diff --git a/tests/review-command.test.ts b/tests/review-command.test.ts index d15014c..5bbe59d 100644 --- a/tests/review-command.test.ts +++ b/tests/review-command.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { sessionFetch } from "./helpers/web-session.js"; import { parseReviewPort, registerReviewCommand } from "../src/cli/commands/review.js"; import { startWebServer, type WebServerHandle } from "../src/web/server.js"; import { EventEmitter } from "node:events"; @@ -52,14 +53,14 @@ describe("registerReviewCommand", () => { expect(started?.initialUrl).toContain("?t="); expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); - const root = await fetch(`${started?.baseUrl}/`); - const alias = await fetch(`${started?.baseUrl}/review`); + const root = await sessionFetch(started)(`${started?.baseUrl}/`); + const alias = await sessionFetch(started)(`${started?.baseUrl}/review`); expect(root.status).toBe(200); expect(alias.status).toBe(200); expect(await root.text()).toContain("Review local"); expect(await alias.text()).toContain("Review local"); - const api = await fetch(`${started?.baseUrl}/api/review?file=src/web/server.ts`); + const api = await sessionFetch(started)(`${started?.baseUrl}/api/review?file=src/web/server.ts`); expect(api.status).toBe(200); expect(await api.json()).toEqual({ ref: "HEAD", file: "src/web/server.ts" }); diff --git a/tests/setup-web.test.ts b/tests/setup-web.test.ts index dcbc477..e2c9e9b 100644 --- a/tests/setup-web.test.ts +++ b/tests/setup-web.test.ts @@ -71,11 +71,8 @@ function request( return new Promise((resolve, reject) => { const method = options.method ?? "GET"; const host = options.host ?? `127.0.0.1:${handle.port}`; - const headers: Record<string, string> = { host }; - if (options.auth) { - headers.origin = `http://${host}`; - headers.cookie = `codedeck_ui_token_${handle.port}=${handle.security.token}`; - } + const headers: Record<string, string> = { host, cookie: `codedeck_ui_token_${handle.port}=${handle.security.token}` }; + if (options.auth) headers.origin = `http://${host}`; if (options.body !== undefined) headers["content-type"] = "application/json"; const req = http.request({ hostname: "127.0.0.1", diff --git a/tests/usage-web.test.ts b/tests/usage-web.test.ts index feb1f7d..aeb0f96 100644 --- a/tests/usage-web.test.ts +++ b/tests/usage-web.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { sessionFetch } from "./helpers/web-session.js"; import { buildUsageQueryParams } from "../src/core/usage-query.js"; import type { UsageQueryParams, UsageQueryResult } from "../src/daemon/protocol.js"; import { createUsageRoutes } from "../src/web/usage-routes.js"; @@ -54,7 +55,7 @@ describe("usage web routes", () => { it("serves the self-contained usage page", async () => { const handle = await startUsageServer(vi.fn(async () => usageResult)); - const response = await fetch(`${handle.baseUrl}/usage`); + const response = await sessionFetch(handle)(`${handle.baseUrl}/usage`); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain("text/html"); @@ -68,7 +69,7 @@ describe("usage web routes", () => { port: 0, initialPath: "/usage", open: false, log: vi.fn(), signalTarget: new EventEmitter(), exit: vi.fn(), }); handles.push(handle); - const response = await fetch(`${handle.baseUrl}/usage?period=7d&repo=work&model=m1&agent=codex&since=2026-09-01&until=2026-09-22&by=repo`); + const response = await sessionFetch(handle)(`${handle.baseUrl}/usage?period=7d&repo=work&model=m1&agent=codex&since=2026-09-01&until=2026-09-22&by=repo`); const html = await response.text(); expect(html).toContain('"period":"7d"'); expect(html).toContain('"repo":"work"'); @@ -94,7 +95,7 @@ describe("usage web routes", () => { const fetchUsageQuery = vi.fn(async () => usageResult); const handle = await startUsageServer(fetchUsageQuery); - const response = await fetch(`${handle.baseUrl}/api/usage${query}`); + const response = await sessionFetch(handle)(`${handle.baseUrl}/api/usage${query}`); expect(response.status).toBe(200); expect(fetchUsageQuery).toHaveBeenCalledWith({ @@ -114,7 +115,7 @@ describe("usage web routes", () => { const since = "2026-09-01T00:00:00.000Z"; const until = "2026-09-20T23:59:59.999Z"; - const response = await fetch( + const response = await sessionFetch(handle)( `${handle.baseUrl}/api/usage?repo=%2Fselected%2Frepo¤t=true&model=gpt-5.6-luna&agent=codex&since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}`, ); @@ -145,7 +146,7 @@ describe("usage web routes", () => { }; const expected = buildUsageQueryParams(cliOptions, cwd, now); - const response = await fetch( + const response = await sessionFetch(handle)( `${handle.baseUrl}/api/usage?days=5&since=${encodeURIComponent(cliOptions.since)}&until=${encodeURIComponent(cliOptions.until)}&repo=%2Fselected%2Frepo¤t=true&model=gpt-5.6-luna&agent=codex`, ); @@ -157,7 +158,7 @@ describe("usage web routes", () => { const fetchUsageQuery = vi.fn(async () => usageResult); const handle = await startUsageServer(fetchUsageQuery); - const response = await fetch(`${handle.baseUrl}/api/usage?period=all`); + const response = await sessionFetch(handle)(`${handle.baseUrl}/api/usage?period=all`); expect(await response.json()).toEqual(usageResult); }); @@ -168,7 +169,7 @@ describe("usage web routes", () => { }); const handle = await startUsageServer(fetchUsageQuery); - const response = await fetch(`${handle.baseUrl}/api/usage`); + const response = await sessionFetch(handle)(`${handle.baseUrl}/api/usage`); expect(response.status).toBe(500); expect(response.headers.get("content-type")).toContain("application/json"); diff --git a/tests/web-cli.test.ts b/tests/web-cli.test.ts index 3b57090..cd1661c 100644 --- a/tests/web-cli.test.ts +++ b/tests/web-cli.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { sessionFetch } from "./helpers/web-session.js"; import { Command } from "commander"; import { createCliProgram } from "../src/cli/index.js"; import { registerUiCommand } from "../src/cli/commands/ui.js"; @@ -105,7 +106,7 @@ describe("ui CLI command", () => { expect(started).toBeDefined(); expect(started?.initialUrl).toContain("?t="); expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); - const rootResponse = await fetch(`${started?.baseUrl}/`); + const rootResponse = await sessionFetch(started)(`${started?.baseUrl}/`); const rootHtml = await rootResponse.text(); expect(rootResponse.status).toBe(200); expect(rootHtml).not.toContain('href="/"'); @@ -113,14 +114,14 @@ describe("ui CLI command", () => { expect(rootHtml).toContain('href="/setup"'); expect(rootHtml).toContain('href="/usage"'); - const reviewResponse = await fetch(`${started?.baseUrl}/review`); + const reviewResponse = await sessionFetch(started)(`${started?.baseUrl}/review`); expect(reviewResponse.status).toBe(200); expect(reviewResponse.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); expect(await reviewResponse.text()).toContain("Review local"); - const setupResponse = await fetch(`${started?.baseUrl}/setup`); + const setupResponse = await sessionFetch(started)(`${started?.baseUrl}/setup`); const setupHtml = await setupResponse.text(); - const usageResponse = await fetch(`${started?.baseUrl}/usage`); + const usageResponse = await sessionFetch(started)(`${started?.baseUrl}/usage`); const setupNav = setupHtml.split('<nav aria-label="Main navigation">')[1]?.split("</nav>")[0] ?? ""; expect(setupResponse.status).toBe(200); expect(setupNav).toContain('href="/">Home</a>'); @@ -178,12 +179,12 @@ describe("ui setup and usage routes", () => { await program.parseAsync(["node", "codedeck", "ui", "--no-open"], { from: "node" }); const baseUrl = started!.baseUrl; - const home = await fetch(`${baseUrl}/`); + const home = await sessionFetch(started)(`${baseUrl}/`); const homeHtml = await home.text(); expect(homeHtml).toContain('href="/setup"'); expect(homeHtml).toContain('href="/usage"'); - expect((await fetch(`${baseUrl}/api/setup/state`)).status).toBe(200); - expect((await fetch(`${baseUrl}/api/setup/catalog`)).status).toBe(200); + expect((await sessionFetch(started)(`${baseUrl}/api/setup/state`)).status).toBe(200); + expect((await sessionFetch(started)(`${baseUrl}/api/setup/catalog`)).status).toBe(200); const headers = { cookie: `codedeck_ui_token_${started!.port}=${started!.security.token}`, @@ -191,13 +192,13 @@ describe("ui setup and usage routes", () => { "content-type": "application/json", }; const emptySelection = JSON.stringify({ agents: {} }); - const refresh = await fetch(`${baseUrl}/api/setup/catalog/refresh`, { method: "POST", headers }); - const dryRun = await fetch(`${baseUrl}/api/setup/dry-run`, { + const refresh = await sessionFetch(started)(`${baseUrl}/api/setup/catalog/refresh`, { method: "POST", headers }); + const dryRun = await sessionFetch(started)(`${baseUrl}/api/setup/dry-run`, { method: "POST", headers, body: emptySelection, }); - const apply = await fetch(`${baseUrl}/api/setup/apply`, { + const apply = await sessionFetch(started)(`${baseUrl}/api/setup/apply`, { method: "POST", headers, body: emptySelection, @@ -206,7 +207,7 @@ describe("ui setup and usage routes", () => { expect(dryRun.status).toBe(200); expect(apply.status).toBe(200); - const usage = await fetch(`${baseUrl}/api/usage`); + const usage = await sessionFetch(started)(`${baseUrl}/api/usage`); expect(usage.status).toBe(200); expect(await usage.json()).toEqual(emptyUsage); expect(fetchUsageQuery).toHaveBeenCalledOnce(); @@ -235,7 +236,7 @@ describe("setup and usage web commands", () => { expect(requested).toMatchObject({ initialPath: "/setup", port: 32123, open: false }); expect(started?.initialUrl).toContain("?t="); expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); - expect((await fetch(`${started?.baseUrl}/setup`)).status).toBe(200); + expect((await sessionFetch(started)(`${started?.baseUrl}/setup`)).status).toBe(200); }); it("prints the token URL and keeps serving when the browser opener fails", async () => { @@ -259,7 +260,7 @@ describe("setup and usage web commands", () => { await program.parseAsync(["node", "codedeck", "ui"], { from: "node" }); expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); - expect((await fetch(`${started?.baseUrl}/`)).status).toBe(200); + expect((await sessionFetch(started)(`${started?.baseUrl}/`)).status).toBe(200); }); it("opens aggregate usage with the selected filters, breakdown, interval, and token URL", async () => { @@ -286,7 +287,7 @@ describe("setup and usage web commands", () => { expect(requested).toMatchObject({ initialPath: "/usage", port: 32124, open: false }); expect(started?.initialUrl).toContain("?t="); expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); - const page = await (await fetch(`${started?.baseUrl}/usage`)).text(); + const page = await (await sessionFetch(started)(`${started?.baseUrl}/usage`)).text(); expect(page).toContain(JSON.stringify({ by: "origin", interval: "0", @@ -300,7 +301,7 @@ describe("setup and usage web commands", () => { }, })); - const query = await fetch(`${started?.baseUrl}/api/usage?since=2026-09-01&until=2026-09-20&repo=${encodeURIComponent(cwd)}&model=gpt-5&agent=codex`); + const query = await sessionFetch(started)(`${started?.baseUrl}/api/usage?since=2026-09-01&until=2026-09-20&repo=${encodeURIComponent(cwd)}&model=gpt-5&agent=codex`); expect(query.status).toBe(200); expect(fetchUsageQuery).toHaveBeenCalledWith({ period: undefined, diff --git a/tests/web-security.test.ts b/tests/web-security.test.ts index 0602f8b..3749eda 100644 --- a/tests/web-security.test.ts +++ b/tests/web-security.test.ts @@ -85,10 +85,11 @@ describe("web request security", () => { const { handle } = await makeServer(); expect(handle.security.token).toMatch(/^[0-9a-f]{64}$/); - const accepted = await request(handle, { path: "/page", host: `LOCALHOST:${handle.port}` }); + const cookie = `codedeck_ui_token_${handle.port}=${handle.security.token}`; + const accepted = await request(handle, { path: "/page", host: `LOCALHOST:${handle.port}`, cookie }); expect(accepted.status).toBe(200); - const acceptedIp = await request(handle, { path: "/page", host: `127.0.0.1:${handle.port}` }); + const acceptedIp = await request(handle, { path: "/page", host: `127.0.0.1:${handle.port}`, cookie }); expect(acceptedIp.status).toBe(200); }); @@ -118,15 +119,70 @@ describe("web request security", () => { ]); }); - it("does not set a cookie for HTML GETs without the current token and adds the framing policy", async () => { + it("keeps the remaining query when the token redirect drops t", async () => { const { handle } = await makeServer(); + const response = await request(handle, { + path: `/page?repo=%2Fx&t=${handle.security.token}`, + host: `127.0.0.1:${handle.port}`, + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/page?repo=%2Fx"); + }); + + it("rejects HTML GETs without the current token or cookie, sets no cookie, and adds the framing policy", async () => { + const { handle, calls } = await makeServer(); + for (const path of ["/page", "/page?t=stale-token"]) { const response = await request(handle, { path, host: `127.0.0.1:${handle.port}` }); - expect(response.status).toBe(200); + expect(response.status).toBe(403); + expect(response.body).toBe("open this page with codedeck ui"); expect(response.headers["set-cookie"]).toBeUndefined(); expect(response.headers["content-security-policy"]).toBe("frame-ancestors 'none'"); } + const stale = await request(handle, { + path: "/page", + host: `127.0.0.1:${handle.port}`, + cookie: `codedeck_ui_token_${handle.port}=stale`, + }); + expect(stale.status).toBe(403); + expect(stale.body).toBe("open this page with codedeck ui"); + expect(calls).toEqual([]); + }); + + it("serves an HTML GET that carries the current cookie", async () => { + const { handle, calls } = await makeServer(); + + const response = await request(handle, { + path: "/page", + host: `127.0.0.1:${handle.port}`, + cookie: `codedeck_ui_token_${handle.port}=${handle.security.token}`, + }); + + expect(response.status).toBe(200); + expect(response.body).toBe("page"); + expect(calls).toEqual(["page"]); + }); + + it("rejects API GETs without the current cookie before dispatch", async () => { + const { handle, calls } = await makeServer(); + const host = `127.0.0.1:${handle.port}`; + + for (const cookie of [undefined, `codedeck_ui_token_${handle.port}=stale`]) { + const response = await request(handle, { path: "/action", host, cookie }); + expect(response.status).toBe(403); + expect(response.body).toBe("forbidden"); + } + expect(calls).toEqual([]); + + const accepted = await request(handle, { + path: "/action", + host, + cookie: `codedeck_ui_token_${handle.port}=${handle.security.token}`, + }); + expect(accepted.status).toBe(200); + expect(calls).toEqual(["action"]); }); it("rejects POSTs without the current cookie and same-origin HTTP Origin before dispatch", async () => { diff --git a/tests/web-server.test.ts b/tests/web-server.test.ts index 3822643..01b42b2 100644 --- a/tests/web-server.test.ts +++ b/tests/web-server.test.ts @@ -1,6 +1,7 @@ import http from "node:http"; import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { sessionFetch } from "./helpers/web-session.js"; import { DEFAULT_WEB_PORT, parseWebPort, @@ -72,17 +73,17 @@ describe("startWebServer", () => { expect(handle.baseUrl).toBe(`http://127.0.0.1:${handle.address.port}`); expect(handle.initialUrl).toBe(`${handle.baseUrl}/?t=${handle.security.token}`); - const home = await fetch(`${handle.baseUrl}/`); + const home = await sessionFetch(handle)(`${handle.baseUrl}/`); expect(home.status).toBe(200); expect(home.headers.get("content-type")).toContain("text/html"); expect(home.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); expect(await home.text()).toBe("home page"); - const api = await fetch(`${handle.baseUrl}/api/test`); + const api = await sessionFetch(handle)(`${handle.baseUrl}/api/test`); expect(api.status).toBe(200); expect(await api.json()).toEqual({ ok: true }); - const missing = await fetch(`${handle.baseUrl}/missing`); + const missing = await sessionFetch(handle)(`${handle.baseUrl}/missing`); expect(missing.status).toBe(404); expect(seen).toEqual(["/", "/api/test"]); }); From ad2e681ec7d7d4f7cd4a702768786dc471e9a876 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:22:36 -0300 Subject: [PATCH 03/20] fix(web): Answer 500 when a route handler fails A throwing or rejecting route used to escape the dispatcher. Now it gets a JSON 500, or an ended response when headers were already sent, and the server keeps serving. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 6 +-- .specs/features/web-daemon/tasks.md | 9 +++-- src/web/server.ts | 20 +++++++++- tests/web-server.test.ts | 62 +++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index bd81675..f3569b5 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -277,9 +277,9 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-21 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-22 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-23 | P1: Web commands delegate to the daemon | Tasks | Pending | -| WD-24 | P1: Web failures stay isolated | Tasks | Pending | -| WD-25 | P1: Web failures stay isolated | Tasks | Pending | -| WD-26 | P1: Web failures stay isolated | Tasks | Pending | +| WD-24 | P1: Web failures stay isolated | Tasks | Verified | +| WD-25 | P1: Web failures stay isolated | Tasks | Verified | +| WD-26 | P1: Web failures stay isolated | Tasks | Verified | | WD-27 | P1: Web failures stay isolated | Tasks | Pending | | WD-28 | P1: Web failures stay isolated | Tasks | Pending | | WD-29 | P1: Long-lived server authentication | Tasks | Verified | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index fbc27bb..d182e4f 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -114,13 +114,14 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] Throwing route → 500 `{ "error": "<message>" }` -- [ ] Rejecting async route → 500 `{ "error": "<message>" }` -- [ ] Route that writes headers then throws → response ends, and the next request still answers -- [ ] Gate check passes: `npx vitest run tests/web-server.test.ts`; `npx tsc --noEmit` +- [x] Throwing route → 500 `{ "error": "<message>" }` +- [x] Rejecting async route → 500 `{ "error": "<message>" }` +- [x] Route that writes headers then throws → response ends, and the next request still answers +- [x] Gate check passes: `npx vitest run tests/web-server.test.ts`; `npx tsc --noEmit` **Tests**: integration **Gate**: quick +**Status**: ✅ Done **Commit**: `fix(web): Answer 500 when a route handler fails` diff --git a/src/web/server.ts b/src/web/server.ts index 42145ed..609fb6a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -192,5 +192,23 @@ function dispatchRequest( return; } - route.handler(request, response); + try { + const result: unknown = route.handler(request, response); + if (isThenable(result)) result.then(undefined, (error: unknown) => failRequest(response, error)); + } catch (error) { + failRequest(response, error); + } +} + +function isThenable(value: unknown): value is PromiseLike<unknown> { + return typeof (value as PromiseLike<unknown> | undefined)?.then === "function"; +} + +function failRequest(response: http.ServerResponse, error: unknown): void { + if (response.headersSent) { + response.end(); + return; + } + response.writeHead(500, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); } diff --git a/tests/web-server.test.ts b/tests/web-server.test.ts index 01b42b2..56b7e1c 100644 --- a/tests/web-server.test.ts +++ b/tests/web-server.test.ts @@ -152,3 +152,65 @@ describe("startWebServer", () => { expect(calls).toEqual(["close", "exit:0"]); }); }); + +describe("route failure isolation", () => { + async function startFailingServer(): Promise<WebServerHandle> { + const routes: WebRoute[] = [ + { path: "/api/throw", kind: "api", handler: () => { throw new Error("sync boom"); } }, + { path: "/api/reject", kind: "api", handler: async () => { throw new Error("async boom"); } }, + { + path: "/api/late", + kind: "api", + handler: async (_req, res) => { + res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); + res.write("partial"); + throw new Error("late boom"); + }, + }, + { + path: "/api/ok", + kind: "api", + handler: (_req, res) => { + res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: true })); + }, + }, + ]; + const handle = await startWebServer({ + routes, + port: 0, + initialPath: "/", + open: false, + log: vi.fn(), + signalTarget: new EventEmitter(), + exit: vi.fn(), + }); + handles.push(handle); + return handle; + } + + it("answers 500 with the message when a handler throws synchronously", async () => { + const handle = await startFailingServer(); + const response = await sessionFetch(handle)(`${handle.baseUrl}/api/throw`); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "sync boom" }); + }); + + it("answers 500 with the message when a handler rejects", async () => { + const handle = await startFailingServer(); + const response = await sessionFetch(handle)(`${handle.baseUrl}/api/reject`); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "async boom" }); + }); + + it("ends a response whose headers were sent and keeps serving", async () => { + const handle = await startFailingServer(); + const late = await sessionFetch(handle)(`${handle.baseUrl}/api/late`); + expect(late.status).toBe(200); + expect(await late.text()).toBe("partial"); + + const next = await sessionFetch(handle)(`${handle.baseUrl}/api/ok`); + expect(next.status).toBe(200); + expect(await next.json()).toEqual({ ok: true }); + }); +}); From 145cc0ca7b57fe4dfb7c6ccfed53b0d6a5f08eaf Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:24:44 -0300 Subject: [PATCH 04/20] fix(setup): Return setup route promises to the web dispatcher The setup API routes discarded their promises with `void`, so a rejection outside the route's own try/catch became an unhandled rejection and left the request hanging. Returning the promise lets the dispatcher answer 500 instead. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 2 +- .specs/features/web-daemon/tasks.md | 7 ++++--- src/web/setup-routes.ts | 8 ++++---- tests/setup-web.test.ts | 20 ++++++++++++++++++++ 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index f3569b5..b6939e7 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -280,7 +280,7 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-24 | P1: Web failures stay isolated | Tasks | Verified | | WD-25 | P1: Web failures stay isolated | Tasks | Verified | | WD-26 | P1: Web failures stay isolated | Tasks | Verified | -| WD-27 | P1: Web failures stay isolated | Tasks | Pending | +| WD-27 | P1: Web failures stay isolated | Tasks | Verified | | WD-28 | P1: Web failures stay isolated | Tasks | Pending | | WD-29 | P1: Long-lived server authentication | Tasks | Verified | | WD-30 | P1: Long-lived server authentication | Tasks | Verified | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index d182e4f..64a9b96 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -142,12 +142,13 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] With an injected `saveConfig` that throws inside `mutationRoute`, `POST /api/setup/apply` answers 500 or the route's own error JSON, and no `unhandledRejection` fires (listener spy) -- [ ] Existing setup web tests pass -- [ ] Gate check passes: `npx vitest run tests/setup-web.test.ts`; `npx tsc --noEmit` +- [x] With injected `readConfig` and `configPath` that both throw (so `mutationRoute` rejects outside its own try/catch; a throwing `saveConfig` is already caught there), `POST /api/setup/apply` answers 500 `{ error: "path boom" }` and no `unhandledRejection` fires (listener spy) +- [x] Existing setup web tests pass +- [x] Gate check passes: `npx vitest run tests/setup-web.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `fix(setup): Return setup route promises to the web dispatcher` diff --git a/src/web/setup-routes.ts b/src/web/setup-routes.ts index 4262054..80f6d1c 100644 --- a/src/web/setup-routes.ts +++ b/src/web/setup-routes.ts @@ -564,16 +564,16 @@ export function createSetupRoutes(dependencies: SetupRoutesDependencies = {}): W if (method(request, response, "GET")) stateRoute(request, response); }), route("/api/setup/catalog", "api", (request, response) => { - if (method(request, response, "GET")) void catalogRoute(request, response); + if (method(request, response, "GET")) return catalogRoute(request, response); }), route("/api/setup/catalog/refresh", "api", (request, response) => { - if (method(request, response, "POST")) void refreshRoute(request, response); + if (method(request, response, "POST")) return refreshRoute(request, response); }), route("/api/setup/dry-run", "api", (request, response) => { - if (method(request, response, "POST")) void mutationRoute(request, response, true); + if (method(request, response, "POST")) return mutationRoute(request, response, true); }), route("/api/setup/apply", "api", (request, response) => { - if (method(request, response, "POST")) void mutationRoute(request, response, false); + if (method(request, response, "POST")) return mutationRoute(request, response, false); }), ]; } diff --git a/tests/setup-web.test.ts b/tests/setup-web.test.ts index e2c9e9b..3aa345e 100644 --- a/tests/setup-web.test.ts +++ b/tests/setup-web.test.ts @@ -266,6 +266,26 @@ describe("setup catalog routes", () => { }); describe("setup dry-run and apply routes", () => { + it("answers 500 when the mutation route rejects instead of leaving the promise unhandled", async () => { + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + try { + const handle = await makeServer({ + readConfig: () => { throw new Error("read boom"); }, + configPath: () => { throw new Error("path boom"); }, + }); + + const response = await post(handle, "/api/setup/apply", emptySelection); + + expect(response.status).toBe(500); + expect(json(response)).toEqual({ error: "path boom" }); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off("unhandledRejection", unhandled); + } + }); + it("returns the exact dry-run envelope and never writes config", async () => { const config: RunAgentConfig = { agents: { general: { harness: "claude", model: "sonnet" } } }; const saveConfig = vi.fn(() => true); From 32eca072761901c4bc45cfbf591f55f3819c41d3 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:25:29 -0300 Subject: [PATCH 05/20] ref(web): Split listening from the command server lifecycle Extract listenWebServer, which binds, creates the session security and closes, with no signal handlers and no browser. The daemon's web child needs this. startWebServer delegates to it and passes an optional fallbackToEphemeral through for the in-process CLI fallback. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 2 +- .specs/features/web-daemon/tasks.md | 13 ++--- src/web/server.ts | 77 ++++++++++++++++++++++------- tests/web-server.test.ts | 70 ++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 24 deletions(-) diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index b6939e7..fa59d12 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -257,7 +257,7 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-01 | P1: Daemon supervises one web server | Tasks | Pending | | WD-02 | P1: Daemon supervises one web server | Tasks | Pending | | WD-03 | P1: Daemon supervises one web server | Tasks | Pending | -| WD-04 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-04 | P1: Daemon supervises one web server | Tasks | Verified | | WD-05 | P1: Daemon supervises one web server | Tasks | Pending | | WD-06 | P1: Daemon supervises one web server | Tasks | Pending | | WD-07 | P1: Daemon supervises one web server | Tasks | Pending | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index 64a9b96..b9e1062 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -169,15 +169,16 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] `listenWebServer` leaves `process.listenerCount("SIGINT")` and `("SIGTERM")` unchanged -- [ ] Busy port + `fallbackToEphemeral: true` → listens on another port and reports it -- [ ] Busy port without fallback → rejects with the listen error -- [ ] `startWebServer({ fallbackToEphemeral: true })` on a busy port serves on another port -- [ ] Existing `startWebServer` tests pass unchanged -- [ ] Gate check passes: `npx vitest run tests/web-server.test.ts`, `npx vitest run tests/web-security.test.ts`, `npx vitest run tests/setup-web.test.ts`; `npx tsc --noEmit` +- [x] `listenWebServer` leaves `process.listenerCount("SIGINT")` and `("SIGTERM")` unchanged +- [x] Busy port + `fallbackToEphemeral: true` → listens on another port and reports it +- [x] Busy port without fallback → rejects with the listen error +- [x] `startWebServer({ fallbackToEphemeral: true })` on a busy port serves on another port +- [x] Existing `startWebServer` tests pass unchanged +- [x] Gate check passes: `npx vitest run tests/web-server.test.ts`, `npx vitest run tests/web-security.test.ts`, `npx vitest run tests/setup-web.test.ts`; `npx tsc --noEmit` **Tests**: integration **Gate**: full +**Status**: ✅ Done **Commit**: `ref(web): Split listening from the command server lifecycle` diff --git a/src/web/server.ts b/src/web/server.ts index 609fb6a..4239fbf 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -23,6 +23,7 @@ export interface WebServerOptions { openBrowser?: (url: string) => boolean | Promise<boolean>; log?: (message: string) => void; serverFactory?: (handler: RequestListener) => Server; + fallbackToEphemeral?: boolean; signalTarget?: EventEmitter; closeServer?: () => Promise<void> | void; exit?: (code: number) => void; @@ -34,6 +35,22 @@ export interface CreateWebServerOptions { serverFactory?: (handler: RequestListener) => Server; } +export interface ListenWebServerOptions { + routes: readonly WebRoute[]; + port?: number; + fallbackToEphemeral?: boolean; + serverFactory?: (handler: RequestListener) => Server; +} + +export interface ListeningWebServer { + server: Server; + address: AddressInfo; + port: number; + baseUrl: string; + security: WebSecurity; + close(): Promise<void>; +} + export interface WebServerHandle { server: Server; address: AddressInfo; @@ -89,8 +106,7 @@ export function createWebServer(options: CreateWebServerOptions): Server { }); } -export async function startWebServer(options: WebServerOptions): Promise<WebServerHandle> { - const requestedPort = options.port ?? DEFAULT_WEB_PORT; +export async function listenWebServer(options: ListenWebServerOptions): Promise<ListeningWebServer> { let security: WebSecurity | undefined; const server = createWebServer({ routes: options.routes, @@ -98,7 +114,37 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ serverFactory: options.serverFactory, }); - await new Promise<void>((resolve, reject) => { + const requestedPort = options.port ?? DEFAULT_WEB_PORT; + try { + await listen(server, requestedPort); + } catch (error) { + if (!options.fallbackToEphemeral || (error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error; + await listen(server, 0); + } + + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise<void>((resolve) => server.close(() => resolve())); + throw new Error("Web server did not return a TCP address"); + } + + security = createWebSecurity(address.port); + let closing: Promise<void> | undefined; + return { + server, + address, + port: address.port, + baseUrl: `http://127.0.0.1:${address.port}`, + security, + close: () => { + closing ??= new Promise<void>((resolve) => server.close(() => resolve())); + return closing; + }, + }; +} + +function listen(server: Server, port: number): Promise<void> { + return new Promise<void>((resolve, reject) => { const onError = (error: Error) => { server.off("listening", onListening); reject(error); @@ -109,17 +155,18 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ }; server.once("error", onError); server.once("listening", onListening); - server.listen(requestedPort, "127.0.0.1"); + server.listen(port, "127.0.0.1"); }); +} - const address = server.address(); - if (!address || typeof address === "string") { - await new Promise<void>((resolve) => server.close(() => resolve())); - throw new Error("Web server did not return a TCP address"); - } - - security = createWebSecurity(address.port); - const baseUrl = `http://127.0.0.1:${address.port}`; +export async function startWebServer(options: WebServerOptions): Promise<WebServerHandle> { + const listening = await listenWebServer({ + routes: options.routes, + port: options.port, + fallbackToEphemeral: options.fallbackToEphemeral, + serverFactory: options.serverFactory, + }); + const { server, address, security, baseUrl } = listening; const pageUrl = new URL(options.initialPath, baseUrl).toString(); const initialUrl = getTokenUrl(pageUrl, security.token); const log = options.log ?? ((message: string) => console.log(message)); @@ -135,15 +182,11 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ const signalTarget = options.signalTarget ?? process; const exit = options.exit ?? ((code: number) => process.exit(code)); let signalClose: Promise<void> | undefined; - let actualClose: Promise<void> | undefined; let shutdown = () => {}; const close = (): Promise<void> => { signalTarget.off("SIGINT", shutdown); signalTarget.off("SIGTERM", shutdown); - if (!actualClose) { - actualClose = new Promise<void>((resolve) => server.close(() => resolve())); - } - return actualClose; + return listening.close(); }; shutdown = () => { signalTarget.off("SIGINT", shutdown); diff --git a/tests/web-server.test.ts b/tests/web-server.test.ts index 56b7e1c..d04f982 100644 --- a/tests/web-server.test.ts +++ b/tests/web-server.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { sessionFetch } from "./helpers/web-session.js"; import { DEFAULT_WEB_PORT, + listenWebServer, parseWebPort, startWebServer, type WebRoute, @@ -214,3 +215,72 @@ describe("route failure isolation", () => { expect(await next.json()).toEqual({ ok: true }); }); }); + +describe("listenWebServer", () => { + const listeners: { close(): Promise<void> }[] = []; + const blockers: http.Server[] = []; + + afterEach(async () => { + await Promise.all(listeners.splice(0).map((listener) => listener.close())); + await Promise.all(blockers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve())))); + }); + + async function busyPort(): Promise<number> { + const blocker = http.createServer(); + blockers.push(blocker); + await new Promise<void>((resolve) => blocker.listen(0, "127.0.0.1", resolve)); + return (blocker.address() as { port: number }).port; + } + + it("listens and serves without installing signal handlers", async () => { + const sigint = process.listenerCount("SIGINT"); + const sigterm = process.listenerCount("SIGTERM"); + + const listening = await listenWebServer({ routes: testRoutes([]), port: 0 }); + listeners.push(listening); + + expect(process.listenerCount("SIGINT")).toBe(sigint); + expect(process.listenerCount("SIGTERM")).toBe(sigterm); + expect(listening.baseUrl).toBe(`http://127.0.0.1:${listening.port}`); + expect(listening.security.port).toBe(listening.port); + const response = await fetch(`${listening.baseUrl}/api/test`, { + headers: { cookie: `codedeck_ui_token_${listening.port}=${listening.security.token}` }, + }); + expect(response.status).toBe(200); + }); + + it("falls back to another port when the requested one is busy and fallback is on", async () => { + const port = await busyPort(); + + const listening = await listenWebServer({ routes: testRoutes([]), port, fallbackToEphemeral: true }); + listeners.push(listening); + + expect(listening.port).not.toBe(port); + expect(listening.port).toBeGreaterThan(0); + }); + + it("rejects with the listen error when the port is busy and fallback is off", async () => { + const port = await busyPort(); + + await expect(listenWebServer({ routes: testRoutes([]), port })).rejects.toMatchObject({ code: "EADDRINUSE" }); + }); + + it("passes the fallback through startWebServer", async () => { + const port = await busyPort(); + + const handle = await startWebServer({ + routes: testRoutes([]), + port, + fallbackToEphemeral: true, + initialPath: "/", + open: false, + log: vi.fn(), + signalTarget: new EventEmitter(), + exit: vi.fn(), + }); + handles.push(handle); + + expect(handle.port).not.toBe(port); + expect((await sessionFetch(handle)(`${handle.baseUrl}/`)).status).toBe(200); + }); +}); From 3483260e4a5b694c593140f6b3bacb2eaddf4ff1 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:26:51 -0300 Subject: [PATCH 06/20] feat(review): Scope the review page to a repo query parameter A shared web server cannot use its own cwd as the review root, so /api/review now takes an absolute repo parameter and rejects a missing or relative one. The page forwards repo on every request, shows a hint without one, and keys drafts by repo so two repos never mix comments. The in-process review command opens /review?repo=<cwd>. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 14 ++--- .specs/features/web-daemon/tasks.md | 15 ++--- src/cli/commands/review.ts | 15 +++-- src/web/review-page.ts | 17 ++++-- tests/review-command.test.ts | 8 ++- tests/review.test.ts | 93 ++++++++++++++++++++++++++++- 6 files changed, 134 insertions(+), 28 deletions(-) diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index fa59d12..a1c795b 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -286,13 +286,13 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-30 | P1: Long-lived server authentication | Tasks | Verified | | WD-31 | P1: Long-lived server authentication | Tasks | Verified | | WD-32 | P1: Long-lived server authentication | Tasks | Verified | -| WD-33 | P1: Review reads the requested repository | Tasks | Pending | -| WD-34 | P1: Review reads the requested repository | Tasks | Pending | -| WD-35 | P1: Review reads the requested repository | Tasks | Pending | -| WD-36 | P1: Review reads the requested repository | Tasks | Pending | -| WD-37 | P1: Review reads the requested repository | Tasks | Pending | -| WD-38 | P1: Review reads the requested repository | Tasks | Pending | -| WD-39 | P1: Review reads the requested repository | Tasks | Pending | +| WD-33 | P1: Review reads the requested repository | Tasks | Verified | +| WD-34 | P1: Review reads the requested repository | Tasks | Verified | +| WD-35 | P1: Review reads the requested repository | Tasks | Verified | +| WD-36 | P1: Review reads the requested repository | Tasks | Verified | +| WD-37 | P1: Review reads the requested repository | Tasks | Verified | +| WD-38 | P1: Review reads the requested repository | Tasks | Verified | +| WD-39 | P1: Review reads the requested repository | Tasks | Verified | | WD-40 | P2: Web child restarts on build change | Tasks | Pending | | WD-41 | P2: Web child restarts on build change | Tasks | Pending | | WD-42 | P2: Web child restarts on build change | Tasks | Pending | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index b9e1062..2bfad3b 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -199,16 +199,17 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] `repo=/abs/path` → loader called with `/abs/path` -- [ ] Missing `repo` → 400 `repo query parameter is required`; relative → 400 `repo must be an absolute path`; `not a git repository` → 404 -- [ ] Page script in `node:vm` with `?repo=%2Ftmp%2Fx&ref=HEAD` fetches a URL whose `repo` is `/tmp/x` -- [ ] Page script without `repo` renders `Open this page with codedeck review inside a repository.` and calls `fetch` zero times -- [ ] Draft saved under repo `/tmp/x` uses a key starting `codedeck-review:/tmp/x:`, and a draft from another repo is not loaded -- [ ] The in-process review command's initial path is `/review?repo=<cwd>` -- [ ] Gate check passes: `npx vitest run tests/review.test.ts`, `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` +- [x] `repo=/abs/path` → loader called with `/abs/path` +- [x] Missing `repo` → 400 `repo query parameter is required`; relative → 400 `repo must be an absolute path`; `not a git repository` → 404 +- [x] Page script in `node:vm` with `?repo=%2Ftmp%2Fx&ref=HEAD` fetches a URL whose `repo` is `/tmp/x` +- [x] Page script without `repo` renders `Open this page with codedeck review inside a repository.` and calls `fetch` zero times +- [x] Draft saved under repo `/tmp/x` uses a key starting `codedeck-review:/tmp/x:`, and a draft from another repo is not loaded +- [x] The in-process review command's initial path is `/review?repo=<cwd>` +- [x] Gate check passes: `npx vitest run tests/review.test.ts`, `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(review): Scope the review page to a repo query parameter` diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index 1414bc2..617457b 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -1,4 +1,5 @@ import http from "node:http"; +import path from "node:path"; import type { Command } from "commander"; import { REVIEW_PAGE } from "../../web/review-page.js"; import { getLocalReview } from "../../git/review.js"; @@ -13,8 +14,6 @@ export function parseReviewPort(raw: string | undefined): number { export { openBrowser }; export interface ReviewDeps { - /** Repo root for /api/review. Defaults to process.cwd() at request time. */ - root?: string; loadReview?: (root: string, ref: string, file?: string) => Promise<unknown>; } @@ -41,7 +40,15 @@ export function createReviewHandler(reviewDeps?: ReviewDeps): http.RequestListen if (req.method === "GET" && url.pathname === "/api/review") { const ref = url.searchParams.get("ref") || "HEAD"; const file = url.searchParams.get("file") || undefined; - const root = reviewDeps?.root ?? process.cwd(); + const root = url.searchParams.get("repo"); + if (!root) { + sendJson(res, 400, { error: "repo query parameter is required" }); + return; + } + if (!path.isAbsolute(root)) { + sendJson(res, 400, { error: "repo must be an absolute path" }); + return; + } try { const load = reviewDeps?.loadReview ?? ((r: string, q: string, f?: string) => getLocalReview(r, { ref: q, file: f })); sendJson(res, 200, await load(root, ref, file)); @@ -101,7 +108,7 @@ export function registerReviewCommand(program: Command, dependencies: ReviewComm await (dependencies.startServer ?? startWebServer)({ routes: createReviewRoutes(dependencies), port, - initialPath: "/", + initialPath: `/review?${new URLSearchParams({ repo: process.cwd() })}`, title: "CodeDeck review", open: opts.open, }); diff --git a/src/web/review-page.ts b/src/web/review-page.ts index 2a2e539..9a2109b 100644 --- a/src/web/review-page.ts +++ b/src/web/review-page.ts @@ -206,8 +206,10 @@ function mergeWords(oldCode, newCode, lang) { } return out; } -/* Drafts: key codedeck-review:<file>:<line>[-<endLine>] -> { code, body, deleted } */ -function draftKey(file, line, endLine) { return "codedeck-review:" + file + ":" + line + (endLine && endLine !== line ? "-" + endLine : ""); } +/* Drafts: key codedeck-review:<repo>:<file>:<line>[-<endLine>] -> { code, body, deleted } */ +var REPO = new URLSearchParams(location.search).get("repo") || ""; +var DRAFT_PREFIX = "codedeck-review:" + REPO + ":"; +function draftKey(file, line, endLine) { return DRAFT_PREFIX + file + ":" + line + (endLine && endLine !== line ? "-" + endLine : ""); } function loadDraft(file, line, endLine) { try { var raw = localStorage.getItem(draftKey(file, line, endLine)); @@ -225,10 +227,10 @@ function allDrafts() { try { for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); - if (!k || k.indexOf("codedeck-review:") !== 0) continue; + if (!k || k.indexOf(DRAFT_PREFIX) !== 0) continue; var v = JSON.parse(localStorage.getItem(k)); if (v && v.body) { - var rest = k.slice("codedeck-review:".length); + var rest = k.slice(DRAFT_PREFIX.length); var li = rest.lastIndexOf(":"); var spec = rest.slice(li + 1).split("-"); var d = { file: rest.slice(0, li), line: Number(spec[0]), code: v.code, body: v.body, deleted: !!v.deleted }; @@ -626,7 +628,12 @@ function render(data) { function load() { var params = new URLSearchParams(location.search); var ref = params.get("ref") || "HEAD"; - fetch("api/review?ref=" + encodeURIComponent(ref)) + if (!REPO) { + meta.textContent = ""; + main.innerHTML = '<div id="empty">Open this page with codedeck review inside a repository.</div>'; + return; + } + fetch("api/review?ref=" + encodeURIComponent(ref) + "&repo=" + encodeURIComponent(REPO)) .then(function (res) { if (!res.ok) throw new Error("review indisponível (HTTP " + res.status + ")"); return res.json(); diff --git a/tests/review-command.test.ts b/tests/review-command.test.ts index 5bbe59d..699a85a 100644 --- a/tests/review-command.test.ts +++ b/tests/review-command.test.ts @@ -51,7 +51,10 @@ describe("registerReviewCommand", () => { await program.parseAsync(["node", "codedeck", "review", "--no-open"], { from: "node" }); - expect(started?.initialUrl).toContain("?t="); + const initial = new URL(started?.initialUrl ?? ""); + expect(initial.searchParams.get("t")).toBe(started?.security.token); + expect(initial.pathname).toBe("/review"); + expect(initial.searchParams.get("repo")).toBe(process.cwd()); expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); const root = await sessionFetch(started)(`${started?.baseUrl}/`); const alias = await sessionFetch(started)(`${started?.baseUrl}/review`); @@ -60,7 +63,8 @@ describe("registerReviewCommand", () => { expect(await root.text()).toContain("Review local"); expect(await alias.text()).toContain("Review local"); - const api = await sessionFetch(started)(`${started?.baseUrl}/api/review?file=src/web/server.ts`); + const repo = encodeURIComponent(process.cwd()); + const api = await sessionFetch(started)(`${started?.baseUrl}/api/review?file=src/web/server.ts&repo=${repo}`); expect(api.status).toBe(200); expect(await api.json()).toEqual({ ref: "HEAD", file: "src/web/server.ts" }); diff --git a/tests/review.test.ts b/tests/review.test.ts index 976f722..20de0b0 100644 --- a/tests/review.test.ts +++ b/tests/review.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execSync } from "node:child_process"; +import vm from "node:vm"; import { describe, expect, it, afterEach } from "vitest"; import { formatReviewComment, @@ -216,14 +217,13 @@ describe("review HTTP routes", () => { const seen: Array<{ root: string; ref: string; file?: string }> = []; const base = await listen( createReviewHandler({ - root: "/repo", loadReview: async (root, ref, file) => { seen.push({ root, ref, file }); return { root, ref, files: [] }; }, }), ); - const res = await fetch(`${base}/api/review?ref=HEAD&file=a.txt`); + const res = await fetch(`${base}/api/review?ref=HEAD&file=a.txt&repo=%2Frepo`); expect(res.status).toBe(200); expect(await res.json()).toEqual({ root: "/repo", ref: "HEAD", files: [] }); expect(seen).toEqual([{ root: "/repo", ref: "HEAD", file: "a.txt" }]); @@ -237,7 +237,94 @@ describe("review HTTP routes", () => { }, }), ); - const res = await fetch(`${base}/api/review`); + const res = await fetch(`${base}/api/review?repo=%2Ftmp%2Fx`); expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "not a git repository: /tmp/x" }); + }); + + it("rejects a request without a repo parameter", async () => { + const base = await listen(createReviewHandler({ loadReview: async () => ({}) })); + const res = await fetch(`${base}/api/review?ref=HEAD`); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "repo query parameter is required" }); + }); + + it("rejects a relative repo path", async () => { + const base = await listen(createReviewHandler({ loadReview: async () => ({}) })); + const res = await fetch(`${base}/api/review?repo=relative%2Frepo`); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "repo must be an absolute path" }); + }); +}); + +describe("review page script", () => { + function fakeElement() { + return { + innerHTML: "", + textContent: "", + style: {}, + children: [], + addEventListener: () => {}, + appendChild: () => {}, + querySelectorAll: () => [], + }; + } + + function runPage(search: string, stored: Record<string, string> = {}) { + const elements = new Map<string, ReturnType<typeof fakeElement>>(); + const fetched: string[] = []; + const store = new Map(Object.entries(stored)); + const localStorage = { + get length() { return store.size; }, + key: (i: number) => [...store.keys()][i] ?? null, + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { store.set(k, v); }, + removeItem: (k: string) => { store.delete(k); }, + }; + const context = vm.createContext({ + location: { search }, + URLSearchParams, + localStorage, + setTimeout, + fetch: (url: string) => { fetched.push(url); return new Promise(() => {}); }, + document: { + getElementById: (id: string) => { + if (!elements.has(id)) elements.set(id, fakeElement()); + return elements.get(id); + }, + addEventListener: () => {}, + querySelectorAll: () => [], + }, + }); + const script = /<script>([\s\S]*)<\/script>/.exec(REVIEW_PAGE)?.[1]; + if (!script) throw new Error("review page has no script"); + vm.runInContext(script, context); + return { context, elements, fetched, store }; + } + + it("sends the repo from the page query on the review request", () => { + const page = runPage("?repo=%2Ftmp%2Fx&ref=HEAD"); + expect(page.fetched).toHaveLength(1); + const url = new URL(page.fetched[0], "http://127.0.0.1/"); + expect(url.pathname).toBe("/api/review"); + expect(url.searchParams.get("repo")).toBe("/tmp/x"); + expect(url.searchParams.get("ref")).toBe("HEAD"); + }); + + it("shows the no-repo message and fetches nothing without a repo", () => { + const page = runPage("?ref=HEAD"); + expect(page.fetched).toEqual([]); + expect(page.elements.get("main")?.innerHTML).toContain("Open this page with codedeck review inside a repository."); + }); + + it("keys drafts by repo and ignores drafts from another repo", () => { + const draft = JSON.stringify({ code: "two", body: "other repo note" }); + const page = runPage("?repo=%2Ftmp%2Fx", { "codedeck-review:/tmp/other:a.txt:2": draft }); + + vm.runInContext('saveDraft("a.txt", 3, { code: "three", body: "note" })', page.context); + + expect([...page.store.keys()]).toContain("codedeck-review:/tmp/x:a.txt:3"); + const drafts = vm.runInContext("allDrafts()", page.context) as Array<{ file: string; line: number; body: string }>; + expect(drafts.map((d) => ({ file: d.file, line: d.line, body: d.body }))).toEqual([{ file: "a.txt", line: 3, body: "note" }]); }); }); From d22473ea71a9609570a8097cc300df4824ac70b2 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:27:29 -0300 Subject: [PATCH 07/20] feat(usage): Read the polling interval from the page URL `usage --web --interval` will open a shared server by URL, so the /usage page reads interval from its query instead of from process-local page options. The page keeps its existing normalization. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 2 +- .specs/features/web-daemon/tasks.md | 7 ++++--- src/web/usage-routes.ts | 1 + tests/usage-web.test.ts | 15 +++++++++++++++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index a1c795b..9c2900b 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -300,7 +300,7 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-44 | P2: In-process fallback | Tasks | Pending | | WD-45 | P2: In-process fallback | Tasks | Pending | | WD-46 | P2: In-process fallback | Tasks | Pending | -| WD-47 | Edge cases | Tasks | Pending | +| WD-47 | Edge cases | Tasks | Verified | | WD-48 | Edge cases | Tasks | Pending | | WD-49 | Edge cases | Tasks | Pending | | WD-50 | Edge cases | Tasks | Pending | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index 2bfad3b..6af9161 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -230,12 +230,13 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] `/usage?interval=5` renders a page configured for a 5 second poll -- [ ] `/usage?interval=0` renders the normalized 2 seconds -- [ ] Gate check passes: `npx vitest run tests/usage-web.test.ts`; `npx tsc --noEmit` +- [x] `/usage?interval=5` renders a page configured for a 5 second poll +- [x] `/usage?interval=0` renders the normalized 2 seconds +- [x] Gate check passes: `npx vitest run tests/usage-web.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(usage): Read the polling interval from the page URL` diff --git a/src/web/usage-routes.ts b/src/web/usage-routes.ts index 3908413..e8fecfa 100644 --- a/src/web/usage-routes.ts +++ b/src/web/usage-routes.ts @@ -58,6 +58,7 @@ export function createUsageRoutes(options: UsageRoutesOptions): WebRoute[] { until: search.get("until") ?? options.page?.filters?.until, }, by: (search.get("by") as UsagePageOptions["by"]) ?? options.page?.by, + interval: search.get("interval") ?? options.page?.interval, })); }, }, diff --git a/tests/usage-web.test.ts b/tests/usage-web.test.ts index aeb0f96..d5220d9 100644 --- a/tests/usage-web.test.ts +++ b/tests/usage-web.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { sessionFetch } from "./helpers/web-session.js"; import { buildUsageQueryParams } from "../src/core/usage-query.js"; import type { UsageQueryParams, UsageQueryResult } from "../src/daemon/protocol.js"; +import { normalizeUsageInterval } from "../src/web/usage-page.js"; import { createUsageRoutes } from "../src/web/usage-routes.js"; import { startWebServer, type WebServerHandle } from "../src/web/server.js"; @@ -81,6 +82,20 @@ describe("usage web routes", () => { expect(html).toContain('aria-current="page" class="active">Usage</a>'); }); + it.each([ + ["5", 5], + ["0", 2], + ])("configures the page poll from interval=%s", async (interval, seconds) => { + const handle = await startUsageServer(vi.fn(async () => usageResult)); + + const response = await sessionFetch(handle)(`${handle.baseUrl}/usage?interval=${interval}`); + const html = await response.text(); + const options = /startUsagePage\((\{.*?\}),usageEnvironment\)/.exec(html)?.[1]; + if (!options) throw new Error("usage page options are missing"); + + expect(normalizeUsageInterval((JSON.parse(options) as { interval: unknown }).interval)).toBe(seconds); + }); + it.each([ ["default today", "", { period: "today" }], ["all period", "?period=all", { period: "all" }], From 9efe91565a889ec042e7a94908eef99bd29f7f46 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:28:08 -0300 Subject: [PATCH 08/20] feat(setup): Refresh the catalog when the page URL asks for it `setup --refresh` will open a shared server by URL, so the page itself sends one catalog refresh after its initial load when the query has refresh=1, instead of the command refreshing before it serves. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 2 +- .specs/features/web-daemon/tasks.md | 7 ++++--- src/web/setup-page.ts | 5 ++++- tests/setup-page.test.ts | 27 ++++++++++++++++++++++++--- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index 9c2900b..109b928 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -301,7 +301,7 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-45 | P2: In-process fallback | Tasks | Pending | | WD-46 | P2: In-process fallback | Tasks | Pending | | WD-47 | Edge cases | Tasks | Verified | -| WD-48 | Edge cases | Tasks | Pending | +| WD-48 | Edge cases | Tasks | Verified | | WD-49 | Edge cases | Tasks | Pending | | WD-50 | Edge cases | Tasks | Pending | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index 6af9161..4585d16 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -257,12 +257,13 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] With `refresh=1`, exactly one `POST /api/setup/catalog/refresh` after the initial state and catalog loads -- [ ] Without it, zero refresh POSTs -- [ ] Gate check passes: `npx vitest run tests/setup-page.test.ts`, `npx vitest run tests/review.test.ts`, `npx vitest run tests/usage-web.test.ts`; `npx tsc --noEmit` +- [x] With `refresh=1`, exactly one `POST /api/setup/catalog/refresh` after the initial state and catalog loads +- [x] Without it, zero refresh POSTs +- [x] Gate check passes: `npx vitest run tests/setup-page.test.ts`, `npx vitest run tests/review.test.ts`, `npx vitest run tests/usage-web.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: full +**Status**: ✅ Done **Commit**: `feat(setup): Refresh the catalog when the page URL asks for it` diff --git a/src/web/setup-page.ts b/src/web/setup-page.ts index ff1d5de..962edf4 100644 --- a/src/web/setup-page.ts +++ b/src/web/setup-page.ts @@ -796,7 +796,10 @@ export function renderSetupPage(options: SetupPageOptions = {}): string { document, confirm: (message) => typeof globalThis.confirm === "function" ? globalThis.confirm(message) : false, }); - globalThis.setupPageReady = globalThis.setupPage.start(); + globalThis.setupPageReady = globalThis.setupPage.start().then(async (state) => { + if (new URLSearchParams(location.search).get("refresh") === "1") await globalThis.setupPage.refreshCatalog(); + return state; + }); </script> </body> </html>`; diff --git a/tests/setup-page.test.ts b/tests/setup-page.test.ts index 9110d68..aefb03c 100644 --- a/tests/setup-page.test.ts +++ b/tests/setup-page.test.ts @@ -586,9 +586,7 @@ describe("setup page selection", () => { }); describe("setup page inline behavior", () => { - it("runs the injected functions in a clean VM with only browser adapters stubbed", async () => { - const script = SETUP_PAGE.match(/<script>([\s\S]*?)<\/script>/)?.[1]; - expect(script).toBeDefined(); + function pageContext(search: string) { const { document } = fakeDocument(); const calls: string[] = []; const context = { @@ -600,8 +598,31 @@ describe("setup page inline behavior", () => { }, setTimeout: () => 1, clearTimeout: () => undefined, + location: { search }, + URLSearchParams, document, }; + return { calls, context }; + } + + it.each([ + ["with refresh=1", "?refresh=1", ["/api/setup/state", "/api/setup/catalog", "POST /api/setup/catalog/refresh"]], + ["without refresh", "", ["/api/setup/state", "/api/setup/catalog"]], + ])("refreshes the catalog once after the initial load only %s", async (_label, search, expected) => { + const script = SETUP_PAGE.match(/<script>([\s\S]*?)<\/script>/)?.[1]; + const { calls, context } = pageContext(search); + + runInNewContext(script!, context); + await (context as typeof context & { setupPageReady: Promise<unknown> }).setupPageReady; + await new Promise((resolve) => setImmediate(resolve)); + + expect(calls).toEqual(expected); + }); + + it("runs the injected functions in a clean VM with only browser adapters stubbed", async () => { + const script = SETUP_PAGE.match(/<script>([\s\S]*?)<\/script>/)?.[1]; + expect(script).toBeDefined(); + const { calls, context } = pageContext(""); runInNewContext(script!, context); await (context as typeof context & { setupPageReady: Promise<unknown> }).setupPageReady; From 0ebe6353f8b65ebc2e9ab9da061beae492f0a515 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:28:28 -0300 Subject: [PATCH 09/20] feat(daemon): Add a build identity from the dist tree The daemon needs to know when its web child runs code older than the CLI calling it. The build id is the newest .js mtime under the dist root, so any rebuild changes it. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/tasks.md | 7 +++-- src/daemon/build-id.ts | 28 ++++++++++++++++++ tests/build-id.test.ts | 45 +++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 src/daemon/build-id.ts create mode 100644 tests/build-id.test.ts diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index 4585d16..42ea957 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -284,12 +284,13 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] Newest `.js` mtime under a temp tree, nested dirs included, non-`.js` ignored; `"0"` for an empty dir -- [ ] `distRootFor("file:///x/dist/daemon/daemon.js")` → `/x/dist` -- [ ] Gate check passes: `npx vitest run tests/build-id.test.ts`; `npx tsc --noEmit` +- [x] Newest `.js` mtime under a temp tree, nested dirs included, non-`.js` ignored; `"0"` for an empty dir +- [x] `distRootFor("file:///x/dist/daemon/daemon.js")` → `/x/dist` +- [x] Gate check passes: `npx vitest run tests/build-id.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(daemon): Add a build identity from the dist tree` diff --git a/src/daemon/build-id.ts b/src/daemon/build-id.ts new file mode 100644 index 0000000..49b4a79 --- /dev/null +++ b/src/daemon/build-id.ts @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** The dist root of a compiled module: two directories above `dist/<dir>/<file>.js`. */ +export function distRootFor(moduleUrl: string): string { + return path.dirname(path.dirname(fileURLToPath(moduleUrl))); +} + +/** Newest `.js` mtime under `root`, as a decimal string; `"0"` when there is none. */ +export function computeBuildId(root: string): string { + let newest = 0; + const walk = (dir: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.isFile() && entry.name.endsWith(".js")) newest = Math.max(newest, fs.statSync(full).mtimeMs); + } + }; + walk(root); + return String(newest); +} diff --git a/tests/build-id.test.ts b/tests/build-id.test.ts new file mode 100644 index 0000000..668a622 --- /dev/null +++ b/tests/build-id.test.ts @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { computeBuildId, distRootFor } from "../src/daemon/build-id.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-build-id-")); + dirs.push(dir); + return dir; +} + +function writeAt(file: string, seconds: number): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, ""); + fs.utimesSync(file, seconds, seconds); +} + +describe("computeBuildId", () => { + it("returns the newest .js mtime, including nested directories and ignoring other files", () => { + const root = tempDir(); + writeAt(path.join(root, "cli", "index.js"), 1_000); + writeAt(path.join(root, "web", "deep", "child.js"), 3_000); + writeAt(path.join(root, "plugin", "newer.md"), 9_000); + + expect(computeBuildId(root)).toBe(String(fs.statSync(path.join(root, "web", "deep", "child.js")).mtimeMs)); + expect(computeBuildId(root)).toBe("3000000"); + }); + + it("returns 0 for a tree without .js files", () => { + expect(computeBuildId(tempDir())).toBe("0"); + }); +}); + +describe("distRootFor", () => { + it("returns the directory two levels above the module file", () => { + expect(distRootFor("file:///x/dist/daemon/daemon.js")).toBe("/x/dist"); + }); +}); From 600f07aca89035e17aaea40bbd05401a68dd286c Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:29:46 -0300 Subject: [PATCH 10/20] feat(web): Add a web child process entry The daemon will host the console in a supervised child instead of in its own event loop. The child serves the full console route table, prints one JSON handshake line with its port, token and build, and exits on stdin EOF or SIGTERM without waiting for in-flight requests. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 6 +- .specs/features/web-daemon/tasks.md | 13 +-- src/web/child.ts | 64 +++++++++++++ tests/web-child.test.ts | 142 ++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 src/web/child.ts create mode 100644 tests/web-child.test.ts diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index 109b928..41a59a1 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -261,8 +261,8 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-05 | P1: Daemon supervises one web server | Tasks | Pending | | WD-06 | P1: Daemon supervises one web server | Tasks | Pending | | WD-07 | P1: Daemon supervises one web server | Tasks | Pending | -| WD-08 | P1: Daemon supervises one web server | Tasks | Pending | -| WD-09 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-08 | P1: Daemon supervises one web server | Tasks | Verified | +| WD-09 | P1: Daemon supervises one web server | Tasks | Verified | | WD-10 | P1: Daemon supervises one web server | Tasks | Pending | | WD-11 | P1: Daemon supervises one web server | Tasks | Pending | | WD-12 | P1: Web commands delegate to the daemon | Tasks | Pending | @@ -293,7 +293,7 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-37 | P1: Review reads the requested repository | Tasks | Verified | | WD-38 | P1: Review reads the requested repository | Tasks | Verified | | WD-39 | P1: Review reads the requested repository | Tasks | Verified | -| WD-40 | P2: Web child restarts on build change | Tasks | Pending | +| WD-40 | P2: Web child restarts on build change | Tasks | Verified | | WD-41 | P2: Web child restarts on build change | Tasks | Pending | | WD-42 | P2: Web child restarts on build change | Tasks | Pending | | WD-43 | P2: Web child restarts on build change | Tasks | Pending | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index 42ea957..f1540bc 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -311,15 +311,16 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] With a port-0 listen, stdout gets one line `{ port, token, build }` and `/`, `/review`, `/setup`, `/usage` answer 200 after the token redirect -- [ ] No port → `fallbackToEphemeral: true`; explicit port → `false` -- [ ] Listen failure → line `{ "error": { "message", "port" } }` and exit code 1 -- [ ] Ending stdin closes the server (new connection refused) and calls exit with 0, even with a request still in flight (a route that never responds) -- [ ] `process.stdout` `EPIPE` does not throw -- [ ] Gate check passes: `npx vitest run tests/web-child.test.ts`; `npx tsc --noEmit` +- [x] With a port-0 listen, stdout gets one line `{ port, token, build }` and `/`, `/review`, `/setup`, `/usage` answer 200 after the token redirect +- [x] No port → `fallbackToEphemeral: true`; explicit port → `false` +- [x] Listen failure → line `{ "error": { "message", "port" } }` and exit code 1 +- [x] Ending stdin closes the server (new connection refused) and calls exit with 0, even with a request still in flight (a route that never responds) +- [x] `process.stdout` `EPIPE` does not throw +- [x] Gate check passes: `npx vitest run tests/web-child.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(web): Add a web child process entry` diff --git a/src/web/child.ts b/src/web/child.ts new file mode 100644 index 0000000..36eb6db --- /dev/null +++ b/src/web/child.ts @@ -0,0 +1,64 @@ +import type { EventEmitter } from "node:events"; +import type { Readable, Writable } from "node:stream"; +import { createUiRoutes } from "../cli/commands/ui.js"; +import { computeBuildId, distRootFor } from "../daemon/build-id.js"; +import { DEFAULT_WEB_PORT, listenWebServer, type WebRoute } from "./server.js"; + +export interface RunWebChildOptions { + /** Explicit port; when omitted the child tries the default port and falls back to an ephemeral one. */ + port?: number; + stdin: Readable; + stdout: Writable; + listen?: typeof listenWebServer; + routes?: () => WebRoute[]; + build?: string; + exit?: (code: number) => void; + signalTarget?: EventEmitter; +} + +/** + * Serve the console routes for the daemon. The first stdout line is the + * handshake: `{ port, token, build }` or `{ error: { message, port } }`. + * The child exits when its stdin ends (the daemon went away) or on SIGTERM. + */ +export async function runWebChild(options: RunWebChildOptions): Promise<void> { + const exit = options.exit ?? ((code: number) => process.exit(code)); + const build = options.build ?? computeBuildId(distRootFor(import.meta.url)); + // The daemon may close the pipe after the handshake; a failed write must not crash the child. + options.stdout.on("error", () => {}); + + let listening: Awaited<ReturnType<typeof listenWebServer>>; + try { + listening = await (options.listen ?? listenWebServer)({ + routes: (options.routes ?? (() => createUiRoutes()))(), + port: options.port, + fallbackToEphemeral: options.port === undefined, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const line = JSON.stringify({ error: { message, port: options.port ?? DEFAULT_WEB_PORT } }); + options.stdout.write(`${line}\n`, () => exit(1)); + return; + } + + let stopped = false; + const stop = (): void => { + if (stopped) return; + stopped = true; + void listening.close(); + listening.server.closeAllConnections(); + exit(0); + }; + options.stdin.once("end", stop); + options.stdin.once("close", stop); + options.stdin.resume(); + (options.signalTarget ?? process).once("SIGTERM", stop); + + options.stdout.write(`${JSON.stringify({ port: listening.port, token: listening.security.token, build })}\n`); +} + +if (process.argv.includes("--web-child")) { + const portIndex = process.argv.indexOf("--port"); + const port = portIndex >= 0 ? Number(process.argv[portIndex + 1]) : undefined; + void runWebChild({ port, stdin: process.stdin, stdout: process.stdout }); +} diff --git a/tests/web-child.test.ts b/tests/web-child.test.ts new file mode 100644 index 0000000..7c7c1a7 --- /dev/null +++ b/tests/web-child.test.ts @@ -0,0 +1,142 @@ +import http from "node:http"; +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runWebChild, type RunWebChildOptions } from "../src/web/child.js"; +import type { ListeningWebServer, WebRoute } from "../src/web/server.js"; + +interface Handshake { port: number; token: string; build: string } + +const cleanups: (() => void)[] = []; + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +function firstLine(stream: PassThrough): Promise<string> { + return new Promise((resolve) => { + let buffer = ""; + stream.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + const end = buffer.indexOf("\n"); + if (end >= 0) resolve(buffer.slice(0, end)); + }); + }); +} + +async function startChild(overrides: Partial<RunWebChildOptions> = {}) { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const signalTarget = new EventEmitter(); + const exit = vi.fn(); + const line = firstLine(stdout); + await runWebChild({ port: 0, stdin, stdout, exit, signalTarget, build: "build-1", ...overrides }); + const handshake = JSON.parse(await line) as Record<string, unknown>; + cleanups.push(() => stdin.end()); + return { stdin, stdout, signalTarget, exit, handshake }; +} + +function cookieFor(handshake: Handshake): string { + return `codedeck_ui_token_${handshake.port}=${handshake.token}`; +} + +function fakeListening(): ListeningWebServer { + const server = http.createServer(); + return { + server, + address: { address: "127.0.0.1", family: "IPv4", port: 3100 }, + port: 3100, + baseUrl: "http://127.0.0.1:3100", + security: { token: "fake-token", port: 3100, cookieName: "codedeck_ui_token_3100" } as ListeningWebServer["security"], + close: async () => {}, + }; +} + +describe("runWebChild", () => { + it("prints one handshake line and serves the console pages after the token redirect", async () => { + const { handshake } = await startChild(); + const { port, token, build } = handshake as unknown as Handshake; + + expect(Object.keys(handshake).sort()).toEqual(["build", "port", "token"]); + expect(port).toBeGreaterThan(0); + expect(typeof token).toBe("string"); + expect(build).toBe("build-1"); + + const redirect = await fetch(`http://127.0.0.1:${port}/?t=${token}`, { redirect: "manual" }); + expect(redirect.status).toBe(303); + expect(redirect.headers.get("set-cookie")).toContain(cookieFor(handshake as unknown as Handshake)); + + for (const path of ["/", "/review", "/setup", "/usage"]) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + headers: { cookie: cookieFor(handshake as unknown as Handshake) }, + }); + expect(response.status, path).toBe(200); + } + }); + + it("asks for the ephemeral fallback only when no port was given", async () => { + const listen = vi.fn(async () => fakeListening()); + + await startChild({ port: undefined, listen, routes: () => [] }); + await startChild({ port: 4567, listen, routes: () => [] }); + + expect(listen.mock.calls.map(([options]) => [options.port, options.fallbackToEphemeral])).toEqual([ + [undefined, true], + [4567, false], + ]); + }); + + it("prints an error handshake and exits 1 when listening fails", async () => { + const listen = vi.fn(async () => { throw new Error("listen EADDRINUSE: address already in use 127.0.0.1:4567"); }); + const stdout = new PassThrough(); + const exit = vi.fn(); + const line = firstLine(stdout); + + await runWebChild({ port: 4567, stdin: new PassThrough(), stdout, exit, listen, routes: () => [], build: "b" }); + + expect(JSON.parse(await line)).toEqual({ + error: { message: "listen EADDRINUSE: address already in use 127.0.0.1:4567", port: 4567 }, + }); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + }); + + it.each([ + ["stdin ends", (child: Awaited<ReturnType<typeof startChild>>) => child.stdin.end()], + ["SIGTERM arrives", (child: Awaited<ReturnType<typeof startChild>>) => child.signalTarget.emit("SIGTERM")], + ])("stops serving and exits 0 when %s, without waiting for an in-flight request", async (_label, trigger) => { + let entered: () => void = () => {}; + const inFlight = new Promise<void>((resolve) => { entered = resolve; }); + const hang: WebRoute = { path: "/hang", kind: "page", handler: () => { entered(); } }; + const child = await startChild({ routes: () => [hang] }); + const { port } = child.handshake as unknown as Handshake; + + const pending = new Promise<string>((resolve) => { + const req = http.get({ host: "127.0.0.1", port, path: "/hang", headers: { cookie: cookieFor(child.handshake as unknown as Handshake) } }); + req.on("response", () => resolve("response")); + req.on("error", (error: NodeJS.ErrnoException) => resolve(error.code ?? "error")); + }); + await inFlight; + + trigger(child); + + await vi.waitFor(() => expect(child.exit).toHaveBeenCalledWith(0)); + expect(await pending).toBe("ECONNRESET"); + await expect(fetch(`http://127.0.0.1:${port}/hang`)).rejects.toThrow(); + }); + + it("ignores EPIPE on stdout", async () => { + const stdout = new Writable({ + write(_chunk, _encoding, callback) { + callback(Object.assign(new Error("write EPIPE"), { code: "EPIPE" })); + }, + }); + const stdin = new PassThrough(); + const exit = vi.fn(); + + await runWebChild({ port: 0, stdin, stdout, exit, signalTarget: new EventEmitter(), build: "b", routes: () => [] }); + await new Promise((resolve) => setImmediate(resolve)); + stdin.end(); + + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0)); + }); +}); From e591d9c718c6d38c2ab4ab3c5cea6f91b848389b Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:32:13 -0300 Subject: [PATCH 11/20] feat(daemon): Supervise a web child process WebSupervisor keeps at most one web child. It shares a start in flight, reads the JSON handshake line with a timeout, maps failures to WEB_LISTEN_FAILED, WEB_START_FAILED and WEB_BAD_ENTRY, and restarts the child when the caller's build or entry differs. The child's stderr goes to logs/web-child.log and the daemon never imports web code. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 18 +- .specs/features/web-daemon/tasks.md | 29 +-- src/daemon/web-supervisor.ts | 270 ++++++++++++++++++++++++++++ tests/web-supervisor.test.ts | 267 +++++++++++++++++++++++++++ 4 files changed, 561 insertions(+), 23 deletions(-) create mode 100644 src/daemon/web-supervisor.ts create mode 100644 tests/web-supervisor.test.ts diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index 41a59a1..69436a9 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -255,16 +255,16 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | Requirement ID | Story | Phase | Status | | --- | --- | --- | --- | | WD-01 | P1: Daemon supervises one web server | Tasks | Pending | -| WD-02 | P1: Daemon supervises one web server | Tasks | Pending | -| WD-03 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-02 | P1: Daemon supervises one web server | Tasks | Verified | +| WD-03 | P1: Daemon supervises one web server | Tasks | Verified | | WD-04 | P1: Daemon supervises one web server | Tasks | Verified | | WD-05 | P1: Daemon supervises one web server | Tasks | Pending | | WD-06 | P1: Daemon supervises one web server | Tasks | Pending | -| WD-07 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-07 | P1: Daemon supervises one web server | Tasks | Verified | | WD-08 | P1: Daemon supervises one web server | Tasks | Verified | | WD-09 | P1: Daemon supervises one web server | Tasks | Verified | | WD-10 | P1: Daemon supervises one web server | Tasks | Pending | -| WD-11 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-11 | P1: Daemon supervises one web server | Tasks | Verified | | WD-12 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-13 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-14 | P1: Web commands delegate to the daemon | Tasks | Pending | @@ -281,7 +281,7 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-25 | P1: Web failures stay isolated | Tasks | Verified | | WD-26 | P1: Web failures stay isolated | Tasks | Verified | | WD-27 | P1: Web failures stay isolated | Tasks | Verified | -| WD-28 | P1: Web failures stay isolated | Tasks | Pending | +| WD-28 | P1: Web failures stay isolated | Tasks | Verified | | WD-29 | P1: Long-lived server authentication | Tasks | Verified | | WD-30 | P1: Long-lived server authentication | Tasks | Verified | | WD-31 | P1: Long-lived server authentication | Tasks | Verified | @@ -294,16 +294,16 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-38 | P1: Review reads the requested repository | Tasks | Verified | | WD-39 | P1: Review reads the requested repository | Tasks | Verified | | WD-40 | P2: Web child restarts on build change | Tasks | Verified | -| WD-41 | P2: Web child restarts on build change | Tasks | Pending | -| WD-42 | P2: Web child restarts on build change | Tasks | Pending | +| WD-41 | P2: Web child restarts on build change | Tasks | Verified | +| WD-42 | P2: Web child restarts on build change | Tasks | Verified | | WD-43 | P2: Web child restarts on build change | Tasks | Pending | | WD-44 | P2: In-process fallback | Tasks | Pending | | WD-45 | P2: In-process fallback | Tasks | Pending | | WD-46 | P2: In-process fallback | Tasks | Pending | | WD-47 | Edge cases | Tasks | Verified | | WD-48 | Edge cases | Tasks | Verified | -| WD-49 | Edge cases | Tasks | Pending | -| WD-50 | Edge cases | Tasks | Pending | +| WD-49 | Edge cases | Tasks | Verified | +| WD-50 | Edge cases | Tasks | Verified | **Coverage:** 50 total, 50 mapped to tasks (see tasks.md), 0 unmapped. diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index f1540bc..f0a0999 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -341,23 +341,24 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] First `ensure` spawns once and resolves `{ baseUrl, port, token }` from the fake handshake -- [ ] Same build again → no spawn; no build → no spawn; two concurrent calls → one spawn, same result -- [ ] Explicit port → `--port <n>` in args; error line → `WEB_LISTEN_FAILED` with `details.port` -- [ ] Exit before handshake → `WEB_START_FAILED`; no line in `startTimeoutMs` (fake timers) → child killed and `WEB_START_FAILED` -- [ ] Handshake split across two stdout chunks parses; non-JSON first line or a line missing `token` → child killed and `WEB_START_FAILED` -- [ ] After the handshake, further stdout data is consumed (the stream is flowing) -- [ ] `entry` spawned as the script; relative entry, entry not ending in `/web/child.js`, or missing entry → `WEB_BAD_ENTRY`, no spawn -- [ ] Same build with a different `entry` → old child stopped, new child spawned from the new entry -- [ ] Default spawn opens `logs/web-child.log` in append mode for stderr (checked via the injected spawn options factory or a spawn spy) -- [ ] Child exit after handshake → log `web child exited code=<code>`; next `ensure` spawns again -- [ ] Different build → SIGTERM to the old child, SIGKILL after `stopTimeoutMs` if it has not exited, then the new child's result -- [ ] Log gets `web listening port=<port>` and never the token -- [ ] Static check: `src/daemon/web-supervisor.ts` and `src/daemon/daemon.ts` import nothing from `../web/` or `../cli/` -- [ ] Gate check passes: `npx vitest run tests/web-supervisor.test.ts`; `npx tsc --noEmit` +- [x] First `ensure` spawns once and resolves `{ baseUrl, port, token }` from the fake handshake +- [x] Same build again → no spawn; no build → no spawn; two concurrent calls → one spawn, same result +- [x] Explicit port → `--port <n>` in args; error line → `WEB_LISTEN_FAILED` with `details.port` +- [x] Exit before handshake → `WEB_START_FAILED`; no line in `startTimeoutMs` (fake timers) → child killed and `WEB_START_FAILED` +- [x] Handshake split across two stdout chunks parses; non-JSON first line or a line missing `token` → child killed and `WEB_START_FAILED` +- [x] After the handshake, further stdout data is consumed (the stream is flowing) +- [x] `entry` spawned as the script; relative entry, entry not ending in `/web/child.js`, or missing entry → `WEB_BAD_ENTRY`, no spawn +- [x] Same build with a different `entry` → old child stopped, new child spawned from the new entry +- [x] Default spawn opens `logs/web-child.log` in append mode for stderr (checked via the injected spawn options factory or a spawn spy) +- [x] Child exit after handshake → log `web child exited code=<code>`; next `ensure` spawns again +- [x] Different build → SIGTERM to the old child, SIGKILL after `stopTimeoutMs` if it has not exited, then the new child's result +- [x] Log gets `web listening port=<port>` and never the token +- [x] Static check: `src/daemon/web-supervisor.ts` and `src/daemon/daemon.ts` import nothing from `../web/` or `../cli/` +- [x] Gate check passes: `npx vitest run tests/web-supervisor.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(daemon): Supervise a web child process` diff --git a/src/daemon/web-supervisor.ts b/src/daemon/web-supervisor.ts new file mode 100644 index 0000000..ec35c3f --- /dev/null +++ b/src/daemon/web-supervisor.ts @@ -0,0 +1,270 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import type { Readable, Writable } from "node:stream"; +import { getPaths } from "../config/paths.js"; + +// The daemon never imports the web server itself (WD-28): it only spawns and watches +// the child that serves the console, so an HTTP failure cannot take the daemon down. + +export const WEB_START_TIMEOUT_MS = 5000; +export const WEB_STOP_TIMEOUT_MS = 3000; + +export interface WebEnsureParams { + port?: number; + build?: string; + entry?: string; +} + +export interface WebEnsureResult { + baseUrl: string; + port: number; + token: string; +} + +export type WebEnsureErrorCode = "WEB_LISTEN_FAILED" | "WEB_START_FAILED" | "WEB_BAD_ENTRY"; + +export class WebEnsureError extends Error { + constructor( + readonly code: WebEnsureErrorCode, + message: string, + readonly details?: { port: number }, + ) { + super(message); + this.name = "WebEnsureError"; + } +} + +export interface WebChildProcess { + stdin: Writable; + stdout: Readable; + kill(signal?: NodeJS.Signals): boolean; + once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown; + once(event: "error", listener: (error: Error) => void): unknown; +} + +export interface WebSupervisorOptions { + log: (line: string) => void; + spawnChild?: (entry: string, args: string[]) => WebChildProcess; + defaultEntry?: string; + entryExists?: (entry: string) => boolean; + startTimeoutMs?: number; + stopTimeoutMs?: number; +} + +interface RunningChild extends WebEnsureResult { + child: WebChildProcess; + entry: string; + build: string | undefined; + exited: Promise<void>; +} + +type SupervisorState = + | { kind: "none" } + | { kind: "starting"; promise: Promise<WebEnsureResult> } + | { kind: "running"; running: RunningChild }; + +export interface SpawnWebChildIo { + spawn: typeof spawn; + logsDir: string; +} + +/** Spawn the child with its stderr appended to `<logsDir>/web-child.log` (WD-50). */ +export function spawnWebChild( + entry: string, + args: string[], + io: SpawnWebChildIo = { spawn, logsDir: getPaths().logsDir }, +): WebChildProcess { + fs.mkdirSync(io.logsDir, { recursive: true }); + const stderr = fs.openSync(path.join(io.logsDir, "web-child.log"), "a"); + try { + const child = io.spawn(process.execPath, [entry, ...args], { + stdio: ["pipe", "pipe", stderr], + env: { ...process.env }, + }); + return child as unknown as WebChildProcess; + } finally { + fs.closeSync(stderr); + } +} + +export class WebSupervisor { + private state: SupervisorState = { kind: "none" }; + private current: WebChildProcess | undefined; + private readonly spawnChild: (entry: string, args: string[]) => WebChildProcess; + private readonly defaultEntry: string; + private readonly entryExists: (entry: string) => boolean; + private readonly startTimeoutMs: number; + private readonly stopTimeoutMs: number; + + constructor(private readonly options: WebSupervisorOptions) { + this.spawnChild = options.spawnChild ?? ((entry, args) => spawnWebChild(entry, args)); + this.defaultEntry = options.defaultEntry ?? fileURLToPath(new URL("../web/child.js", import.meta.url)); + this.entryExists = options.entryExists ?? ((entry) => fs.existsSync(entry)); + this.startTimeoutMs = options.startTimeoutMs ?? WEB_START_TIMEOUT_MS; + this.stopTimeoutMs = options.stopTimeoutMs ?? WEB_STOP_TIMEOUT_MS; + } + + ensure(params: WebEnsureParams): Promise<WebEnsureResult> { + if (params.entry !== undefined && !this.isValidEntry(params.entry)) { + return Promise.reject(new WebEnsureError("WEB_BAD_ENTRY", `invalid web child entry: ${params.entry}`)); + } + const state = this.state; + if (state.kind === "starting") return state.promise; + if (state.kind === "running" && this.matches(state.running, params)) return Promise.resolve(resultOf(state.running)); + + const previous = state.kind === "running" ? state.running : undefined; + const promise = (async () => { + if (previous) await this.stop(previous); + return this.start(params); + })(); + this.state = { kind: "starting", promise }; + promise.catch(() => { + if (this.state.kind === "starting" && this.state.promise === promise) this.state = { kind: "none" }; + }); + return promise; + } + + close(): void { + this.state = { kind: "none" }; + this.current?.kill("SIGTERM"); + } + + private isValidEntry(entry: string): boolean { + return path.isAbsolute(entry) && entry.endsWith("/web/child.js") && this.entryExists(entry); + } + + private matches(running: RunningChild, params: WebEnsureParams): boolean { + if (params.entry !== undefined && params.entry !== running.entry) return false; + if (params.build === undefined) return true; + return (params.entry ?? this.defaultEntry) === running.entry && params.build === running.build; + } + + private async stop(running: RunningChild): Promise<void> { + running.child.kill("SIGTERM"); + if (await exitsWithin(running.exited, this.stopTimeoutMs)) return; + running.child.kill("SIGKILL"); + await exitsWithin(running.exited, this.stopTimeoutMs); + } + + private start(params: WebEnsureParams): Promise<WebEnsureResult> { + const entry = params.entry ?? this.defaultEntry; + const args = ["--web-child", ...(params.port === undefined ? [] : ["--port", String(params.port)])]; + let child: WebChildProcess; + try { + child = this.spawnChild(entry, args); + } catch (error) { + return Promise.reject(new WebEnsureError("WEB_START_FAILED", `could not start the web child: ${messageOf(error)}`)); + } + this.current = child; + const exited = new Promise<void>((resolve) => child.once("exit", () => resolve())); + + return new Promise<WebEnsureResult>((resolve, reject) => { + let buffer = ""; + let settled = false; + let listening = false; + const settle = (): boolean => { + if (settled) return false; + settled = true; + clearTimeout(timer); + return true; + }; + const fail = (message: string): void => { + if (!settle()) return; + child.kill("SIGKILL"); + reject(new WebEnsureError("WEB_START_FAILED", message)); + }; + const timer = setTimeout(() => fail(`web child sent no handshake within ${this.startTimeoutMs} ms`), this.startTimeoutMs); + + child.stdin.on("error", () => {}); + child.stdout.on("error", () => {}); + child.once("error", (error) => fail(`could not start the web child: ${error.message}`)); + child.once("exit", (code, signal) => { + if (!settled) { + fail(`web child exited before its handshake (code=${code ?? signal})`); + return; + } + if (!listening) return; + this.options.log(`web child exited code=${code ?? signal}`); + if (this.state.kind === "running" && this.state.running.child === child) this.state = { kind: "none" }; + }); + // Keep reading after the handshake so a chatty child never blocks on a full pipe. + child.stdout.on("data", (chunk: Buffer | string) => { + if (settled) return; + buffer += chunk.toString(); + const end = buffer.indexOf("\n"); + if (end < 0) return; + const handshake = parseHandshake(buffer.slice(0, end)); + if (handshake.kind === "invalid") { + fail(`web child sent an invalid handshake: ${handshake.message}`); + return; + } + settle(); + if (handshake.kind === "error") { + reject(new WebEnsureError("WEB_LISTEN_FAILED", handshake.message, { port: handshake.port ?? params.port ?? 0 })); + return; + } + listening = true; + const running: RunningChild = { + baseUrl: `http://127.0.0.1:${handshake.port}`, + port: handshake.port, + token: handshake.token, + child, + entry, + build: handshake.build ?? params.build, + exited, + }; + this.state = { kind: "running", running }; + this.options.log(`web listening port=${handshake.port}`); + resolve(resultOf(running)); + }); + }); + } +} + +type Handshake = + | { kind: "ok"; port: number; token: string; build?: string } + | { kind: "error"; message: string; port?: number } + | { kind: "invalid"; message: string }; + +function parseHandshake(line: string): Handshake { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + return { kind: "invalid", message: "not JSON" }; + } + if (typeof value !== "object" || value === null) return { kind: "invalid", message: "not an object" }; + const record = value as Record<string, unknown>; + if (typeof record.error === "object" && record.error !== null) { + const error = record.error as Record<string, unknown>; + return { + kind: "error", + message: typeof error.message === "string" ? error.message : "web child could not listen", + port: typeof error.port === "number" ? error.port : undefined, + }; + } + if (typeof record.port !== "number" || typeof record.token !== "string") { + return { kind: "invalid", message: "missing port or token" }; + } + return { kind: "ok", port: record.port, token: record.token, build: typeof record.build === "string" ? record.build : undefined }; +} + +function resultOf(running: RunningChild): WebEnsureResult { + return { baseUrl: running.baseUrl, port: running.port, token: running.token }; +} + +function exitsWithin(exited: Promise<void>, ms: number): Promise<boolean> { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), ms); + void exited.then(() => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/tests/web-supervisor.test.ts b/tests/web-supervisor.test.ts new file mode 100644 index 0000000..4f22a9f --- /dev/null +++ b/tests/web-supervisor.test.ts @@ -0,0 +1,267 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import type { spawn } from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { spawnWebChild, WebSupervisor, type WebChildProcess, type WebSupervisorOptions } from "../src/daemon/web-supervisor.js"; + +const ENTRY = "/opt/codedeck/dist/web/child.js"; + +class FakeChild extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly signals: string[] = []; + exitOn: string[] = ["SIGTERM", "SIGKILL"]; + + kill(signal: NodeJS.Signals = "SIGTERM"): boolean { + this.signals.push(signal); + if (this.exitOn.includes(signal)) queueMicrotask(() => this.emit("exit", null, signal)); + return true; + } + + handshake(value: unknown): void { + this.stdout.write(`${JSON.stringify(value)}\n`); + } +} + +function harness(overrides: Partial<WebSupervisorOptions> = {}) { + const children: FakeChild[] = []; + const spawns: { entry: string; args: string[] }[] = []; + const lines: string[] = []; + const supervisor = new WebSupervisor({ + log: (line) => lines.push(line), + defaultEntry: ENTRY, + entryExists: () => true, + spawnChild: (entry, args) => { + spawns.push({ entry, args }); + const child = new FakeChild(); + children.push(child); + return child as unknown as WebChildProcess; + }, + ...overrides, + }); + return { supervisor, children, spawns, lines }; +} + +const ok = (port = 4100, token = "tok-1", build = "b1") => ({ port, token, build }); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("WebSupervisor.ensure", () => { + it("spawns once and resolves the handshake result", async () => { + const { supervisor, children, spawns } = harness(); + + const pending = supervisor.ensure({ build: "b1" }); + children[0].handshake(ok()); + + await expect(pending).resolves.toEqual({ baseUrl: "http://127.0.0.1:4100", port: 4100, token: "tok-1" }); + expect(spawns).toEqual([{ entry: ENTRY, args: ["--web-child"] }]); + }); + + it("reuses the running child for the same build or no build, and shares a start in flight", async () => { + const { supervisor, children, spawns } = harness(); + + const first = supervisor.ensure({ build: "b1" }); + const concurrent = supervisor.ensure({ build: "b1" }); + children[0].handshake(ok()); + const [a, b] = await Promise.all([first, concurrent]); + + expect(b).toEqual(a); + await expect(supervisor.ensure({ build: "b1" })).resolves.toEqual(a); + await expect(supervisor.ensure({})).resolves.toEqual(a); + await expect(supervisor.ensure({ entry: ENTRY })).resolves.toEqual(a); + expect(spawns).toHaveLength(1); + }); + + it("passes an explicit port and maps an error line to WEB_LISTEN_FAILED", async () => { + const { supervisor, children, spawns } = harness(); + + const pending = supervisor.ensure({ port: 4567 }); + children[0].handshake({ error: { message: "listen EADDRINUSE: address already in use 127.0.0.1:4567", port: 4567 } }); + + await expect(pending).rejects.toMatchObject({ + code: "WEB_LISTEN_FAILED", + message: "listen EADDRINUSE: address already in use 127.0.0.1:4567", + details: { port: 4567 }, + }); + expect(spawns[0].args).toEqual(["--web-child", "--port", "4567"]); + }); + + it("fails the start when the child exits before its handshake", async () => { + const { supervisor, children } = harness(); + + const pending = supervisor.ensure({}); + children[0].emit("exit", 1, null); + + await expect(pending).rejects.toMatchObject({ code: "WEB_START_FAILED" }); + }); + + it("kills the child and fails the start when no handshake arrives in time", async () => { + vi.useFakeTimers(); + const { supervisor, children } = harness({ startTimeoutMs: 5000 }); + + const pending = supervisor.ensure({}); + const settled = expect(pending).rejects.toMatchObject({ code: "WEB_START_FAILED" }); + await vi.advanceTimersByTimeAsync(5000); + + await settled; + expect(children[0].signals).toEqual(["SIGKILL"]); + }); + + it("parses a handshake split across chunks and keeps the stream flowing afterwards", async () => { + const { supervisor, children } = harness(); + + const pending = supervisor.ensure({}); + children[0].stdout.write('{"port":4100,'); + children[0].stdout.write('"token":"tok-1","build":"b1"}\n'); + + await expect(pending).resolves.toMatchObject({ port: 4100, token: "tok-1" }); + children[0].stdout.write("later output\n"); + expect(children[0].stdout.readableFlowing).toBe(true); + }); + + it.each([ + ["a non-JSON line", "listening on 4100\n"], + ["a line without token", '{"port":4100}\n'], + ])("kills the child and fails the start on %s", async (_label, line) => { + const { supervisor, children } = harness(); + + const pending = supervisor.ensure({}); + children[0].stdout.write(line); + + await expect(pending).rejects.toMatchObject({ code: "WEB_START_FAILED" }); + expect(children[0].signals).toEqual(["SIGKILL"]); + }); + + it("spawns the requested entry as the script", async () => { + const other = "/home/me/codedeck/dist/web/child.js"; + const { supervisor, children, spawns } = harness(); + + const pending = supervisor.ensure({ entry: other }); + children[0].handshake(ok()); + await pending; + + expect(spawns[0].entry).toBe(other); + }); + + it.each([ + ["a relative entry", "dist/web/child.js", true], + ["an entry that is not the web child", "/opt/codedeck/dist/daemon/daemon.js", true], + ["a missing entry", "/gone/dist/web/child.js", false], + ])("rejects %s with WEB_BAD_ENTRY and spawns nothing", async (_label, entry, exists) => { + const { supervisor, spawns } = harness({ entryExists: () => exists }); + + await expect(supervisor.ensure({ entry })).rejects.toMatchObject({ code: "WEB_BAD_ENTRY" }); + expect(spawns).toEqual([]); + }); + + it("replaces the child when the same build comes from another entry", async () => { + const other = "/home/me/codedeck/dist/web/child.js"; + const { supervisor, children, spawns } = harness(); + const first = supervisor.ensure({ build: "b1" }); + children[0].handshake(ok()); + await first; + + const second = supervisor.ensure({ build: "b1", entry: other }); + await vi.waitFor(() => expect(children).toHaveLength(2)); + children[1].handshake(ok(4101, "tok-2", "b1")); + + await expect(second).resolves.toMatchObject({ port: 4101, token: "tok-2" }); + expect(children[0].signals).toEqual(["SIGTERM"]); + expect(spawns.map((spawned) => spawned.entry)).toEqual([ENTRY, other]); + }); + + it("logs a child exit after the handshake and spawns again on the next ensure", async () => { + const { supervisor, children, spawns, lines } = harness(); + const first = supervisor.ensure({}); + children[0].handshake(ok()); + await first; + + children[0].emit("exit", 7, null); + const second = supervisor.ensure({}); + children[1].handshake(ok(4101, "tok-2")); + + await expect(second).resolves.toMatchObject({ port: 4101 }); + expect(lines).toContain("web child exited code=7"); + expect(spawns).toHaveLength(2); + }); + + it("stops an old build with SIGTERM, escalates to SIGKILL after the stop timeout, and returns the new child", async () => { + vi.useFakeTimers(); + const { supervisor, children } = harness({ stopTimeoutMs: 3000 }); + const first = supervisor.ensure({ build: "b1" }); + children[0].handshake(ok()); + await first; + children[0].exitOn = ["SIGKILL"]; + + const second = supervisor.ensure({ build: "b2" }); + await vi.advanceTimersByTimeAsync(2999); + expect(children[0].signals).toEqual(["SIGTERM"]); + expect(children).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await vi.waitFor(() => expect(children).toHaveLength(2)); + children[1].handshake(ok(4101, "tok-2", "b2")); + + await expect(second).resolves.toMatchObject({ port: 4101, token: "tok-2" }); + expect(children[0].signals).toEqual(["SIGTERM", "SIGKILL"]); + }); + + it("logs the listening port and never the token", async () => { + const { supervisor, children, lines } = harness(); + + const pending = supervisor.ensure({}); + children[0].handshake(ok(4100, "secret-token")); + await pending; + + expect(lines).toEqual(["web listening port=4100"]); + expect(lines.join("\n")).not.toContain("secret-token"); + }); +}); + +describe("WebSupervisor.close", () => { + it("sends SIGTERM to the running child and returns without waiting", async () => { + const { supervisor, children } = harness(); + const pending = supervisor.ensure({}); + children[0].handshake(ok()); + await pending; + children[0].exitOn = []; + + expect(supervisor.close()).toBeUndefined(); + expect(children[0].signals).toEqual(["SIGTERM"]); + }); +}); + +describe("spawnWebChild", () => { + it("appends the child's stderr to logs/web-child.log", () => { + const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-web-child-log-")); + const logFile = path.join(logsDir, "web-child.log"); + fs.writeFileSync(logFile, "old\n"); + const fakeSpawn = vi.fn((_command: string, args: readonly string[], options: { stdio: unknown[] }) => { + fs.writeSync(options.stdio[2] as number, "new\n"); + return { args } as never; + }); + + try { + spawnWebChild(ENTRY, ["--web-child"], { spawn: fakeSpawn as unknown as typeof spawn, logsDir }); + + expect(fakeSpawn).toHaveBeenCalledWith(process.execPath, [ENTRY, "--web-child"], expect.objectContaining({ + stdio: ["pipe", "pipe", expect.any(Number)], + })); + expect(fs.readFileSync(logFile, "utf8")).toBe("old\nnew\n"); + } finally { + fs.rmSync(logsDir, { recursive: true, force: true }); + } + }); +}); + +describe("daemon import boundary", () => { + it.each(["src/daemon/web-supervisor.ts", "src/daemon/daemon.ts"])("%s imports nothing from web or cli", (file) => { + const source = fs.readFileSync(path.join(import.meta.dirname, "..", file), "utf8"); + expect(source).not.toMatch(/from\s+["']\.\.\/(web|cli)\//); + expect(source).not.toMatch(/import\(\s*["']\.\.\/(web|cli)\//); + }); +}); From 6b3245b7d7df0439a2b0b9572accdb91237ddc38 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:33:29 -0300 Subject: [PATCH 12/20] feat(daemon): Host the web console through web.ensure The daemon answers web.ensure by starting or reusing its supervised web child and returns the base URL, port and token. Supervisor failures become IPC errors with the same code and details, and shutdown sends SIGTERM to the child before the session drain. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 10 ++-- .specs/features/web-daemon/tasks.md | 11 ++-- src/daemon/daemon.ts | 40 ++++++++++++- src/daemon/protocol.ts | 24 +++++++- src/daemon/web-supervisor.ts | 15 +---- tests/daemon-web.test.ts | 91 +++++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 26 deletions(-) create mode 100644 tests/daemon-web.test.ts diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index 69436a9..c2b2407 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -254,16 +254,16 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | Requirement ID | Story | Phase | Status | | --- | --- | --- | --- | -| WD-01 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-01 | P1: Daemon supervises one web server | Tasks | Verified | | WD-02 | P1: Daemon supervises one web server | Tasks | Verified | | WD-03 | P1: Daemon supervises one web server | Tasks | Verified | | WD-04 | P1: Daemon supervises one web server | Tasks | Verified | -| WD-05 | P1: Daemon supervises one web server | Tasks | Pending | -| WD-06 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-05 | P1: Daemon supervises one web server | Tasks | Verified | +| WD-06 | P1: Daemon supervises one web server | Tasks | Verified | | WD-07 | P1: Daemon supervises one web server | Tasks | Verified | | WD-08 | P1: Daemon supervises one web server | Tasks | Verified | | WD-09 | P1: Daemon supervises one web server | Tasks | Verified | -| WD-10 | P1: Daemon supervises one web server | Tasks | Pending | +| WD-10 | P1: Daemon supervises one web server | Tasks | Verified | | WD-11 | P1: Daemon supervises one web server | Tasks | Verified | | WD-12 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-13 | P1: Web commands delegate to the daemon | Tasks | Pending | @@ -296,7 +296,7 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-40 | P2: Web child restarts on build change | Tasks | Verified | | WD-41 | P2: Web child restarts on build change | Tasks | Verified | | WD-42 | P2: Web child restarts on build change | Tasks | Verified | -| WD-43 | P2: Web child restarts on build change | Tasks | Pending | +| WD-43 | P2: Web child restarts on build change | Tasks | Verified | | WD-44 | P2: In-process fallback | Tasks | Pending | | WD-45 | P2: In-process fallback | Tasks | Pending | | WD-46 | P2: In-process fallback | Tasks | Pending | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index f0a0999..525617c 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -379,14 +379,15 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] `web.ensure` via the seam with an injected supervisor returns its result -- [ ] Supervisor `WebEnsureError` → IPC error with the same `code`, `message`, `details` -- [ ] `handleShutdown` calls `close()` on the supervisor before marking sessions -- [ ] A seeded `working` session is unchanged after `web.ensure` calls that restart the child -- [ ] Gate check passes: `npx vitest run tests/daemon-web.test.ts`, `npx vitest run tests/web-supervisor.test.ts`, `npx vitest run tests/web-child.test.ts`; `npx tsc --noEmit` +- [x] `web.ensure` via the seam with an injected supervisor returns its result +- [x] Supervisor `WebEnsureError` → IPC error with the same `code`, `message`, `details` +- [x] `handleShutdown` calls `close()` on the supervisor before marking sessions +- [x] A seeded `working` session is unchanged after `web.ensure` calls that restart the child +- [x] Gate check passes: `npx vitest run tests/daemon-web.test.ts`, `npx vitest run tests/web-supervisor.test.ts`, `npx vitest run tests/web-child.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: full +**Status**: ✅ Done **Commit**: `feat(daemon): Host the web console through web.ensure` diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index 79fbc8b..5d0ab1c 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -9,7 +9,8 @@ import { EventStore } from "../store/events.js"; import { ClaimsStore } from "../store/claims.js"; import { getPaths, ensureDirs } from "../config/paths.js"; import { createIpcServer } from "./ipc.js"; -import type { IpcRequest, IpcResponse, UsageQueryParams } from "./protocol.js"; +import type { IpcRequest, IpcResponse, UsageQueryParams, WebEnsureParams, WebEnsureResult } from "./protocol.js"; +import { WebEnsureError, WebSupervisor } from "./web-supervisor.js"; import { getRegistry } from "../drivers/registry.js"; import { isActiveStatus, isTerminalStatus, liveStatus, normalizeAgentId, type AgentId, type Session, type SessionStatus } from "../core/session.js"; import { parseSandbox, type AgentDriver, type CodexSandbox, type DriverSession } from "../core/driver.js"; @@ -93,6 +94,21 @@ function livePidIdentity(s: Session): boolean { ); } +export interface WebHost { + ensure(params: WebEnsureParams): Promise<WebEnsureResult>; + close(): void; +} + +export interface DaemonOptions { + webSupervisor?: WebHost; +} + +function appendDaemonLog(line: string): void { + try { + fs.appendFileSync(getPaths().daemonLog, `[${new Date().toISOString()}] ${line}\n`); + } catch {} +} + class Daemon { private db: Database; private sessions: SessionStore; @@ -114,6 +130,8 @@ class Daemon { // Best-effort delay-lock child (systemd-inhibit), alive for the daemon's // whole life when the binary exists; killed after TRUNCATE in the drain. private inhibitChild: ChildProcess | null = null; + // Supervisor of the web console child, created on the first web.ensure. + private web?: WebHost; private inhibitExitHookInstalled = false; private inFlightModels = new Map<string, Promise<HarnessModels[]>>(); private inFlightOpenUsageReconciliations = new Map<string, Promise<boolean>>(); @@ -291,7 +309,8 @@ class Daemon { return promise; } - constructor() { + constructor(options: DaemonOptions = {}) { + this.web = options.webSupervisor; ensureDirs(); this.db = new Database(); const handle = this.db.getHandle(); @@ -1349,6 +1368,21 @@ class Daemon { break; } + case "web.ensure": { + const p = (params || {}) as WebEnsureParams; + this.web ??= new WebSupervisor({ log: appendDaemonLog }); + try { + send({ result: await this.web.ensure(p) }); + } catch (error) { + if (error instanceof WebEnsureError) { + send({ error: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } }); + } else { + send({ error: { code: "WEB_START_FAILED", message: error instanceof Error ? error.message : String(error) } }); + } + } + break; + } + case "daemon.stop": { send({ result: { ok: true } }); void this.handleShutdown("daemon.stop").finally(() => process.exit(0)); @@ -1852,6 +1886,8 @@ class Daemon { } private async runShutdown(reason: string): Promise<void> { + // SIGTERM only; the web child holds no state worth waiting for. + try { this.web?.close(); } catch {} const handle = this.db.getHandle(); // Never close mid-transaction: roll back a stale BEGIN (best-effort) // BEFORE persisting, so the interrupted writes below stay durable. diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 6cc9752..a44ee03 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -27,7 +27,8 @@ export type RequestMethod = | "doctor" | "models.list" | "usage.get" - | "usage.query"; + | "usage.query" + | "web.ensure"; export interface RunOptions { prompt: string; @@ -226,6 +227,24 @@ export interface QueryUsageRequest { params?: UsageQueryParams; } +export interface WebEnsureParams { + port?: number; + build?: string; + /** Absolute path of the caller's `dist/web/child.js`; the daemon spawns it as the web child. */ + entry?: string; +} + +export interface WebEnsureResult { + baseUrl: string; + port: number; + token: string; +} + +export interface EnsureWebRequest { + method: "web.ensure"; + params?: WebEnsureParams; +} + export interface ListModelsResult { agents: HarnessModels[]; } @@ -250,7 +269,8 @@ export type RequestParams = | DaemonStatusRequest | ListModelsRequest | GetUsageRequest - | QueryUsageRequest; + | QueryUsageRequest + | EnsureWebRequest; export type UsageGetResult = RunUsageSummary; diff --git a/src/daemon/web-supervisor.ts b/src/daemon/web-supervisor.ts index ec35c3f..f2c6534 100644 --- a/src/daemon/web-supervisor.ts +++ b/src/daemon/web-supervisor.ts @@ -4,25 +4,16 @@ import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import type { Readable, Writable } from "node:stream"; import { getPaths } from "../config/paths.js"; +import type { WebEnsureParams, WebEnsureResult } from "./protocol.js"; // The daemon never imports the web server itself (WD-28): it only spawns and watches // the child that serves the console, so an HTTP failure cannot take the daemon down. +export type { WebEnsureParams, WebEnsureResult }; + export const WEB_START_TIMEOUT_MS = 5000; export const WEB_STOP_TIMEOUT_MS = 3000; -export interface WebEnsureParams { - port?: number; - build?: string; - entry?: string; -} - -export interface WebEnsureResult { - baseUrl: string; - port: number; - token: string; -} - export type WebEnsureErrorCode = "WEB_LISTEN_FAILED" | "WEB_START_FAILED" | "WEB_BAD_ENTRY"; export class WebEnsureError extends Error { diff --git a/tests/daemon-web.test.ts b/tests/daemon-web.test.ts new file mode 100644 index 0000000..29aec0a --- /dev/null +++ b/tests/daemon-web.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { Daemon, type WebHost } from "../src/daemon/daemon.js"; +import { WebEnsureError, WebSupervisor, type WebChildProcess } from "../src/daemon/web-supervisor.js"; +import { fakeSocket, makeDaemonTestContext, registerDaemonTestHooks, seam, seed } from "./helpers/daemon-seam.js"; + +let daemon: Daemon | undefined; +const testContext = makeDaemonTestContext("daemon-web-"); +registerDaemonTestHooks(testContext, () => daemon, () => { daemon = undefined; }); + +async function ensure(params: unknown): Promise<Record<string, any>> { + const { writes, socket } = fakeSocket(); + await seam(daemon!).handleRequest({ id: testContext.nextRequestId("web"), method: "web.ensure", params }, socket); + return JSON.parse(writes[0]); +} + +function fakeHost(overrides: Partial<WebHost> = {}): WebHost { + return { + ensure: vi.fn(async () => ({ baseUrl: "http://127.0.0.1:4100", port: 4100, token: "tok" })), + close: vi.fn(), + ...overrides, + }; +} + +describe("daemon web.ensure", () => { + it("returns the supervisor result for the request params", async () => { + const host = fakeHost(); + daemon = new Daemon({ webSupervisor: host }); + + const response = await ensure({ port: 4100, build: "b1", entry: "/opt/codedeck/dist/web/child.js" }); + + expect(response.result).toEqual({ baseUrl: "http://127.0.0.1:4100", port: 4100, token: "tok" }); + expect(host.ensure).toHaveBeenCalledWith({ port: 4100, build: "b1", entry: "/opt/codedeck/dist/web/child.js" }); + }); + + it("maps a supervisor error to an IPC error with the same code, message and details", async () => { + daemon = new Daemon({ + webSupervisor: fakeHost({ + ensure: async () => { throw new WebEnsureError("WEB_LISTEN_FAILED", "listen EADDRINUSE", { port: 4567 }); }, + }), + }); + + const response = await ensure({ port: 4567 }); + + expect(response.error).toEqual({ code: "WEB_LISTEN_FAILED", message: "listen EADDRINUSE", details: { port: 4567 } }); + }); + + it("stops the web child before marking sessions during shutdown", async () => { + let statusAtClose: string | undefined; + const host = fakeHost({ + close: vi.fn(() => { statusAtClose = seam(daemon!).sessions.get("s1")?.status; }), + }); + daemon = new Daemon({ webSupervisor: host }); + seed(daemon, "s1", "working"); + + await seam(daemon).handleShutdown("SIGTERM"); + + expect(host.close).toHaveBeenCalledTimes(1); + expect(statusAtClose).toBe("working"); + }); + + it("leaves session rows untouched while restarting the web child", async () => { + const children: EventEmitter[] = []; + const supervisor = new WebSupervisor({ + log: () => {}, + defaultEntry: "/opt/codedeck/dist/web/child.js", + entryExists: () => true, + spawnChild: () => { + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + kill: (signal?: string) => { queueMicrotask(() => child.emit("exit", null, signal)); return true; }, + }); + children.push(child); + const port = 4100 + children.length; + queueMicrotask(() => child.stdout.write(`${JSON.stringify({ port, token: `tok-${port}`, build: `b${children.length}` })}\n`)); + return child as unknown as WebChildProcess; + }, + }); + daemon = new Daemon({ webSupervisor: supervisor }); + seed(daemon, "s1", "working", { pid: 999_999 }); + const before = seam(daemon).sessions.get("s1"); + + expect((await ensure({ build: "b1" })).result.port).toBe(4101); + expect((await ensure({ build: "b2" })).result.port).toBe(4102); + + expect(children).toHaveLength(2); + expect(seam(daemon).sessions.get("s1")).toEqual(before); + }); +}); From dac0fa3b036ab8c468e4dd40a899f9adfcf62279 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:34:23 -0300 Subject: [PATCH 13/20] feat(cli): Open web pages through the daemon launchWebPage ensures the daemon, asks it for the web child with the local build and entry, then opens or prints the page URL and returns. A busy explicit port fails with exit 1. Any other daemon error, or a daemon that cannot start, falls back to serving the full console from this process. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 16 ++-- .specs/features/web-daemon/tasks.md | 17 ++-- src/cli/web-launch.ts | 101 ++++++++++++++++++++++ tests/web-launch.test.ts | 128 ++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 16 deletions(-) create mode 100644 src/cli/web-launch.ts create mode 100644 tests/web-launch.test.ts diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index c2b2407..cfe9ad6 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -270,12 +270,12 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-14 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-15 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-16 | P1: Web commands delegate to the daemon | Tasks | Pending | -| WD-17 | P1: Web commands delegate to the daemon | Tasks | Pending | -| WD-18 | P1: Web commands delegate to the daemon | Tasks | Pending | -| WD-19 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-17 | P1: Web commands delegate to the daemon | Tasks | Verified | +| WD-18 | P1: Web commands delegate to the daemon | Tasks | Verified | +| WD-19 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-20 | P1: Web commands delegate to the daemon | Tasks | Pending | -| WD-21 | P1: Web commands delegate to the daemon | Tasks | Pending | -| WD-22 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-21 | P1: Web commands delegate to the daemon | Tasks | Verified | +| WD-22 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-23 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-24 | P1: Web failures stay isolated | Tasks | Verified | | WD-25 | P1: Web failures stay isolated | Tasks | Verified | @@ -297,9 +297,9 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-41 | P2: Web child restarts on build change | Tasks | Verified | | WD-42 | P2: Web child restarts on build change | Tasks | Verified | | WD-43 | P2: Web child restarts on build change | Tasks | Verified | -| WD-44 | P2: In-process fallback | Tasks | Pending | -| WD-45 | P2: In-process fallback | Tasks | Pending | -| WD-46 | P2: In-process fallback | Tasks | Pending | +| WD-44 | P2: In-process fallback | Tasks | Verified | +| WD-45 | P2: In-process fallback | Tasks | Verified | +| WD-46 | P2: In-process fallback | Tasks | Verified | | WD-47 | Edge cases | Tasks | Verified | | WD-48 | Edge cases | Tasks | Verified | | WD-49 | Edge cases | Tasks | Verified | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index 525617c..aaa96b0 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -408,17 +408,18 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] Success → opens `<baseUrl><path>?<query>&t=<token>`, prints `<title> on <url>`, returns 0; `web.ensure` params carry `build` and an absolute `entry` ending in `/web/child.js` -- [ ] `open: false` → prints the URL line, opener not called, returns 0 -- [ ] Opener false → prints `Could not open a browser, visit <url> manually.`, returns 0 -- [ ] `port` given → sent; omitted → no `port` key; result on another port → `CodeDeck web is already running on port <port>` -- [ ] `WEB_LISTEN_FAILED` → `Failed to listen on 127.0.0.1:<port>: <message>`, returns 1, no fallback -- [ ] `UNKNOWN_METHOD` and `SERVICE_UNAVAILABLE` → WD-44 line with the code, `startServer` called with the full route table, `initialPath` = path + encoded query (a repo with a space and `&` round-trips; no trailing `?` for an empty query), and `fallbackToEphemeral: true` when the port was omitted -- [ ] `ensureDaemonStarted` throws → WD-45 line and `startServer` called -- [ ] Gate check passes: `npx vitest run tests/web-launch.test.ts`; `npx tsc --noEmit` +- [x] Success → opens `<baseUrl><path>?<query>&t=<token>`, prints `<title> on <url>`, returns 0; `web.ensure` params carry `build` and an absolute `entry` ending in `/web/child.js` +- [x] `open: false` → prints the URL line, opener not called, returns 0 +- [x] Opener false → prints `Could not open a browser, visit <url> manually.`, returns 0 +- [x] `port` given → sent; omitted → no `port` key; result on another port → `CodeDeck web is already running on port <port>` +- [x] `WEB_LISTEN_FAILED` → `Failed to listen on 127.0.0.1:<port>: <message>`, returns 1, no fallback +- [x] `UNKNOWN_METHOD` and `SERVICE_UNAVAILABLE` → WD-44 line with the code, `startServer` called with the full route table, `initialPath` = path + encoded query (a repo with a space and `&` round-trips; no trailing `?` for an empty query), and `fallbackToEphemeral: true` when the port was omitted +- [x] `ensureDaemonStarted` throws → WD-45 line and `startServer` called +- [x] Gate check passes: `npx vitest run tests/web-launch.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(cli): Open web pages through the daemon` diff --git a/src/cli/web-launch.ts b/src/cli/web-launch.ts new file mode 100644 index 0000000..c9a6ac9 --- /dev/null +++ b/src/cli/web-launch.ts @@ -0,0 +1,101 @@ +import { fileURLToPath } from "node:url"; +import { IpcClient } from "../daemon/ipc.js"; +import { computeBuildId, distRootFor } from "../daemon/build-id.js"; +import type { WebEnsureParams, WebEnsureResult } from "../daemon/protocol.js"; +import { DEFAULT_WEB_PORT, openBrowser, startWebServer } from "../web/server.js"; +import { createUiRoutes } from "./commands/ui.js"; + +export interface LaunchWebPageOptions { + path: string; + query?: Record<string, string>; + title: string; + /** Explicit `--port`; omitted means the daemon picks 3100 or an ephemeral port. */ + port?: number; + open: boolean; +} + +export interface LaunchWebPageDependencies { + client?: Pick<IpcClient, "ensureDaemonStarted" | "request">; + openBrowser?: (url: string) => boolean | Promise<boolean>; + log?: (message: string) => void; + error?: (message: string) => void; + startServer?: typeof startWebServer; + build?: string; + entry?: string; +} + +/** + * Open a console page served by the daemon's web child and return the exit code. + * When the daemon cannot host it, serve the pages from this process instead. + */ +export async function launchWebPage(options: LaunchWebPageOptions, deps: LaunchWebPageDependencies = {}): Promise<number> { + const client = deps.client ?? new IpcClient(); + const log = deps.log ?? ((message: string) => console.log(message)); + const error = deps.error ?? ((message: string) => console.error(message)); + + try { + await client.ensureDaemonStarted(); + } catch { + error("CodeDeck daemon is unavailable; serving from this process."); + return serveInProcess(options, deps, error); + } + + const params: WebEnsureParams = { + ...(options.port === undefined ? {} : { port: options.port }), + build: deps.build ?? computeBuildId(distRootFor(import.meta.url)), + entry: deps.entry ?? fileURLToPath(new URL("../web/child.js", import.meta.url)), + }; + let web: WebEnsureResult; + try { + web = await client.request<WebEnsureResult>("web.ensure", params); + } catch (failure) { + const { code, message, details } = failure as Error & { code?: string; details?: { port?: number } }; + if (code === "WEB_LISTEN_FAILED") { + error(`Failed to listen on 127.0.0.1:${details?.port ?? options.port ?? DEFAULT_WEB_PORT}: ${message}`); + return 1; + } + error(`CodeDeck daemon cannot host the web console (${code ?? "UNKNOWN"}); serving from this process.`); + return serveInProcess(options, deps, error); + } + + if (options.port !== undefined && web.port !== options.port) { + log(`CodeDeck web is already running on port ${web.port}`); + } + const url = new URL(options.path, web.baseUrl); + for (const [key, value] of Object.entries(options.query ?? {})) url.searchParams.set(key, value); + url.searchParams.set("t", web.token); + const pageUrl = url.toString(); + + if (options.open && !(await (deps.openBrowser ?? openBrowser)(pageUrl))) { + log(`Could not open a browser, visit ${pageUrl} manually.`); + } else { + log(`${options.title} on ${pageUrl}`); + } + return 0; +} + +async function serveInProcess( + options: LaunchWebPageOptions, + deps: LaunchWebPageDependencies, + error: (message: string) => void, +): Promise<number> { + const query = new URLSearchParams(options.query ?? {}).toString(); + const port = options.port ?? DEFAULT_WEB_PORT; + try { + await (deps.startServer ?? startWebServer)({ + routes: createUiRoutes(), + port, + fallbackToEphemeral: options.port === undefined, + initialPath: query ? `${options.path}?${query}` : options.path, + title: options.title, + open: options.open, + openBrowser: deps.openBrowser, + log: deps.log, + }); + } catch (failure) { + error(`Failed to listen on 127.0.0.1:${port}: ${failure instanceof Error ? failure.message : String(failure)}`); + return 1; + } + // The server keeps this process alive until SIGINT or SIGTERM. + return 0; +} diff --git a/tests/web-launch.test.ts b/tests/web-launch.test.ts new file mode 100644 index 0000000..7e93d9d --- /dev/null +++ b/tests/web-launch.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; +import { launchWebPage, type LaunchWebPageDependencies, type LaunchWebPageOptions } from "../src/cli/web-launch.js"; +import type { WebServerHandle } from "../src/web/server.js"; + +const BASE = { baseUrl: "http://127.0.0.1:3100", port: 3100, token: "tok" }; + +function ipcError(code: string, message: string, details?: unknown): Error { + return Object.assign(new Error(message), { code, details }); +} + +function setup(overrides: { + ensure?: () => Promise<unknown>; + start?: () => Promise<void>; + opener?: boolean; +} = {}) { + const request = vi.fn(async (_method: string, _params: unknown) => (overrides.ensure ?? (async () => BASE))()); + const ensureDaemonStarted = vi.fn(overrides.start ?? (async () => {})); + const openBrowser = vi.fn(async (_url: string) => overrides.opener ?? true); + const startServer = vi.fn(async () => ({}) as WebServerHandle); + const logs: string[] = []; + const errors: string[] = []; + const deps: LaunchWebPageDependencies = { + client: { ensureDaemonStarted, request } as unknown as LaunchWebPageDependencies["client"], + openBrowser, + startServer, + log: (message) => logs.push(message), + error: (message) => errors.push(message), + build: "build-1", + }; + const launch = (options: Partial<LaunchWebPageOptions> = {}) => + launchWebPage({ path: "/review", query: { repo: "/work/app" }, title: "CodeDeck review", open: true, ...options }, deps); + return { launch, request, ensureDaemonStarted, openBrowser, startServer, logs, errors }; +} + +describe("launchWebPage", () => { + it("opens the daemon page with the query and token, prints the URL line, and returns 0", async () => { + const t = setup(); + + const code = await t.launch(); + + const url = "http://127.0.0.1:3100/review?repo=%2Fwork%2Fapp&t=tok"; + expect(code).toBe(0); + expect(t.openBrowser).toHaveBeenCalledWith(url); + expect(t.logs).toEqual([`CodeDeck review on ${url}`]); + const [method, params] = t.request.mock.calls[0] as [string, { build: string; entry: string; port?: number }]; + expect(method).toBe("web.ensure"); + expect(params.build).toBe("build-1"); + expect(params.entry).toMatch(/^\/.*\/web\/child\.js$/); + expect(t.startServer).not.toHaveBeenCalled(); + }); + + it("prints the URL without opening a browser when open is false", async () => { + const t = setup(); + + expect(await t.launch({ open: false })).toBe(0); + + expect(t.openBrowser).not.toHaveBeenCalled(); + expect(t.logs).toEqual(["CodeDeck review on http://127.0.0.1:3100/review?repo=%2Fwork%2Fapp&t=tok"]); + }); + + it("prints the manual-visit line when the browser cannot open", async () => { + const t = setup({ opener: false }); + + expect(await t.launch()).toBe(0); + + expect(t.logs).toEqual(["Could not open a browser, visit http://127.0.0.1:3100/review?repo=%2Fwork%2Fapp&t=tok manually."]); + }); + + it("sends an explicit port, omits a missing one, and notices a different running port", async () => { + const t = setup(); + + await t.launch({ port: 4200, open: false }); + await t.launch({ open: false }); + + expect(t.request.mock.calls[0][1]).toMatchObject({ port: 4200 }); + expect(t.request.mock.calls[1][1]).not.toHaveProperty("port"); + expect(t.logs[0]).toBe("CodeDeck web is already running on port 3100"); + expect(t.logs.filter((line) => line.startsWith("CodeDeck web is already running"))).toHaveLength(1); + }); + + it("reports WEB_LISTEN_FAILED, returns 1 and does not fall back", async () => { + const t = setup({ ensure: async () => { throw ipcError("WEB_LISTEN_FAILED", "listen EADDRINUSE", { port: 4200 }); } }); + + expect(await t.launch({ port: 4200 })).toBe(1); + + expect(t.errors).toEqual(["Failed to listen on 127.0.0.1:4200: listen EADDRINUSE"]); + expect(t.startServer).not.toHaveBeenCalled(); + }); + + it.each(["UNKNOWN_METHOD", "SERVICE_UNAVAILABLE"])("serves in-process with the full route table after %s", async (code) => { + const t = setup({ ensure: async () => { throw ipcError(code, "nope"); } }); + + expect(await t.launch({ query: { repo: "/work/my app&co" } })).toBe(0); + + expect(t.errors).toEqual([`CodeDeck daemon cannot host the web console (${code}); serving from this process.`]); + const options = (t.startServer.mock.calls[0] as unknown as [Record<string, any>])[0]; + expect(options.routes.map((route: { path: string }) => route.path)).toEqual(expect.arrayContaining([ + "/", "/review", "/api/review", "/setup", "/usage", "/api/usage", + ])); + expect(options.routes.some((route: { path: string }) => route.path.startsWith("/api/setup/"))).toBe(true); + const initial = new URL(options.initialPath, "http://127.0.0.1"); + expect(initial.pathname).toBe("/review"); + expect(initial.searchParams.get("repo")).toBe("/work/my app&co"); + expect(options.fallbackToEphemeral).toBe(true); + expect(options.port).toBe(3100); + }); + + it("keeps an explicit port in the fallback and adds no ? for an empty query", async () => { + const t = setup({ ensure: async () => { throw ipcError("UNKNOWN_METHOD", "nope"); } }); + + await t.launch({ path: "/setup", query: {}, port: 4200 }); + + const options = (t.startServer.mock.calls[0] as unknown as [Record<string, any>])[0]; + expect(options.initialPath).toBe("/setup"); + expect(options.port).toBe(4200); + expect(options.fallbackToEphemeral).toBe(false); + }); + + it("serves in-process when the daemon cannot start", async () => { + const t = setup({ start: async () => { throw new Error("Failed to start daemon"); } }); + + expect(await t.launch()).toBe(0); + + expect(t.errors).toEqual(["CodeDeck daemon is unavailable; serving from this process."]); + expect(t.request).not.toHaveBeenCalled(); + expect(t.startServer).toHaveBeenCalledTimes(1); + }); +}); From bf7c78df1c0ab8ac17d761dd82f5756a7a4f9fd7 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:35:41 -0300 Subject: [PATCH 14/20] feat(cli): Open ui and review from the daemon web server ui opens / and review opens /review?repo=<cwd> through launchWebPage, so both return to the shell instead of holding a server. The commander "3100" defaults are gone so an omitted --port reaches the daemon as no port. The route table tests now serve createUiRoutes and createReviewRoutes directly; the opener-failure case lives in the launcher tests. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 8 +- .specs/features/web-daemon/tasks.md | 9 +- src/cli/commands/review.ts | 35 +++---- src/cli/commands/ui.ts | 34 +++---- src/web/server.ts | 5 + tests/review-command.test.ts | 90 +++++++++++------ tests/web-cli.test.ts | 150 +++++++++++----------------- 7 files changed, 160 insertions(+), 171 deletions(-) diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index cfe9ad6..db34051 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -265,18 +265,18 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-09 | P1: Daemon supervises one web server | Tasks | Verified | | WD-10 | P1: Daemon supervises one web server | Tasks | Verified | | WD-11 | P1: Daemon supervises one web server | Tasks | Verified | -| WD-12 | P1: Web commands delegate to the daemon | Tasks | Pending | -| WD-13 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-12 | P1: Web commands delegate to the daemon | Tasks | Verified | +| WD-13 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-14 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-15 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-16 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-17 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-18 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-19 | P1: Web commands delegate to the daemon | Tasks | Verified | -| WD-20 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-20 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-21 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-22 | P1: Web commands delegate to the daemon | Tasks | Verified | -| WD-23 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-23 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-24 | P1: Web failures stay isolated | Tasks | Verified | | WD-25 | P1: Web failures stay isolated | Tasks | Verified | | WD-26 | P1: Web failures stay isolated | Tasks | Verified | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index aaa96b0..2f01691 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -440,13 +440,14 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] `ui` → `launchWebPage` with path `/`, no port when `--port` is omitted -- [ ] `review` → path `/review`, `repo` = cwd -- [ ] Invalid `--port` → exit 1, launcher not called -- [ ] Gate check passes: `npx vitest run tests/web-cli.test.ts`, `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` +- [x] `ui` → `launchWebPage` with path `/`, no port when `--port` is omitted +- [x] `review` → path `/review`, `repo` = cwd +- [x] Invalid `--port` → exit 1, launcher not called +- [x] Gate check passes: `npx vitest run tests/web-cli.test.ts`, `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(cli): Open ui and review from the daemon web server` diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index 617457b..fc8e918 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -3,7 +3,8 @@ import path from "node:path"; import type { Command } from "commander"; import { REVIEW_PAGE } from "../../web/review-page.js"; import { getLocalReview } from "../../git/review.js"; -import { DEFAULT_WEB_PORT, openBrowser, parseWebPort, startWebServer, type WebRoute } from "../../web/server.js"; +import { DEFAULT_WEB_PORT, openBrowser, parseOptionalWebPort, parseWebPort, type WebRoute } from "../../web/server.js"; +import { launchWebPage } from "../web-launch.js"; export const DEFAULT_REVIEW_PORT = DEFAULT_WEB_PORT; @@ -17,8 +18,8 @@ export interface ReviewDeps { loadReview?: (root: string, ref: string, file?: string) => Promise<unknown>; } -export interface ReviewCommandDependencies extends ReviewDeps { - startServer?: typeof startWebServer; +export interface ReviewCommandDependencies { + launch?: typeof launchWebPage; } function sendJson(res: http.ServerResponse, code: number, value: unknown): void { @@ -92,29 +93,25 @@ export function registerReviewCommand(program: Command, dependencies: ReviewComm program .command("review") .description("Open a local review of the current git changes") - .option("--port <n>", "port to listen on (default: 3100)", String(DEFAULT_REVIEW_PORT)) - .option("--no-open", "serve the review without opening a browser") + .option("--port <n>", "port to listen on (default: 3100)") + .option("--no-open", "print the review URL without opening a browser") .action(async (opts: ReviewCommandOptions) => { - let port: number; + let port: number | undefined; try { - port = parseReviewPort(opts.port); + port = parseOptionalWebPort(opts.port); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; return; } - try { - await (dependencies.startServer ?? startWebServer)({ - routes: createReviewRoutes(dependencies), - port, - initialPath: `/review?${new URLSearchParams({ repo: process.cwd() })}`, - title: "CodeDeck review", - open: opts.open, - }); - } catch (error) { - console.error(`Failed to listen on 127.0.0.1:${port}: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - } + const code = await (dependencies.launch ?? launchWebPage)({ + path: "/review", + query: { repo: process.cwd() }, + title: "CodeDeck review", + port, + open: opts.open !== false, + }); + if (code !== 0) process.exitCode = code; }); } diff --git a/src/cli/commands/ui.ts b/src/cli/commands/ui.ts index 9b983bf..ad775a1 100644 --- a/src/cli/commands/ui.ts +++ b/src/cli/commands/ui.ts @@ -3,22 +3,26 @@ import { createReviewRoutes } from "./review.js"; import { fetchUsageQuery } from "./usage.js"; import { renderHomePage } from "../../web/home-page.js"; import { createSetupRoutes, type SetupRoutesDependencies } from "../../web/setup-routes.js"; -import { DEFAULT_WEB_PORT, parseWebPort, startWebServer, type WebRoute } from "../../web/server.js"; +import { parseOptionalWebPort, type WebRoute } from "../../web/server.js"; import { createUsageRoutes, type UsageRoutesOptions } from "../../web/usage-routes.js"; import type { WebPageLink } from "../../web/brand.js"; +import { launchWebPage } from "../web-launch.js"; export interface UiCommandOptions { port?: string; open?: boolean; } -export interface UiCommandDependencies { - startServer?: typeof startWebServer; +export interface UiRoutesDependencies { setup?: SetupRoutesDependencies; usage?: UsageRoutesOptions; } -export function createUiRoutes(dependencies: Pick<UiCommandDependencies, "setup" | "usage"> = {}): WebRoute[] { +export interface UiCommandDependencies { + launch?: typeof launchWebPage; +} + +export function createUiRoutes(dependencies: UiRoutesDependencies = {}): WebRoute[] { const reviewRoutes = createReviewRoutes().filter((route) => route.path !== "/"); const pages: WebPageLink[] = []; const setupRoutes = createSetupRoutes({ ...dependencies.setup, pages }); @@ -51,29 +55,19 @@ export function registerUiCommand(program: Command, dependencies: UiCommandDepen program .command("ui") .description("Open the local CodeDeck console") - .option("--port <n>", "port to listen on (default: 3100)", String(DEFAULT_WEB_PORT)) - .option("--no-open", "serve the console without opening a browser") + .option("--port <n>", "port to listen on (default: 3100)") + .option("--no-open", "print the console URL without opening a browser") .action(async (opts: UiCommandOptions) => { - let port: number; + let port: number | undefined; try { - port = parseWebPort(opts.port); + port = parseOptionalWebPort(opts.port); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; return; } - try { - await (dependencies.startServer ?? startWebServer)({ - routes: createUiRoutes(dependencies), - port, - initialPath: "/", - title: "CodeDeck UI", - open: opts.open, - }); - } catch (error) { - console.error(`Failed to listen on 127.0.0.1:${port}: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - } + const code = await (dependencies.launch ?? launchWebPage)({ path: "/", title: "CodeDeck UI", port, open: opts.open !== false }); + if (code !== 0) process.exitCode = code; }); } diff --git a/src/web/server.ts b/src/web/server.ts index 4239fbf..e6c8cb3 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -71,6 +71,11 @@ export function parseWebPort(raw: string | undefined): number { return port; } +/** Like `parseWebPort`, but an omitted `--port` stays undefined so the daemon picks the port. */ +export function parseOptionalWebPort(raw: string | undefined): number | undefined { + return raw === undefined ? undefined : parseWebPort(raw); +} + export function openBrowser(url: string): Promise<boolean> { const opener = process.platform === "darwin" diff --git a/tests/review-command.test.ts b/tests/review-command.test.ts index 699a85a..654c358 100644 --- a/tests/review-command.test.ts +++ b/tests/review-command.test.ts @@ -1,8 +1,8 @@ import { Command } from "commander"; import { afterEach, describe, expect, it, vi } from "vitest"; import { sessionFetch } from "./helpers/web-session.js"; -import { parseReviewPort, registerReviewCommand } from "../src/cli/commands/review.js"; -import { startWebServer, type WebServerHandle } from "../src/web/server.js"; +import { createReviewRoutes, parseReviewPort, registerReviewCommand } from "../src/cli/commands/review.js"; +import { startWebServer } from "../src/web/server.js"; import { EventEmitter } from "node:events"; afterEach(() => vi.restoreAllMocks()); @@ -32,42 +32,66 @@ describe("registerReviewCommand", () => { expect(program.commands[0].description()).toContain("local review"); }); - it("starts the shared server with the review aliases and API route", async () => { - const log = vi.spyOn(console, "log").mockImplementation(() => {}); + it("opens the review page for the current repo through the launcher", async () => { + const launch = vi.fn(async () => 0); const program = new Command(); - let started: WebServerHandle | undefined; - registerReviewCommand(program, { - startServer: async (options) => { - started = await startWebServer({ - ...options, - port: 0, - signalTarget: new EventEmitter(), - exit: () => {}, - }); - return started; - }, - loadReview: async (_root, ref, file) => ({ ref, file }), - }); + registerReviewCommand(program, { launch }); await program.parseAsync(["node", "codedeck", "review", "--no-open"], { from: "node" }); - const initial = new URL(started?.initialUrl ?? ""); - expect(initial.searchParams.get("t")).toBe(started?.security.token); - expect(initial.pathname).toBe("/review"); - expect(initial.searchParams.get("repo")).toBe(process.cwd()); - expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); - const root = await sessionFetch(started)(`${started?.baseUrl}/`); - const alias = await sessionFetch(started)(`${started?.baseUrl}/review`); - expect(root.status).toBe(200); - expect(alias.status).toBe(200); - expect(await root.text()).toContain("Review local"); - expect(await alias.text()).toContain("Review local"); + expect(launch).toHaveBeenCalledWith({ + path: "/review", + query: { repo: process.cwd() }, + title: "CodeDeck review", + port: undefined, + open: false, + }); + }); - const repo = encodeURIComponent(process.cwd()); - const api = await sessionFetch(started)(`${started?.baseUrl}/api/review?file=src/web/server.ts&repo=${repo}`); - expect(api.status).toBe(200); - expect(await api.json()).toEqual({ ref: "HEAD", file: "src/web/server.ts" }); + it("rejects an invalid port without launching", async () => { + const launch = vi.fn(async () => 0); + vi.spyOn(console, "error").mockImplementation(() => {}); + const program = new Command(); + registerReviewCommand(program, { launch }); + const exitCode = process.exitCode; - await started?.close(); + try { + await program.parseAsync(["node", "codedeck", "review", "--port", "abc"], { from: "node" }); + + expect(launch).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = exitCode; + } + }); +}); + +describe("review route table", () => { + it("serves the review aliases and the API route", async () => { + const started = await startWebServer({ + routes: createReviewRoutes({ loadReview: async (_root, ref, file) => ({ ref, file }) }), + port: 0, + initialPath: "/review", + open: false, + log: () => {}, + signalTarget: new EventEmitter(), + exit: () => {}, + }); + + try { + const root = await sessionFetch(started)(`${started.baseUrl}/`); + const alias = await sessionFetch(started)(`${started.baseUrl}/review`); + expect(root.status).toBe(200); + expect(alias.status).toBe(200); + expect(await root.text()).toContain("Review local"); + expect(await alias.text()).toContain("Review local"); + + const repo = encodeURIComponent(process.cwd()); + const api = await sessionFetch(started)(`${started.baseUrl}/api/review?file=src/web/server.ts&repo=${repo}`); + expect(api.status).toBe(200); + expect(await api.json()).toEqual({ ref: "HEAD", file: "src/web/server.ts" }); + } finally { + await started.close(); + } }); }); diff --git a/tests/web-cli.test.ts b/tests/web-cli.test.ts index cd1661c..9781226 100644 --- a/tests/web-cli.test.ts +++ b/tests/web-cli.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { sessionFetch } from "./helpers/web-session.js"; import { Command } from "commander"; import { createCliProgram } from "../src/cli/index.js"; -import { registerUiCommand } from "../src/cli/commands/ui.js"; +import { createUiRoutes, registerUiCommand } from "../src/cli/commands/ui.js"; import { registerSetupCommand } from "../src/cli/commands/setup.js"; import { registerUsageCommand, type UsageCommandDependencies } from "../src/cli/commands/usage.js"; import { DEFAULT_CONFIG, serializeConfig, type SetupConfigRead } from "../src/config/config.js"; @@ -82,103 +82,95 @@ const emptyUsage: UsageQueryResult = { }; describe("ui CLI command", () => { - it("appears in root help and serves its registered home, review, setup, and usage pages", async () => { + it("appears in root help and opens the home page through the launcher", async () => { const root = createCliProgram(); expect(root.helpInformation()).toContain("ui"); - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - let started: WebServerHandle | undefined; + const launch = vi.fn(async () => 0); const program = new Command(); - registerUiCommand(program, { - startServer: async (options) => { - started = await startWebServer({ - ...options, - port: 0, - signalTarget: new EventEmitter(), - exit: vi.fn(), - }); - handles.push(started); - return started; - }, - }); + registerUiCommand(program, { launch }); await program.parseAsync(["node", "codedeck", "ui", "--no-open"], { from: "node" }); - expect(started).toBeDefined(); - expect(started?.initialUrl).toContain("?t="); - expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); - const rootResponse = await sessionFetch(started)(`${started?.baseUrl}/`); - const rootHtml = await rootResponse.text(); - expect(rootResponse.status).toBe(200); - expect(rootHtml).not.toContain('href="/"'); - expect(rootHtml).toContain('href="/review"'); - expect(rootHtml).toContain('href="/setup"'); - expect(rootHtml).toContain('href="/usage"'); + expect(launch).toHaveBeenCalledWith({ path: "/", title: "CodeDeck UI", port: undefined, open: false }); + expect(process.exitCode).toBe(originalExitCode); + }); - const reviewResponse = await sessionFetch(started)(`${started?.baseUrl}/review`); - expect(reviewResponse.status).toBe(200); - expect(reviewResponse.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); - expect(await reviewResponse.text()).toContain("Review local"); + it("sends an explicit port to the launcher", async () => { + const launch = vi.fn(async () => 0); + const program = new Command(); + registerUiCommand(program, { launch }); - const setupResponse = await sessionFetch(started)(`${started?.baseUrl}/setup`); - const setupHtml = await setupResponse.text(); - const usageResponse = await sessionFetch(started)(`${started?.baseUrl}/usage`); - const setupNav = setupHtml.split('<nav aria-label="Main navigation">')[1]?.split("</nav>")[0] ?? ""; - expect(setupResponse.status).toBe(200); - expect(setupNav).toContain('href="/">Home</a>'); - expect(setupNav).toContain('href="/review">Review</a>'); - expect(setupNav).toContain('href="/setup" aria-current="page" class="active">Setup</a>'); - expect(setupNav).toContain('href="/usage">Usage</a>'); - expect(usageResponse.status).toBe(200); + await program.parseAsync(["node", "codedeck", "ui", "--port", "4200"], { from: "node" }); + + expect(launch).toHaveBeenCalledWith({ path: "/", title: "CodeDeck UI", port: 4200, open: true }); }); - it("rejects an invalid port without starting a server", async () => { - const startServer = vi.fn(); + it("rejects an invalid port without launching", async () => { + const launch = vi.fn(async () => 0); const error = vi.spyOn(console, "error").mockImplementation(() => {}); const program = new Command(); - registerUiCommand(program, { startServer }); + registerUiCommand(program, { launch }); await program.parseAsync(["node", "codedeck", "ui", "--port", "0"], { from: "node" }); - expect(startServer).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith("--port must be a positive integer"); expect(process.exitCode).toBe(1); }); - it("reports a listen failure without printing a started URL", async () => { - const startServer = vi.fn(async () => { - throw new Error("EADDRINUSE"); - }); - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - const log = vi.spyOn(console, "log").mockImplementation(() => {}); + it("exits with the launcher's failure code", async () => { const program = new Command(); - registerUiCommand(program, { startServer }); + registerUiCommand(program, { launch: vi.fn(async () => 1) }); await program.parseAsync(["node", "codedeck", "ui", "--no-open"], { from: "node" }); - expect(startServer).toHaveBeenCalledOnce(); - expect(error).toHaveBeenCalledWith("Failed to listen on 127.0.0.1:3100: EADDRINUSE"); - expect(log).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); }); }); -describe("ui setup and usage routes", () => { +describe("ui route table", () => { + it("serves its registered home, review, setup, and usage pages", async () => { + const started = await startEphemeralServer({ routes: createUiRoutes(), initialPath: "/", open: false, log: vi.fn() }); + + const rootResponse = await sessionFetch(started)(`${started.baseUrl}/`); + const rootHtml = await rootResponse.text(); + expect(rootResponse.status).toBe(200); + expect(rootHtml).not.toContain('href="/"'); + expect(rootHtml).toContain('href="/review"'); + expect(rootHtml).toContain('href="/setup"'); + expect(rootHtml).toContain('href="/usage"'); + + const reviewResponse = await sessionFetch(started)(`${started.baseUrl}/review`); + expect(reviewResponse.status).toBe(200); + expect(reviewResponse.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); + expect(await reviewResponse.text()).toContain("Review local"); + + const setupResponse = await sessionFetch(started)(`${started.baseUrl}/setup`); + const setupHtml = await setupResponse.text(); + const usageResponse = await sessionFetch(started)(`${started.baseUrl}/usage`); + const setupNav = setupHtml.split('<nav aria-label="Main navigation">')[1]?.split("</nav>")[0] ?? ""; + expect(setupResponse.status).toBe(200); + expect(setupNav).toContain('href="/">Home</a>'); + expect(setupNav).toContain('href="/review">Review</a>'); + expect(setupNav).toContain('href="/setup" aria-current="page" class="active">Setup</a>'); + expect(setupNav).toContain('href="/usage">Usage</a>'); + expect(usageResponse.status).toBe(200); + }); + it("serves setup state, catalog, actions, and usage query routes", async () => { const getBatchModels = vi.fn(async (_options: BatchModelsOptions) => emptyCatalog); const fetchUsageQuery = vi.fn(async () => emptyUsage); - let started: WebServerHandle | undefined; - const program = new Command(); - registerUiCommand(program, { - setup: { readConfig: () => setupRead, getBatchModels }, - usage: { fetchUsageQuery, cwd: "/repo" }, - startServer: async (options) => { - started = await startEphemeralServer(options); - return started; - }, + const started = await startEphemeralServer({ + routes: createUiRoutes({ + setup: { readConfig: () => setupRead, getBatchModels }, + usage: { fetchUsageQuery, cwd: "/repo" }, + }), + initialPath: "/", + open: false, + log: vi.fn(), }); - await program.parseAsync(["node", "codedeck", "ui", "--no-open"], { from: "node" }); - const baseUrl = started!.baseUrl; + const baseUrl = started.baseUrl; const home = await sessionFetch(started)(`${baseUrl}/`); const homeHtml = await home.text(); expect(homeHtml).toContain('href="/setup"'); @@ -187,7 +179,7 @@ describe("ui setup and usage routes", () => { expect((await sessionFetch(started)(`${baseUrl}/api/setup/catalog`)).status).toBe(200); const headers = { - cookie: `codedeck_ui_token_${started!.port}=${started!.security.token}`, + cookie: `codedeck_ui_token_${started.port}=${started.security.token}`, origin: baseUrl, "content-type": "application/json", }; @@ -239,30 +231,6 @@ describe("setup and usage web commands", () => { expect((await sessionFetch(started)(`${started?.baseUrl}/setup`)).status).toBe(200); }); - it("prints the token URL and keeps serving when the browser opener fails", async () => { - let started: WebServerHandle | undefined; - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const program = new Command(); - registerUiCommand(program, { - startServer: async (options) => { - started = await startWebServer({ - ...options, - port: 0, - openBrowser: () => false, - signalTarget: new EventEmitter(), - exit: vi.fn(), - }); - handles.push(started); - return started; - }, - }); - - await program.parseAsync(["node", "codedeck", "ui"], { from: "node" }); - - expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); - expect((await sessionFetch(started)(`${started?.baseUrl}/`)).status).toBe(200); - }); - it("opens aggregate usage with the selected filters, breakdown, interval, and token URL", async () => { const cwd = "/web-current/repo"; vi.spyOn(process, "cwd").mockReturnValue(cwd); From 4928ebc8bae71f6bd7dd3f2cd6569637d97a2425 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:36:53 -0300 Subject: [PATCH 15/20] feat(setup): Open the setup page from the daemon web server setup's web branch opens /setup through launchWebPage and returns, and it no longer needs a TTY because it prints or opens a URL. Only --tui still requires a terminal. --refresh becomes refresh=1 on the page URL, so the command-side route override is gone, and an omitted --port no longer defaults to 3100. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 4 +- .specs/features/web-daemon/tasks.md | 13 ++-- src/cli/commands/setup.ts | 61 +++++---------- tests/setup-cli-contract.test.ts | 111 +++++++++++++--------------- tests/setup-wizard.test.ts | 4 +- tests/web-cli.test.ts | 24 ++---- 6 files changed, 90 insertions(+), 127 deletions(-) diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index db34051..f4da6a1 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -267,8 +267,8 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-11 | P1: Daemon supervises one web server | Tasks | Verified | | WD-12 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-13 | P1: Web commands delegate to the daemon | Tasks | Verified | -| WD-14 | P1: Web commands delegate to the daemon | Tasks | Pending | -| WD-15 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-14 | P1: Web commands delegate to the daemon | Tasks | Verified | +| WD-15 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-16 | P1: Web commands delegate to the daemon | Tasks | Pending | | WD-17 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-18 | P1: Web commands delegate to the daemon | Tasks | Verified | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index 2f01691..2292bd3 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -468,15 +468,16 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] Non-TTY `setup` → launcher called with `/setup`, no port, exit 0 -- [ ] `setup --refresh` → query `{ refresh: "1" }` -- [ ] `setup --tui` without a TTY still fails with `<cli> setup needs a terminal` -- [ ] `tests/setup-wizard.test.ts:1190-1210` moved to the `--tui` case; `tests/setup-cli-contract.test.ts` refresh-injection cases rewritten to assert the launcher query -- [ ] Batch flag tests unchanged -- [ ] Gate check passes: `npx vitest run tests/setup-cli-contract.test.ts`, `npx vitest run tests/setup-wizard.test.ts`, `npx vitest run tests/web-cli.test.ts`; `npx tsc --noEmit` +- [x] Non-TTY `setup` → launcher called with `/setup`, no port, exit 0 +- [x] `setup --refresh` → query `{ refresh: "1" }` +- [x] `setup --tui` without a TTY still fails with `<cli> setup needs a terminal` +- [x] `tests/setup-wizard.test.ts:1190-1210` moved to the `--tui` case; `tests/setup-cli-contract.test.ts` refresh-injection cases rewritten to assert the launcher query +- [x] Batch flag tests unchanged +- [x] Gate check passes: `npx vitest run tests/setup-cli-contract.test.ts`, `npx vitest run tests/setup-wizard.test.ts`, `npx vitest run tests/web-cli.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(setup): Open the setup page from the daemon web server` diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 6ebc2ae..ada4b19 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -52,9 +52,8 @@ import { type RunAgentConfig, type SelfWorkMode, } from "../../config/config.js"; -import { SETUP_PAGE } from "../../web/setup-page.js"; -import { createSetupRoutes } from "../../web/setup-routes.js"; -import { DEFAULT_WEB_PORT, parseWebPort, startWebServer, type WebRoute } from "../../web/server.js"; +import { parseOptionalWebPort } from "../../web/server.js"; +import { launchWebPage } from "../web-launch.js"; export { diffConfig, SetupUsageError }; export type { JsonValue, SetupEnvelope }; @@ -1208,7 +1207,7 @@ export interface SetupCommandDependencies extends SetupBatchDependencies { wizardDiscoverModels?: ModelWizardOptions["discoverModels"]; runWizard?: typeof runModelSetupWizard; saveConfig?: (config: RunAgentConfig) => void; - startServer?: typeof startWebServer; + launch?: typeof launchWebPage; } function commandTokens(opts: Record<string, unknown>, command: Command): string[] { @@ -1233,24 +1232,6 @@ export interface SetupActionResult { envelope?: SetupEnvelope; } -function createSetupCommandRoutes(options: SetupCliOptions): WebRoute[] { - const routes = createSetupRoutes(); - if (!options.refresh) return routes; - - return routes.map((route) => route.path === "/setup" - ? { - ...route, - handler: (_request, response) => { - response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - response.end(SETUP_PAGE.replace( - "</body>", - "<script>globalThis.setupPageReady.then(() => globalThis.setupPage.refreshCatalog());</script>\n</body>", - )); - }, - } - : route); -} - export async function executeSetupAction( args: readonly string[], dependencies: SetupCommandDependencies = {}, @@ -1268,12 +1249,11 @@ export async function executeSetupAction( const tty = dependencies.isTTY ?? Boolean(input.isTTY && stdout.isTTY); if (!parsed.options.batch) { - if (!tty) { - const message = `${getCliName()} setup needs a terminal on both stdin and stdout.`; - writeError(message); - return { code: 1 }; - } if (parsed.options.tui) { + if (!tty) { + writeError(`${getCliName()} setup needs a terminal on both stdin and stdout.`); + return { code: 1 }; + } try { await (dependencies.runWizard ?? runModelSetupWizard)({ registry: dependencies.registry, @@ -1291,26 +1271,21 @@ export async function executeSetupAction( return { code: 0 }; } - let port: number; + let port: number | undefined; try { - port = parseWebPort(parsed.options.port ?? String(DEFAULT_WEB_PORT)); + port = parseOptionalWebPort(parsed.options.port); } catch (error) { writeError(error instanceof Error ? error.message : String(error)); return { code: 1 }; } - try { - await (dependencies.startServer ?? startWebServer)({ - routes: createSetupCommandRoutes(parsed.options), - port, - initialPath: "/setup", - title: "CodeDeck setup", - open: !parsed.options.noOpen, - }); - } catch (error) { - writeError(`Failed to listen on 127.0.0.1:${port}: ${error instanceof Error ? error.message : String(error)}`); - return { code: 1 }; - } - return { code: 0 }; + const code = await (dependencies.launch ?? launchWebPage)({ + path: "/setup", + query: parsed.options.refresh ? { refresh: "1" } : {}, + title: "CodeDeck setup", + port, + open: !parsed.options.noOpen, + }, { error: writeError }); + return { code: code === 0 ? 0 : 1 }; } const result = await runSetupBatch(parsed.options, dependencies); @@ -1327,7 +1302,7 @@ export function registerSetupCommand(program: Command, dependencies: SetupComman .option("--refresh", "ignore the cached catalog and rediscover") .option("--tui", "use the frozen terminal setup wizard") .option("--port <n>", "port to listen on (default: 3100)") - .option("--no-open", "serve setup without opening a browser") + .option("--no-open", "print the setup URL without opening a browser") .option("--non-interactive", "run setup without the picker") .option("--json", "output one machine-readable envelope") .option("--dry-run", "show the proposed config without writing it") diff --git a/tests/setup-cli-contract.test.ts b/tests/setup-cli-contract.test.ts index 9f60af5..4f1ee2b 100644 --- a/tests/setup-cli-contract.test.ts +++ b/tests/setup-cli-contract.test.ts @@ -28,11 +28,9 @@ import { parseBind, parseSetupArgs, runSetupBatch, - type SetupCommandDependencies, type SetupBatchDependencies, type SetupCliOptions, } from "../src/cli/commands/setup.js"; -import type { WebServerHandle, WebServerOptions } from "../src/web/server.js"; const originalEnv = { HOME: process.env.HOME, @@ -367,20 +365,21 @@ describe("setup batch execution", () => { it("does not discover or write for a non-batch invocation without a TTY", async () => { const discover = vi.fn<NonNullable<SetupBatchDependencies["discoverModels"]>>(); const save = vi.fn(); - const stderr = new MemoryWritable(); + const launch = vi.fn(async () => 0); const input = new PassThrough() as PassThrough & { isTTY?: boolean }; input.isTTY = false; const result = await executeSetupAction([], { input, stdout: Object.assign(new MemoryWritable(), { isTTY: false }), - stderr, + stderr: new MemoryWritable(), discoverModels: discover, saveConfig: save, isTTY: false, + launch, }); - expect(result.code).toBe(1); - expect(stderr.text()).toBe(`${getCliName()} setup needs a terminal on both stdin and stdout.\n`); + expect(result.code).toBe(0); + expect(launch).toHaveBeenCalledOnce(); expect(discover).not.toHaveBeenCalled(); expect(save).not.toHaveBeenCalled(); }); @@ -706,96 +705,92 @@ describe("config store seam", () => { }); describe("setup web command", () => { - it("keeps non-TTY setup on the current error path without starting a server", async () => { - const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + it("opens the setup page without a TTY and without a port default", async () => { + const launch = vi.fn(async () => 0); + const stderr = new MemoryWritable(); + const result = await executeSetupAction([], { isTTY: false, launch, stderr }); + + expect(result.code).toBe(0); + expect(stderr.text()).toBe(""); + expect(launch).toHaveBeenCalledWith( + { path: "/setup", query: {}, title: "CodeDeck setup", port: undefined, open: true }, + expect.anything(), + ); + }); + + it("asks the page to refresh the catalog with --refresh", async () => { + const launch = vi.fn(async () => 0); + + const result = await executeSetupAction(["--refresh", "--port", "3201", "--no-open"], { isTTY: true, launch }); + + expect(result.code).toBe(0); + expect(launch).toHaveBeenCalledWith( + { path: "/setup", query: { refresh: "1" }, title: "CodeDeck setup", port: 3201, open: false }, + expect.anything(), + ); + }); + + it("returns 1 when the launcher fails", async () => { + const result = await executeSetupAction(["--port", "3201"], { isTTY: true, launch: vi.fn(async () => 1) }); + + expect(result.code).toBe(1); + }); + + it("keeps --tui on the terminal error path without a TTY", async () => { + const launch = vi.fn(async () => 0); + const runWizard = vi.fn(async () => ({})); const stderr = new MemoryWritable(); - const result = await executeSetupAction([], { isTTY: false, startServer, stderr }); + const result = await executeSetupAction(["--tui"], { isTTY: false, launch, runWizard, stderr }); expect(result.code).toBe(1); expect(stderr.text()).toBe(`${getCliName()} setup needs a terminal on both stdin and stdout.\n`); - expect(startServer).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); + expect(runWizard).not.toHaveBeenCalled(); }); - it("uses the frozen wizard for --tui and does not start the web server", async () => { + it("uses the frozen wizard for --tui and does not launch the web page", async () => { const runWizard = vi.fn(async () => ({})); - const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + const launch = vi.fn(async () => 0); - const result = await executeSetupAction(["--tui"], { isTTY: true, runWizard, startServer }); + const result = await executeSetupAction(["--tui"], { isTTY: true, runWizard, launch }); expect(result.code).toBe(0); expect(runWizard).toHaveBeenCalledOnce(); - expect(startServer).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); }); it.each(["--json", "--dry-run", "--non-interactive"])( - "rejects --port with %s before starting the server", + "rejects --port with %s before launching", async (flag) => { - const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + const launch = vi.fn(async () => 0); const stderr = new MemoryWritable(); const result = await executeSetupAction([flag, "--port", "3201"], { isTTY: true, - startServer, + launch, stderr, }); expect(result.code).toBe(2); expect(stderr.text()).toContain('Option "--port" cannot be used'); - expect(startServer).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); }, ); it("rejects --tui with batch flags before starting either path", async () => { const runWizard = vi.fn(async () => ({})); - const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + const launch = vi.fn(async () => 0); const stderr = new MemoryWritable(); const result = await executeSetupAction(["--tui", "--non-interactive"], { isTTY: true, runWizard, - startServer, + launch, stderr, }); expect(result.code).toBe(2); expect(stderr.text()).toContain('Option "--tui" cannot be used with batch setup flags.'); expect(runWizard).not.toHaveBeenCalled(); - expect(startServer).not.toHaveBeenCalled(); - }); - - it("passes refresh behavior to the setup page route", async () => { - const configFile = getPaths().configFile; - const pointerKey = "activeProfile"; - const savedSetsKey = "profiles"; - fs.mkdirSync(path.dirname(configFile), { recursive: true, mode: 0o700 }); - fs.writeFileSync(configFile, serializeConfig({ - ...DEFAULT_CONFIG, - agents: { reviewer: { harness: "codex", model: "gpt-5" } }, - [pointerKey]: "staging", - [savedSetsKey]: { staging: { agents: { general: { harness: "omp", model: "legacy" } } } }, - }), "utf8"); - - let captured: WebServerOptions | undefined; - const startServer: NonNullable<SetupCommandDependencies["startServer"]> = async (options) => { - captured = options; - return {} as WebServerHandle; - }; - const result = await executeSetupAction( - ["--refresh", "--port", "3201", "--no-open"], - { isTTY: true, startServer }, - ); - - expect(result.code).toBe(0); - expect(captured).toMatchObject({ initialPath: "/setup", port: 3201, open: false }); - const page = captured?.routes.find((route) => route.path === "/setup"); - const pageResponse = { writeHead: vi.fn(), end: vi.fn() }; - page?.handler({} as never, pageResponse as never); - expect(String(pageResponse.end.mock.calls[0]?.[0])).toContain( - "globalThis.setupPageReady.then(() => globalThis.setupPage.refreshCatalog())", - ); - - const state = captured?.routes.find((route) => route.path === "/api/setup/state"); - const stateResponse = { writeHead: vi.fn(), end: vi.fn() }; - state?.handler({ method: "GET" } as never, stateResponse as never); - expect(JSON.parse(String(stateResponse.end.mock.calls[0]?.[0])).target).toEqual({ kind: "global" }); + expect(launch).not.toHaveBeenCalled(); }); }); diff --git a/tests/setup-wizard.test.ts b/tests/setup-wizard.test.ts index 4271228..148bf9e 100644 --- a/tests/setup-wizard.test.ts +++ b/tests/setup-wizard.test.ts @@ -1185,7 +1185,7 @@ describe("setup command", () => { expect(setup?.options.map((option) => option.long)).toContain("--refresh"); }); - it("names the renamed CLI when setup has no terminal", async () => { + it("names the renamed CLI when setup --tui has no terminal", async () => { const previousCliName = process.env.CODEDECK_CLI_NAME; process.env.CODEDECK_CLI_NAME = "codedeck-dev"; const errors: string[] = []; @@ -1198,7 +1198,7 @@ describe("setup command", () => { const program = new Command(); program.exitOverride(); registerSetupCommand(program); - await program.parseAsync(["setup"], { from: "user" }); + await program.parseAsync(["setup", "--tui"], { from: "user" }); expect(errors.join("\n")).toContain("codedeck-dev setup needs a terminal"); expect(process.exitCode).toBe(1); diff --git a/tests/web-cli.test.ts b/tests/web-cli.test.ts index 9781226..30a3ba5 100644 --- a/tests/web-cli.test.ts +++ b/tests/web-cli.test.ts @@ -209,26 +209,18 @@ describe("ui route table", () => { }); describe("setup and usage web commands", () => { - it("starts setup on its selected port without opening a browser", async () => { - let requested: WebServerOptions | undefined; - let started: WebServerHandle | undefined; - const log = vi.spyOn(console, "log").mockImplementation(() => {}); + it("opens setup on its selected port without opening a browser", async () => { + const launch = vi.fn(async () => 0); const program = new Command(); - registerSetupCommand(program, { - isTTY: true, - startServer: async (options) => { - requested = options; - started = await startEphemeralServer(options); - return started; - }, - }); + registerSetupCommand(program, { launch }); await program.parseAsync(["node", "codedeck", "setup", "--port", "32123", "--no-open"], { from: "node" }); - expect(requested).toMatchObject({ initialPath: "/setup", port: 32123, open: false }); - expect(started?.initialUrl).toContain("?t="); - expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); - expect((await sessionFetch(started)(`${started?.baseUrl}/setup`)).status).toBe(200); + expect(launch).toHaveBeenCalledWith( + { path: "/setup", query: {}, title: "CodeDeck setup", port: 32123, open: false }, + expect.anything(), + ); + expect(process.exitCode).toBe(0); }); it("opens aggregate usage with the selected filters, breakdown, interval, and token URL", async () => { From 18faa61dcf3291d9ded72587a1f83cb77148155c Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:37:52 -0300 Subject: [PATCH 16/20] feat(usage): Open the usage page from the daemon web server usage --web resolves its filters against the caller's cwd and passes the non-empty ones, plus by and interval, as the page query to launchWebPage. The shared server no longer needs a process-local page configuration, and the command returns to the shell. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/spec.md | 2 +- .specs/features/web-daemon/tasks.md | 7 ++-- src/cli/commands/usage.ts | 60 +++++++++++++---------------- tests/usage-cli.test.ts | 40 +++++++++++-------- tests/web-cli.test.ts | 60 ++++++++++------------------- 5 files changed, 76 insertions(+), 93 deletions(-) diff --git a/.specs/features/web-daemon/spec.md b/.specs/features/web-daemon/spec.md index f4da6a1..75fbf9f 100644 --- a/.specs/features/web-daemon/spec.md +++ b/.specs/features/web-daemon/spec.md @@ -269,7 +269,7 @@ Every web command (`review`, `setup`, `usage --web`, `ui`) starts its own HTTP s | WD-13 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-14 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-15 | P1: Web commands delegate to the daemon | Tasks | Verified | -| WD-16 | P1: Web commands delegate to the daemon | Tasks | Pending | +| WD-16 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-17 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-18 | P1: Web commands delegate to the daemon | Tasks | Verified | | WD-19 | P1: Web commands delegate to the daemon | Tasks | Verified | diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index 2292bd3..2659d77 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -498,12 +498,13 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] `usage --web --today --repo x --by model --interval 5` → query has `period=today`, `repo=x`, `by=model`, `interval=5`, no empty keys -- [ ] `tests/usage-cli.test.ts:300-333` rewritten against the injected launcher; run-id, backfill, and `--web --tui` tests unchanged -- [ ] Gate check passes: `npx vitest run tests/usage-cli.test.ts`, `npx vitest run tests/web-cli.test.ts`, `npx vitest run tests/setup-cli-contract.test.ts`, `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` +- [x] `usage --web --today --repo x --by model --interval 5` → query has `period=today`, `repo=x`, `by=model`, `interval=5`, no empty keys +- [x] `tests/usage-cli.test.ts:300-333` rewritten against the injected launcher; run-id, backfill, and `--web --tui` tests unchanged +- [x] Gate check passes: `npx vitest run tests/usage-cli.test.ts`, `npx vitest run tests/web-cli.test.ts`, `npx vitest run tests/setup-cli-contract.test.ts`, `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: full +**Status**: ✅ Done **Commit**: `feat(usage): Open the usage page from the daemon web server` diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts index 56154b6..8f7fa49 100644 --- a/src/cli/commands/usage.ts +++ b/src/cli/commands/usage.ts @@ -11,8 +11,8 @@ import { SessionStore, resolveUsageDateRange } from "../../store/sessions.js"; import { renderSnapshot } from "../usage/snapshot.js"; import { runDashboard, type DashboardFetcher } from "../usage/dashboard.js"; import { backfillUsage } from "./usage-backfill.js"; -import { DEFAULT_WEB_PORT, parseWebPort, startWebServer } from "../../web/server.js"; -import { createUsageRoutes } from "../../web/usage-routes.js"; +import { parseOptionalWebPort } from "../../web/server.js"; +import { launchWebPage } from "../web-launch.js"; export interface UsageCommandOptions { json?: boolean; @@ -42,7 +42,7 @@ export interface UsageCommandOptions { export interface UsageCommandDependencies { fetchUsageQuery?: typeof fetchUsageQuery; backfillUsage?: typeof backfillUsage; - startServer?: typeof startWebServer; + launch?: typeof launchWebPage; } function parseUsageObservation(value: string | undefined): { nativeId: string; costUsd: number } | undefined { @@ -151,8 +151,8 @@ export function registerUsageCommand(program: Command, dependencies: UsageComman .option("--transcript <nativeId=path>", "report live orchestrator transcript tokens") .option("--backfill", "import historical orchestrator usage") .option("--web", "open aggregate usage in the browser") - .option("--port <n>", "port to listen on (default: 3100)", String(DEFAULT_WEB_PORT)) - .option("--no-open", "serve usage without opening a browser") + .option("--port <n>", "port to listen on (default: 3100)") + .option("--no-open", "print the usage URL without opening a browser") .option("-i, --tui", "open interactive full-screen TUI dashboard") .option("-w, --watch", "watch usage in real time with live updates") .option("--interval <seconds>", "refresh interval for --watch (default: 2)", "2") @@ -215,42 +215,34 @@ export function registerUsageCommand(program: Command, dependencies: UsageComman const queryParams = buildUsageQueryParams(opts, cwd, new Date()); if (opts.web) { - let port: number; + let port: number | undefined; try { - port = parseWebPort(opts.port); + port = parseOptionalWebPort(opts.port); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; return; } - try { - await (dependencies.startServer ?? startWebServer)({ - routes: createUsageRoutes({ - fetchUsageQuery: queryUsage, - cwd, - page: { - by: opts.by, - interval: opts.interval, - filters: { - period: queryParams.period ?? "", - repo: queryParams.repository ?? "", - model: queryParams.model ?? "", - agent: queryParams.agent ?? "", - since: queryParams.since ?? "", - until: queryParams.until ?? "", - }, - }, - }), - port, - initialPath: "/usage", - title: "CodeDeck usage", - open: opts.open, - }); - } catch (error) { - console.error(`Failed to listen on 127.0.0.1:${port}: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - } + // The shared server has its own cwd, so the page gets filters already resolved here. + const query = Object.fromEntries(Object.entries({ + period: queryParams.period, + repo: queryParams.repository, + model: queryParams.model, + agent: queryParams.agent, + since: queryParams.since, + until: queryParams.until, + by: opts.by, + interval: opts.interval, + }).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== "")); + const code = await (dependencies.launch ?? launchWebPage)({ + path: "/usage", + query, + title: "CodeDeck usage", + port, + open: opts.open !== false, + }); + if (code !== 0) process.exitCode = code; return; } diff --git a/tests/usage-cli.test.ts b/tests/usage-cli.test.ts index 37a9662..72fa8a0 100644 --- a/tests/usage-cli.test.ts +++ b/tests/usage-cli.test.ts @@ -2,7 +2,6 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildUsageQueryParams } from "../src/core/usage-query.js"; import { normalizeUsageInterval } from "../src/web/usage-page.js"; -import type { WebServerHandle, WebServerOptions } from "../src/web/server.js"; import type { UsageCommandDependencies } from "../src/cli/commands/usage.js"; const ensureDaemonStarted = vi.fn(async () => {}); @@ -299,31 +298,42 @@ describe("usage CLI", () => { describe("usage web options", () => { it("runs backfill before web startup", async () => { const backfill = vi.fn(async () => ({ imported: 2, skipped: 1 })); - const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + const launch = vi.fn(async () => 0); await runProgramWithDependencies(["--backfill", "--web"], { backfillUsage: backfill, - startServer, + launch, }); expect(backfill).toHaveBeenCalledOnce(); - expect(startServer).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); expect(logs).toEqual(["Usage backfill: imported 2, skipped 1"]); }); - it("forwards the web polling interval and applies its normalization rule", async () => { - let captured: WebServerOptions | undefined; - const startServer: NonNullable<UsageCommandDependencies["startServer"]> = async (options) => { - captured = options; - return {} as WebServerHandle; - }; + it("opens the usage page with the resolved filters, breakdown and interval and no empty keys", async () => { + const launch = vi.fn(async () => 0); - await runProgramWithDependencies(["--web", "--interval", "0"], { startServer }); + await runProgramWithDependencies(["--web", "--today", "--repo", "x", "--by", "model", "--interval", "5"], { launch }); - const pageRoute = captured?.routes.find((route) => route.path === "/usage"); - const response = { writeHead: vi.fn(), end: vi.fn() }; - pageRoute?.handler({} as never, response as never); - expect(String(response.end.mock.calls[0]?.[0])).toContain('"interval":"0"'); + expect(launch).toHaveBeenCalledWith({ + path: "/usage", + query: { period: "today", repo: "x", by: "model", interval: "5" }, + title: "CodeDeck usage", + port: undefined, + open: true, + }); + }); + + it("forwards a raw polling interval for the page to normalize", async () => { + const launch = vi.fn(async () => 0); + + await runProgramWithDependencies(["--web", "--interval", "0", "--port", "4200", "--no-open"], { launch }); + + expect(launch).toHaveBeenCalledWith(expect.objectContaining({ + query: expect.objectContaining({ interval: "0" }), + port: 4200, + open: false, + })); expect(normalizeUsageInterval("0")).toBe(2); expect(normalizeUsageInterval("not-a-number")).toBe(2); expect(normalizeUsageInterval("-0.5")).toBe(1); diff --git a/tests/web-cli.test.ts b/tests/web-cli.test.ts index 30a3ba5..1cfa3a2 100644 --- a/tests/web-cli.test.ts +++ b/tests/web-cli.test.ts @@ -5,7 +5,7 @@ import { Command } from "commander"; import { createCliProgram } from "../src/cli/index.js"; import { createUiRoutes, registerUiCommand } from "../src/cli/commands/ui.js"; import { registerSetupCommand } from "../src/cli/commands/setup.js"; -import { registerUsageCommand, type UsageCommandDependencies } from "../src/cli/commands/usage.js"; +import { registerUsageCommand } from "../src/cli/commands/usage.js"; import { DEFAULT_CONFIG, serializeConfig, type SetupConfigRead } from "../src/config/config.js"; import type { BatchModelsOptions, BatchModelsResult } from "../src/core/models.js"; import type { UsageQueryResult } from "../src/daemon/protocol.js"; @@ -223,20 +223,12 @@ describe("setup and usage web commands", () => { expect(process.exitCode).toBe(0); }); - it("opens aggregate usage with the selected filters, breakdown, interval, and token URL", async () => { + it("opens aggregate usage with the resolved filters, breakdown, and interval", async () => { const cwd = "/web-current/repo"; vi.spyOn(process, "cwd").mockReturnValue(cwd); - const fetchUsageQuery = vi.fn(async () => emptyUsage); - let requested: WebServerOptions | undefined; - let started: WebServerHandle | undefined; - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const startServer: NonNullable<UsageCommandDependencies["startServer"]> = async (options) => { - requested = options; - started = await startEphemeralServer(options); - return started; - }; + const launch = vi.fn(async () => 0); const program = new Command(); - registerUsageCommand(program, { startServer, fetchUsageQuery }); + registerUsageCommand(program, { launch }); await program.parseAsync([ "node", "codedeck", "usage", "--web", "--json", "--port", "32124", "--no-open", @@ -244,49 +236,37 @@ describe("setup and usage web commands", () => { "--current", "--model", "gpt-5", "--agent", "codex", "--by", "origin", "--interval", "0", ], { from: "node" }); - expect(requested).toMatchObject({ initialPath: "/usage", port: 32124, open: false }); - expect(started?.initialUrl).toContain("?t="); - expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); - const page = await (await sessionFetch(started)(`${started?.baseUrl}/usage`)).text(); - expect(page).toContain(JSON.stringify({ - by: "origin", - interval: "0", - filters: { - period: "", + expect(launch).toHaveBeenCalledWith({ + path: "/usage", + query: { repo: cwd, model: "gpt-5", agent: "codex", since: "2026-09-01", until: "2026-09-20", + by: "origin", + interval: "0", }, - })); - - const query = await sessionFetch(started)(`${started?.baseUrl}/api/usage?since=2026-09-01&until=2026-09-20&repo=${encodeURIComponent(cwd)}&model=gpt-5&agent=codex`); - expect(query.status).toBe(200); - expect(fetchUsageQuery).toHaveBeenCalledWith({ - period: undefined, - since: "2026-09-01", - until: "2026-09-20", - repository: cwd, - model: "gpt-5", - agent: "codex", + title: "CodeDeck usage", + port: 32124, + open: false, }); }); - it("rejects usage --web --tui without starting a server", async () => { - const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + it("rejects usage --web --tui without launching", async () => { + const launch = vi.fn(async () => 0); const error = vi.spyOn(console, "error").mockImplementation(() => {}); const program = new Command(); - registerUsageCommand(program, { startServer }); + registerUsageCommand(program, { launch }); await program.parseAsync(["node", "codedeck", "usage", "--web", "--tui"], { from: "node" }); - expect(startServer).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith("Options --web and --tui cannot be used together."); expect(process.exitCode).toBe(2); }); - it("keeps usage <run-id> --web --json on usage.get without a server", async () => { + it("keeps usage <run-id> --web --json on usage.get without launching", async () => { const summary = { runId: "run-web", inputTokens: 12, @@ -302,15 +282,15 @@ describe("setup and usage web commands", () => { }; usageIpc.ensureDaemonStarted.mockResolvedValue(undefined); usageIpc.request.mockResolvedValue(summary); - const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + const launch = vi.fn(async () => 0); const log = vi.spyOn(console, "log").mockImplementation(() => {}); const program = new Command(); - registerUsageCommand(program, { startServer }); + registerUsageCommand(program, { launch }); await program.parseAsync(["node", "codedeck", "usage", "run-web", "--web", "--json"], { from: "node" }); expect(usageIpc.request).toHaveBeenCalledWith("usage.get", { runId: "run-web" }); expect(JSON.parse(log.mock.calls[0]![0] as string)).toEqual(summary); - expect(startServer).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); }); }); From a2904988cdf3fc9111896488f0c18794b1103cb0 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:39:47 -0300 Subject: [PATCH 17/20] docs(web): Document the daemon web console protocol Describe web.ensure, its params, result and error codes, the web child handshake and restart on a build change, the token and cookie flow, and the in-process fallback. Verified with npm run build, the pty gate and an isolated smoke run of review, setup, usage --web and ui. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/tasks.md | 11 +++--- docs/protocol.md | 58 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index 2659d77..cc54fe6 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -525,14 +525,15 @@ T12 → T13 → T14 → T15 → T16 **Done when**: -- [ ] Doc covers `web.ensure` params, result, errors, child restart on build change, and the fallback -- [ ] `npm run build` and `scripts/pty-gate.sh` pass -- [ ] Smoke run with an isolated `RUN_AGENT_DIR`: `review --no-open`, `setup --no-open`, `usage --web --no-open` each exit 0 and print URLs on one port; curl of each page after the token redirect answers 200. After the run, the isolated daemon is stopped -- [ ] Killing the web child, then `ui --no-open`, prints a working URL -- [ ] `touch dist/web/child.js`, then `review --no-open` again → a different token, and `ps` shows the same sessions as before +- [x] Doc covers `web.ensure` params, result, errors, child restart on build change, and the fallback +- [x] `npm run build` and `scripts/pty-gate.sh` pass +- [x] Smoke run with an isolated `RUN_AGENT_DIR`: `review --no-open`, `setup --no-open`, `usage --web --no-open` each exit 0 and print URLs on one port; curl of each page after the token redirect answers 200. After the run, the isolated daemon is stopped +- [x] Killing the web child, then `ui --no-open`, prints a working URL +- [x] `touch dist/web/child.js`, then `review --no-open` again → a different token, and `ps` shows the same sessions as before **Tests**: none **Gate**: build +**Status**: ✅ Done **Commit**: `docs(web): Document the daemon web console protocol` diff --git a/docs/protocol.md b/docs/protocol.md index f7d92f3..94e9497 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -32,3 +32,61 @@ daemon reiniciar, ele reatacha pelo PID + identidade de início persistidos e continua do offset salvo, sem iniciar um segundo processo. Persistência: `sessions` + `events(seq)` monotônico, `raw_payload` preservado. + +## Console web (`web.ensure`) + +O daemon não abre porta HTTP. Ele supervisiona um processo filho +(`dist/web/child.js --web-child`) que serve o console inteiro: `/`, `/review`, +`/setup`, `/usage` e as rotas `/api/*`. `codedeck ui`, `review`, `setup` e +`usage --web` pedem esse filho ao daemon, abrem ou imprimem a URL e voltam para +o shell. + +Request: +```json +{ "id": "w1", "method": "web.ensure", "params": { "port": 3100, "build": "1790000000000", "entry": "/abs/dist/web/child.js" } } +``` + +- `port` (opcional): porta explícita (`--port`). Sem ela, o filho tenta 3100 e + cai numa porta efêmera se 3100 estiver ocupada. +- `build` (opcional): identidade do build do chamador, o maior `mtime` dos + `.js` na árvore `dist/` dele. +- `entry` (opcional): caminho absoluto do `dist/web/child.js` do chamador. Sem + ele, o daemon usa o próprio. + +Response: +```json +{ "id": "w1", "result": { "baseUrl": "http://127.0.0.1:3100", "port": 3100, "token": "..." } } +``` + +A página abre em `<baseUrl><path>?<query>&t=<token>`. O token vira cookie +(`303` sem `t`), e toda rota `/api/*` exige esse cookie. Uma página aberta sem +token nem cookie responde `403 open this page with codedeck ui`. O review +recebe o repositório em `?repo=<cwd>`, porque o filho não roda no cwd de quem +chamou. + +Erros: + +| `code` | Quando | `details` | +| --- | --- | --- | +| `WEB_LISTEN_FAILED` | a porta pedida está ocupada (o CLI imprime `Failed to listen on 127.0.0.1:<port>: ...` e sai com 1) | `{ "port": n }` | +| `WEB_START_FAILED` | o filho morreu antes do handshake, não respondeu em 5 s ou mandou um handshake inválido | | +| `WEB_BAD_ENTRY` | `entry` não é absoluto, não termina em `/web/child.js` ou não existe | | + +Ciclo de vida do filho: + +- Existe no máximo um filho. Pedidos simultâneos compartilham o mesmo start. +- Handshake: a primeira linha do stdout do filho é `{ port, token, build }` ou + `{ error: { message, port } }`. O stderr vai para `~/.run-agent/logs/web-child.log`. +- Se `build` ou `entry` do pedido diferem do filho atual, o daemon manda + `SIGTERM`, espera até 3 s, manda `SIGKILL` se preciso e sobe um filho novo. + Um pedido sem `build` reaproveita o filho atual. Sessões não são tocadas. +- Se o filho morre depois do handshake, o daemon registra + `web child exited code=<code>` no `daemon.log` e sobe outro no próximo + `web.ensure`. O token nunca vai para o log. +- O filho sai quando o stdin fecha (o daemon morreu) ou com `SIGTERM`. No + shutdown, o daemon manda `SIGTERM` sem esperar. + +Fallback: se `web.ensure` falhar com qualquer outro erro (por exemplo +`UNKNOWN_METHOD` de um daemon antigo ou `SERVICE_UNAVAILABLE` durante o +shutdown), ou se o daemon não subir, o comando serve as páginas no próprio +processo, como antes, e fica rodando até `SIGINT` ou `SIGTERM`. From ffeaf8ae719f7566735aabbcaa508b44e3a40845 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:49:26 -0300 Subject: [PATCH 18/20] test(web): Pin the defaults and wiring the verifier found unpinned The verifier showed seven spec values could change without a failing test. Tests now run against the real 5000 ms start and 3000 ms stop timeouts, the default child entry, the child's own build id, and the daemon.log lines through the daemon. They also cover the fallback for WEB_START_FAILED and WEB_BAD_ENTRY, the transitive import boundary, and an invalid --port on setup and usage. The daemon and child gain small seams (spawnWebChild, distRoot) for this. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/tasks.md | 23 ++++++++++- src/daemon/daemon.ts | 8 +++- src/web/child.ts | 4 +- tests/daemon-web.test.ts | 30 ++++++++++++++ tests/setup-cli-contract.test.ts | 11 ++++++ tests/usage-cli.test.ts | 10 +++++ tests/web-child.test.ts | 15 +++++++ tests/web-launch.test.ts | 2 +- tests/web-supervisor.test.ts | 61 +++++++++++++++++++++++------ 9 files changed, 148 insertions(+), 16 deletions(-) diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index cc54fe6..3db6b5f 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -539,6 +539,27 @@ T12 → T13 → T14 → T15 → T16 --- +### T17: Pin the values the Verifier found unpinned + +**What**: Fix task from validation round 1: tests for the 5000 ms and 3000 ms defaults, the default entry, the child's own build id, the daemon.log wiring, WEB_START_FAILED and WEB_BAD_ENTRY fallback, the transitive import boundary, and invalid `--port` on setup and usage. +**Where**: `tests/web-supervisor.test.ts`, `tests/daemon-web.test.ts`, `tests/web-child.test.ts`, `tests/web-launch.test.ts`, `tests/setup-cli-contract.test.ts`, `tests/usage-cli.test.ts`; seams `DaemonOptions.spawnWebChild` and `RunWebChildOptions.distRoot` +**Depends on**: T16 +**Reuses**: existing fakes +**Requirement**: WD-01, WD-06, WD-07, WD-11, WD-28, WD-40, WD-41, WD-44 + +**Done when**: + +- [x] Surviving mutants M6, M7, M8, M10, M18, M21, M22 each break a test +- [x] Gate check passes: the six test files above, one run each; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick +**Status**: ✅ Done + +**Commit**: `test(web): Pin the defaults and wiring the verifier found unpinned` + +--- + ## Phase Execution Map ``` @@ -547,7 +568,7 @@ Phase 1 → Phase 2 → Phase 3 → Phase 4 Phase 1: T1 → T2 → T3 → T4 Phase 2: T5 → T6 → T7 Phase 3: T8 → T9 → T10 → T11 -Phase 4: T12 → T13 → T14 → T15 → T16 +Phase 4: T12 → T13 → T14 → T15 → T16 → T17 ``` ## Diagram-Definition Cross-Check diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index 5d0ab1c..ec95e8c 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -10,7 +10,7 @@ import { ClaimsStore } from "../store/claims.js"; import { getPaths, ensureDirs } from "../config/paths.js"; import { createIpcServer } from "./ipc.js"; import type { IpcRequest, IpcResponse, UsageQueryParams, WebEnsureParams, WebEnsureResult } from "./protocol.js"; -import { WebEnsureError, WebSupervisor } from "./web-supervisor.js"; +import { WebEnsureError, WebSupervisor, type WebSupervisorOptions } from "./web-supervisor.js"; import { getRegistry } from "../drivers/registry.js"; import { isActiveStatus, isTerminalStatus, liveStatus, normalizeAgentId, type AgentId, type Session, type SessionStatus } from "../core/session.js"; import { parseSandbox, type AgentDriver, type CodexSandbox, type DriverSession } from "../core/driver.js"; @@ -101,6 +101,8 @@ export interface WebHost { export interface DaemonOptions { webSupervisor?: WebHost; + /** Spawn used by the default supervisor; tests replace the real child process. */ + spawnWebChild?: WebSupervisorOptions["spawnChild"]; } function appendDaemonLog(line: string): void { @@ -132,6 +134,7 @@ class Daemon { private inhibitChild: ChildProcess | null = null; // Supervisor of the web console child, created on the first web.ensure. private web?: WebHost; + private readonly spawnWebChild?: WebSupervisorOptions["spawnChild"]; private inhibitExitHookInstalled = false; private inFlightModels = new Map<string, Promise<HarnessModels[]>>(); private inFlightOpenUsageReconciliations = new Map<string, Promise<boolean>>(); @@ -311,6 +314,7 @@ class Daemon { constructor(options: DaemonOptions = {}) { this.web = options.webSupervisor; + this.spawnWebChild = options.spawnWebChild; ensureDirs(); this.db = new Database(); const handle = this.db.getHandle(); @@ -1370,7 +1374,7 @@ class Daemon { case "web.ensure": { const p = (params || {}) as WebEnsureParams; - this.web ??= new WebSupervisor({ log: appendDaemonLog }); + this.web ??= new WebSupervisor({ log: appendDaemonLog, spawnChild: this.spawnWebChild }); try { send({ result: await this.web.ensure(p) }); } catch (error) { diff --git a/src/web/child.ts b/src/web/child.ts index 36eb6db..8544046 100644 --- a/src/web/child.ts +++ b/src/web/child.ts @@ -12,6 +12,8 @@ export interface RunWebChildOptions { listen?: typeof listenWebServer; routes?: () => WebRoute[]; build?: string; + /** Tree the build identity is computed from; defaults to this module's dist root. */ + distRoot?: string; exit?: (code: number) => void; signalTarget?: EventEmitter; } @@ -23,7 +25,7 @@ export interface RunWebChildOptions { */ export async function runWebChild(options: RunWebChildOptions): Promise<void> { const exit = options.exit ?? ((code: number) => process.exit(code)); - const build = options.build ?? computeBuildId(distRootFor(import.meta.url)); + const build = options.build ?? computeBuildId(options.distRoot ?? distRootFor(import.meta.url)); // The daemon may close the pipe after the handshake; a failed write must not crash the child. options.stdout.on("error", () => {}); diff --git a/tests/daemon-web.test.ts b/tests/daemon-web.test.ts index 29aec0a..7147c81 100644 --- a/tests/daemon-web.test.ts +++ b/tests/daemon-web.test.ts @@ -1,7 +1,9 @@ +import fs from "node:fs"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { describe, expect, it, vi } from "vitest"; import { Daemon, type WebHost } from "../src/daemon/daemon.js"; +import { getPaths } from "../src/config/paths.js"; import { WebEnsureError, WebSupervisor, type WebChildProcess } from "../src/daemon/web-supervisor.js"; import { fakeSocket, makeDaemonTestContext, registerDaemonTestHooks, seam, seed } from "./helpers/daemon-seam.js"; @@ -60,6 +62,34 @@ describe("daemon web.ensure", () => { expect(statusAtClose).toBe("working"); }); + it("writes the listening and exit lines to daemon.log without the token and respawns after an exit", async () => { + const children: EventEmitter[] = []; + daemon = new Daemon({ + spawnWebChild: () => { + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + kill: () => true, + }); + children.push(child); + const port = 4100 + children.length; + queueMicrotask(() => child.stdout.write(`${JSON.stringify({ port, token: `secret-${port}`, build: "b1" })}\n`)); + return child as unknown as WebChildProcess; + }, + }); + + expect((await ensure({})).result.port).toBe(4101); + children[0].emit("exit", 3, null); + expect((await ensure({})).result.port).toBe(4102); + + const log = fs.readFileSync(getPaths().daemonLog, "utf8"); + expect(log).toMatch(/\] web listening port=4101\n/); + expect(log).toMatch(/\] web child exited code=3\n/); + expect(log).toMatch(/\] web listening port=4102\n/); + expect(log).not.toContain("secret-"); + expect(children).toHaveLength(2); + }); + it("leaves session rows untouched while restarting the web child", async () => { const children: EventEmitter[] = []; const supervisor = new WebSupervisor({ diff --git a/tests/setup-cli-contract.test.ts b/tests/setup-cli-contract.test.ts index 4f1ee2b..18d40ed 100644 --- a/tests/setup-cli-contract.test.ts +++ b/tests/setup-cli-contract.test.ts @@ -730,6 +730,17 @@ describe("setup web command", () => { ); }); + it("rejects an invalid port without launching", async () => { + const launch = vi.fn(async () => 0); + const stderr = new MemoryWritable(); + + const result = await executeSetupAction(["--port", "abc"], { isTTY: true, launch, stderr }); + + expect(result.code).toBe(1); + expect(stderr.text()).toBe("--port must be a positive integer\n"); + expect(launch).not.toHaveBeenCalled(); + }); + it("returns 1 when the launcher fails", async () => { const result = await executeSetupAction(["--port", "3201"], { isTTY: true, launch: vi.fn(async () => 1) }); diff --git a/tests/usage-cli.test.ts b/tests/usage-cli.test.ts index 72fa8a0..3c5a42b 100644 --- a/tests/usage-cli.test.ts +++ b/tests/usage-cli.test.ts @@ -324,6 +324,16 @@ describe("usage web options", () => { }); }); + it("rejects an invalid --port without launching", async () => { + const launch = vi.fn(async () => 0); + + await runProgramWithDependencies(["--web", "--port", "abc"], { launch }); + + expect(launch).not.toHaveBeenCalled(); + expect(errors).toEqual(["--port must be a positive integer"]); + expect(process.exitCode).toBe(1); + }); + it("forwards a raw polling interval for the page to normalize", async () => { const launch = vi.fn(async () => 0); diff --git a/tests/web-child.test.ts b/tests/web-child.test.ts index 7c7c1a7..d3ee80a 100644 --- a/tests/web-child.test.ts +++ b/tests/web-child.test.ts @@ -1,4 +1,7 @@ +import fs from "node:fs"; import http from "node:http"; +import os from "node:os"; +import path from "node:path"; import { EventEmitter } from "node:events"; import { PassThrough, Writable } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -74,6 +77,18 @@ describe("runWebChild", () => { } }); + it("computes its build identity from its dist tree when none is given", async () => { + const distRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-child-dist-")); + cleanups.push(() => fs.rmSync(distRoot, { recursive: true, force: true })); + fs.mkdirSync(path.join(distRoot, "web")); + fs.writeFileSync(path.join(distRoot, "web", "child.js"), ""); + fs.utimesSync(path.join(distRoot, "web", "child.js"), 4_000, 4_000); + + const { handshake } = await startChild({ build: undefined, distRoot, routes: () => [] }); + + expect(handshake.build).toBe("4000000"); + }); + it("asks for the ephemeral fallback only when no port was given", async () => { const listen = vi.fn(async () => fakeListening()); diff --git a/tests/web-launch.test.ts b/tests/web-launch.test.ts index 7e93d9d..54d3455 100644 --- a/tests/web-launch.test.ts +++ b/tests/web-launch.test.ts @@ -87,7 +87,7 @@ describe("launchWebPage", () => { expect(t.startServer).not.toHaveBeenCalled(); }); - it.each(["UNKNOWN_METHOD", "SERVICE_UNAVAILABLE"])("serves in-process with the full route table after %s", async (code) => { + it.each(["UNKNOWN_METHOD", "SERVICE_UNAVAILABLE", "WEB_START_FAILED", "WEB_BAD_ENTRY"])("serves in-process with the full route table after %s", async (code) => { const t = setup({ ensure: async () => { throw ipcError(code, "nope"); } }); expect(await t.launch({ query: { repo: "/work/my app&co" } })).toBe(0); diff --git a/tests/web-supervisor.test.ts b/tests/web-supervisor.test.ts index 4f22a9f..35b7bd3 100644 --- a/tests/web-supervisor.test.ts +++ b/tests/web-supervisor.test.ts @@ -100,15 +100,18 @@ describe("WebSupervisor.ensure", () => { await expect(pending).rejects.toMatchObject({ code: "WEB_START_FAILED" }); }); - it("kills the child and fails the start when no handshake arrives in time", async () => { + it("kills the child and fails the start when no handshake arrives within 5000 ms", async () => { vi.useFakeTimers(); - const { supervisor, children } = harness({ startTimeoutMs: 5000 }); + const { supervisor, children } = harness(); - const pending = supervisor.ensure({}); - const settled = expect(pending).rejects.toMatchObject({ code: "WEB_START_FAILED" }); - await vi.advanceTimersByTimeAsync(5000); + let outcome: unknown = "pending"; + supervisor.ensure({}).then((value) => { outcome = value; }, (error: unknown) => { outcome = error; }); + await vi.advanceTimersByTimeAsync(4999); + expect(outcome).toBe("pending"); + expect(children[0].signals).toEqual([]); - await settled; + await vi.advanceTimersByTimeAsync(1); + expect(outcome).toMatchObject({ code: "WEB_START_FAILED" }); expect(children[0].signals).toEqual(["SIGKILL"]); }); @@ -192,7 +195,7 @@ describe("WebSupervisor.ensure", () => { it("stops an old build with SIGTERM, escalates to SIGKILL after the stop timeout, and returns the new child", async () => { vi.useFakeTimers(); - const { supervisor, children } = harness({ stopTimeoutMs: 3000 }); + const { supervisor, children } = harness(); const first = supervisor.ensure({ build: "b1" }); children[0].handshake(ok()); await first; @@ -222,6 +225,26 @@ describe("WebSupervisor.ensure", () => { }); }); +describe("WebSupervisor default entry", () => { + it("spawns the web child next to the supervisor module when no entry is given", async () => { + const spawns: string[] = []; + const child = new FakeChild(); + const supervisor = new WebSupervisor({ + log: () => {}, + spawnChild: (entry) => { + spawns.push(entry); + return child as unknown as WebChildProcess; + }, + }); + + const pending = supervisor.ensure({}); + child.handshake(ok()); + await pending; + + expect(spawns).toEqual([path.join(import.meta.dirname, "..", "src", "web", "child.js")]); + }); +}); + describe("WebSupervisor.close", () => { it("sends SIGTERM to the running child and returns without waiting", async () => { const { supervisor, children } = harness(); @@ -259,9 +282,25 @@ describe("spawnWebChild", () => { }); describe("daemon import boundary", () => { - it.each(["src/daemon/web-supervisor.ts", "src/daemon/daemon.ts"])("%s imports nothing from web or cli", (file) => { - const source = fs.readFileSync(path.join(import.meta.dirname, "..", file), "utf8"); - expect(source).not.toMatch(/from\s+["']\.\.\/(web|cli)\//); - expect(source).not.toMatch(/import\(\s*["']\.\.\/(web|cli)\//); + function localImports(file: string): string[] { + const source = fs.readFileSync(file, "utf8"); + const specifiers = [...source.matchAll(/(?:from\s+|import\(\s*)["'](\.{1,2}\/[^"']+)["']/g)].map((match) => match[1]); + return specifiers.map((specifier) => path.resolve(path.dirname(file), specifier.replace(/\.js$/, ".ts"))); + } + + it.each(["src/daemon/web-supervisor.ts", "src/daemon/daemon.ts"])("%s reaches nothing in src/web or src/cli", (file) => { + const root = path.join(import.meta.dirname, ".."); + const seen = new Set<string>(); + const queue = [path.join(root, file)]; + while (queue.length > 0) { + const next = queue.pop()!; + if (seen.has(next) || !fs.existsSync(next)) continue; + seen.add(next); + queue.push(...localImports(next)); + } + + const reached = [...seen].map((module) => path.relative(root, module)); + expect(reached.length).toBeGreaterThan(1); + expect(reached.filter((module) => module.startsWith("src/web/") || module.startsWith("src/cli/"))).toEqual([]); }); }); From 7bddffa2c3fd87b5f77a3f36dc8d375e554ef617 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:55:59 -0300 Subject: [PATCH 19/20] test(web): Pin the CLI build and entry defaults Validation round 2 left four mutants alive: the build and entry launchWebPage sends by default, the child's default build root, and an import-boundary walker that skipped side-effect imports. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/features/web-daemon/tasks.md | 23 ++++++++++++++++++++++- tests/web-child.test.ts | 15 +++++++++++++++ tests/web-launch.test.ts | 25 +++++++++++++++++++++++-- tests/web-supervisor.test.ts | 2 +- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/.specs/features/web-daemon/tasks.md b/.specs/features/web-daemon/tasks.md index 3db6b5f..ef2564d 100644 --- a/.specs/features/web-daemon/tasks.md +++ b/.specs/features/web-daemon/tasks.md @@ -560,6 +560,27 @@ T12 → T13 → T14 → T15 → T16 --- +### T18: Pin the CLI defaults and follow side-effect imports + +**What**: Fix task from validation round 2: tests for the default build and entry `launchWebPage` sends, the child's default `distRootFor` build root, and an import-boundary walker that also follows `import "./x.js"`. +**Where**: `tests/web-launch.test.ts`, `tests/web-child.test.ts`, `tests/web-supervisor.test.ts` +**Depends on**: T17 +**Reuses**: `vi.mock` of `src/daemon/build-id.ts` with the real implementation spied +**Requirement**: WD-12, WD-28, WD-40 + +**Done when**: + +- [x] Surviving mutants M31, M32, M18b, M27 each break a test +- [x] Gate check passes: the three test files above, one run each; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick +**Status**: ✅ Done + +**Commit**: `test(web): Pin the CLI build and entry defaults` + +--- + ## Phase Execution Map ``` @@ -568,7 +589,7 @@ Phase 1 → Phase 2 → Phase 3 → Phase 4 Phase 1: T1 → T2 → T3 → T4 Phase 2: T5 → T6 → T7 Phase 3: T8 → T9 → T10 → T11 -Phase 4: T12 → T13 → T14 → T15 → T16 → T17 +Phase 4: T12 → T13 → T14 → T15 → T16 → T17 → T18 ``` ## Diagram-Definition Cross-Check diff --git a/tests/web-child.test.ts b/tests/web-child.test.ts index d3ee80a..16d7833 100644 --- a/tests/web-child.test.ts +++ b/tests/web-child.test.ts @@ -5,7 +5,13 @@ import path from "node:path"; import { EventEmitter } from "node:events"; import { PassThrough, Writable } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { computeBuildId } from "../src/daemon/build-id.js"; import { runWebChild, type RunWebChildOptions } from "../src/web/child.js"; + +vi.mock("../src/daemon/build-id.js", async (importOriginal) => { + const actual = await importOriginal<typeof import("../src/daemon/build-id.js")>(); + return { ...actual, computeBuildId: vi.fn(actual.computeBuildId) }; +}); import type { ListeningWebServer, WebRoute } from "../src/web/server.js"; interface Handshake { port: number; token: string; build: string } @@ -89,6 +95,15 @@ describe("runWebChild", () => { expect(handshake.build).toBe("4000000"); }); + it("computes its build identity from its own dist root by default", async () => { + vi.mocked(computeBuildId).mockReturnValueOnce("own-tree"); + + const { handshake } = await startChild({ build: undefined, routes: () => [] }); + + expect(computeBuildId).toHaveBeenLastCalledWith(path.join(import.meta.dirname, "..", "src")); + expect(handshake.build).toBe("own-tree"); + }); + it("asks for the ephemeral fallback only when no port was given", async () => { const listen = vi.fn(async () => fakeListening()); diff --git a/tests/web-launch.test.ts b/tests/web-launch.test.ts index 54d3455..9a9d564 100644 --- a/tests/web-launch.test.ts +++ b/tests/web-launch.test.ts @@ -1,7 +1,18 @@ +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { computeBuildId } from "../src/daemon/build-id.js"; import { launchWebPage, type LaunchWebPageDependencies, type LaunchWebPageOptions } from "../src/cli/web-launch.js"; import type { WebServerHandle } from "../src/web/server.js"; +// Keep the real implementation but record calls; the default-build test stubs one call +// so it does not walk the whole repository. +vi.mock("../src/daemon/build-id.js", async (importOriginal) => { + const actual = await importOriginal<typeof import("../src/daemon/build-id.js")>(); + return { ...actual, computeBuildId: vi.fn(actual.computeBuildId) }; +}); + +const REPO_ROOT = path.join(import.meta.dirname, ".."); + const BASE = { baseUrl: "http://127.0.0.1:3100", port: 3100, token: "tok" }; function ipcError(code: string, message: string, details?: unknown): Error { @@ -29,7 +40,7 @@ function setup(overrides: { }; const launch = (options: Partial<LaunchWebPageOptions> = {}) => launchWebPage({ path: "/review", query: { repo: "/work/app" }, title: "CodeDeck review", open: true, ...options }, deps); - return { launch, request, ensureDaemonStarted, openBrowser, startServer, logs, errors }; + return { launch, deps, request, ensureDaemonStarted, openBrowser, startServer, logs, errors }; } describe("launchWebPage", () => { @@ -45,10 +56,20 @@ describe("launchWebPage", () => { const [method, params] = t.request.mock.calls[0] as [string, { build: string; entry: string; port?: number }]; expect(method).toBe("web.ensure"); expect(params.build).toBe("build-1"); - expect(params.entry).toMatch(/^\/.*\/web\/child\.js$/); + expect(params.entry).toBe(path.join(REPO_ROOT, "src", "web", "child.js")); expect(t.startServer).not.toHaveBeenCalled(); }); + it("sends the build id of its own dist root when none is injected", async () => { + const t = setup(); + vi.mocked(computeBuildId).mockReturnValueOnce("tree-build"); + + await launchWebPage({ path: "/", title: "CodeDeck UI", open: false }, { ...t.deps, build: undefined }); + + expect(computeBuildId).toHaveBeenLastCalledWith(path.join(REPO_ROOT, "src")); + expect(t.request.mock.calls[0][1]).toMatchObject({ build: "tree-build" }); + }); + it("prints the URL without opening a browser when open is false", async () => { const t = setup(); diff --git a/tests/web-supervisor.test.ts b/tests/web-supervisor.test.ts index 35b7bd3..9253a68 100644 --- a/tests/web-supervisor.test.ts +++ b/tests/web-supervisor.test.ts @@ -284,7 +284,7 @@ describe("spawnWebChild", () => { describe("daemon import boundary", () => { function localImports(file: string): string[] { const source = fs.readFileSync(file, "utf8"); - const specifiers = [...source.matchAll(/(?:from\s+|import\(\s*)["'](\.{1,2}\/[^"']+)["']/g)].map((match) => match[1]); + const specifiers = [...source.matchAll(/(?:from\s+|import\s*\(?\s*)["'](\.{1,2}\/[^"']+)["']/g)].map((match) => match[1]); return specifiers.map((specifier) => path.resolve(path.dirname(file), specifier.replace(/\.js$/, ".ts"))); } From 97da7a1e9587d2bd1e5cba0ca7972a6e70b1508c Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:59:13 -0300 Subject: [PATCH 20/20] docs(specs): Record the web-daemon validation and lessons The verifier passed round 3: 50 of 50 acceptance criteria have discriminating evidence and all 19 injected mutants break a test. Co-Authored-By: Claude <noreply@anthropic.com> --- .specs/LESSONS.md | 51 ++++++ .specs/features/web-daemon/validation.md | 212 +++++++++++++++++++++++ .specs/lessons.json | 100 +++++++++++ 3 files changed, 363 insertions(+) create mode 100644 .specs/LESSONS.md create mode 100644 .specs/features/web-daemon/validation.md create mode 100644 .specs/lessons.json diff --git a/.specs/LESSONS.md b/.specs/LESSONS.md new file mode 100644 index 0000000..4a5eb34 --- /dev/null +++ b/.specs/LESSONS.md @@ -0,0 +1,51 @@ +# LESSONS - auto-maintained by scripts/lessons.py + +> Machine-owned. Do NOT hand-edit. Changes are overwritten on the next `lessons.py` write. +> Canonical state lives in `.specs/lessons.json`. Edit lessons only via the script. +> promote_threshold=2 distinct features · window_days=45 · quarantine_threshold=2 + +## Confirmed (load these at Specify/Design) + +Corroborated across multiple features. Safe to apply as guidance. + +_none_ + +## Candidates (under observation - do NOT load as guidance yet) + +Seen once or not yet corroborated. Tracked, not trusted. + +### L-001 - Test a spec-defined timeout against the default constant, not an injected copy of the value, and assert nothing fires one tick before the deadline +- signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `timers` · harmful: 0 +- features: web-daemon +- evidence: validation.md M6, M7, M21 (src/daemon/web-supervisor.ts:14, :15, :169) (timers) +- last seen: 2026-09-25T00:47:48Z + +### L-002 - When tests inject a dependency, keep at least one test that omits the injection so the production default is exercised +- signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `seams` · harmful: 0 +- features: web-daemon +- evidence: validation.md M8, M18, M22 (src/daemon/daemon.ts:1373, src/web/child.ts:26, src/daemon/web-supervisor.ts:94) (seams) (+1 more) +- last seen: 2026-09-25T00:53:07Z + +### L-003 - List every error code the spec names as a trigger in the test table instead of a representative subset +- signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `error-mapping` · harmful: 0 +- features: web-daemon +- evidence: validation.md M10 (src/cli/web-launch.ts:53, WD-44) (error-mapping) +- last seen: 2026-09-25T00:47:48Z + +### L-004 - Assert the exact expected path instead of a pattern that a wrong path also matches +- signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `assertions` · harmful: 0 +- features: web-daemon +- evidence: validation.md round 2 M32 (tests/web-launch.test.ts:48) (assertions) +- last seen: 2026-09-25T00:53:08Z + +### L-005 - When a test parses source imports, cover static from, dynamic import() and bare side-effect imports +- signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `import-boundary` · harmful: 0 +- features: web-daemon +- evidence: validation.md round 2 M27 (tests/web-supervisor.test.ts:287) (import-boundary) +- last seen: 2026-09-25T00:53:08Z + +## Quarantined (failed when applied - ignore) + +A confirmed lesson that recurred alongside failure. Kept for the maintainer to review. + +_none_ diff --git a/.specs/features/web-daemon/validation.md b/.specs/features/web-daemon/validation.md new file mode 100644 index 0000000..1bc6bf5 --- /dev/null +++ b/.specs/features/web-daemon/validation.md @@ -0,0 +1,212 @@ +# web-daemon validation report (round 3) + +**Date**: 2026-09-24 +**Spec**: `.specs/features/web-daemon/spec.md` +**Diff range**: `main..feat/web-daemon` (73dc835..7bddffa, 19 commits; round 3 re-verifies fix commit 7bddffa, T18) +**Verifier**: independent sub-agent (author is not the verifier) + +**Result**: PASS. All 50 ACs have discriminating evidence. Round 3 injected 19 mutants: the 4 round-2 survivors (M18b, M27, M31, M32), 4 new variants aimed at the new tests, and the 11 round-2 kills re-run. All 19 were killed. A re-run sample of 8 round-1 mutants was also killed. + +7bddffa changes only tests and `tasks.md`. `src/` is identical to round 2 (`git diff ffeaf8a..HEAD --stat -- src/` is empty), so the behavior verified in rounds 1 and 2 stands. + +--- + +## Task completion + +| Task | Status | Notes | +| --- | --- | --- | +| T1 to T16 | Done | Feature tasks. | +| T17 | Done | Round-1 fix task (ffeaf8a). | +| T18 | Done | Round-2 fix task (7bddffa). Its Done-when (M18b, M27, M31, M32 each break a test) is confirmed below. | + +--- + +## Gap history + +| Gap | Round found | Fixed in | Round-3 evidence | +| --- | --- | --- | --- | +| WD-44 `WEB_START_FAILED` fallback (M10) | 1 | ffeaf8a | `tests/web-launch.test.ts:111` | +| WD-07/WD-11 daemon.log wiring (M8) | 1 | ffeaf8a | `tests/daemon-web.test.ts:86`-`:90` | +| WD-06 5000 ms default and both edges (M6, M21) | 1 | ffeaf8a | `tests/web-supervisor.test.ts:110`, `:115` | +| WD-41 3000 ms default (M7) | 1 | ffeaf8a | `tests/web-supervisor.test.ts:205`, `:213` | +| WD-40 child build computation (M18) | 1 | ffeaf8a | `tests/web-child.test.ts:95` | +| WD-01 default entry (M22) | 1 | ffeaf8a | `tests/web-supervisor.test.ts:244` | +| WD-12 CLI default entry (M32) | 2 | 7bddffa | `tests/web-launch.test.ts:59` - `params.entry toBe(path.join(REPO_ROOT, "src", "web", "child.js"))` | +| WD-12 CLI default build (M31) | 2 | 7bddffa | `tests/web-launch.test.ts:69`/`:70` - `computeBuildId toHaveBeenLastCalledWith(path.join(REPO_ROOT, "src"))`, `build: "tree-build"` | +| WD-40 child default dist root (M18b) | 2 | 7bddffa | `tests/web-child.test.ts:103`/`:104` - called with `<repo>/src`, handshake `build toBe("own-tree")` | +| WD-28 bare side-effect imports (M27) | 2 | 7bddffa | `tests/web-supervisor.test.ts:287` regex now matches `import "..."`; `:304` | + +Correction to the round-2 report: its gap 3 said the child's default dist root resolves to the repo root. From `src/web/child.ts`, `distRootFor` (two directories above the module file) gives `<repo>/src`, and `<dist>` in a build. The T18 tests assert `<repo>/src`, which is correct. + +--- + +## Spec-anchored acceptance criteria + +PASS = the assertion targets the spec outcome and a mutant (or direct reading) shows it discriminates. NOTE = covered, with a caveat that does not fail the AC. + +### P1: Daemon supervises one web server + +| AC | Spec-defined outcome | Evidence (`file:line` - assertion) | Result | +| --- | --- | --- | --- | +| WD-01 | spawn from `entry`, or own `dist/web/child.js`; return `{ baseUrl, port, token }` | `tests/web-supervisor.test.ts:61`, `:151`, `:244` | PASS (M22 killed) | +| WD-02 | same entry+build reuses, no spawn | `tests/web-supervisor.test.ts:74`, `:77` | PASS (M1 killed) | +| WD-03 | concurrent requests share one spawn | `tests/web-supervisor.test.ts:73`, `:77` | PASS (M3 killed) | +| WD-04 | default 3100 busy: OS port | `tests/web-child.test.ts:113`; `tests/web-server.test.ts:258`; `tests/web-supervisor.test.ts:62` | PASS (composition) | +| WD-05 | `WEB_LISTEN_FAILED`, message, `details { port }` | `tests/web-child.test.ts:127`; `tests/web-supervisor.test.ts:86`-`:89`; `tests/daemon-web.test.ts:48` | PASS (M5, M9 killed) | +| WD-06 | exit, 5000 ms timeout, invalid line: kill + `WEB_START_FAILED` | `tests/web-supervisor.test.ts:100`, `:110`/`:111`/`:115`, `:139`/`:140` | PASS (M4, M6, M21, M29 killed) | +| WD-07 | exit after handshake: stopped, `web child exited code=<code>` in daemon log, respawn | `tests/daemon-web.test.ts:83`, `:87`, `:90`; `tests/web-supervisor.test.ts:192`/`:193` | PASS (M8, M20 killed) | +| WD-08 | child serves the `createUiRoutes` table | `tests/web-child.test.ts:82`; `tests/web-cli.test.ts:178`-`:204` | PASS | +| WD-09 | stdin end or SIGTERM: stop, close connections, exit 0 | `tests/web-child.test.ts:152`/`:153`/`:154` | PASS (M17 killed) | +| WD-10 | shutdown sends SIGTERM without waiting | `tests/daemon-web.test.ts:61`/`:62`; `tests/web-supervisor.test.ts:256`/`:257` | PASS (M23 killed) | +| WD-11 | `web listening port=<port>` in daemon log, no token | `tests/daemon-web.test.ts:86`/`:88`/`:89`; `tests/web-supervisor.test.ts:223`/`:224` | PASS (M8 killed) | + +### P1: Web commands delegate to the daemon + +| AC | Spec-defined outcome | Evidence | Result | +| --- | --- | --- | --- | +| WD-12 | `ui` ensures daemon, calls `web.ensure` with its local build and entry, opens `/?t=` | `tests/web-cli.test.ts:94`; `tests/web-launch.test.ts:57`-`:59`, `:69`/`:70` | PASS (M31, M31b, M32, M32b killed) | +| WD-13 | `/review?repo=<cwd>&t=` | `tests/review-command.test.ts:42`; `tests/web-launch.test.ts:52`/`:54` | PASS | +| WD-14 | `/setup?t=` without a TTY | `tests/setup-cli-contract.test.ts:708`-`:719` | PASS | +| WD-15 | `/setup?refresh=1&t=` | `tests/setup-cli-contract.test.ts:727`/`:728` | PASS | +| WD-16 | usage filters, `by`, `interval`, no empty keys | `tests/usage-cli.test.ts:318`; `tests/web-cli.test.ts:239` | PASS | +| WD-17 | `<title> on <url>`, exit 0 | `tests/web-launch.test.ts:53`/`:55` | PASS | +| WD-18 | `--no-open` | `tests/web-launch.test.ts:76`/`:78`/`:79` | PASS | +| WD-19 | `Could not open a browser, visit <url> manually.` | `tests/web-launch.test.ts:85`/`:87` | PASS | +| WD-20 | explicit port sent, omitted not sent | `tests/web-launch.test.ts:96`/`:97`; `tests/web-cli.test.ts:105` | PASS | +| WD-21 | `CodeDeck web is already running on port <port>` first | `tests/web-launch.test.ts:98`/`:99` | PASS (M12 killed) | +| WD-22 | `Failed to listen on 127.0.0.1:<port>: <message>`, exit 1 | `tests/web-launch.test.ts:105`/`:107`/`:108` | PASS | +| WD-23 | invalid `--port`: error, exit 1, no IPC | `tests/web-cli.test.ts:116`-`:118`; `tests/review-command.test.ts:61`/`:62`; `tests/setup-cli-contract.test.ts:740`; `tests/usage-cli.test.ts:333` | PASS | + +### P1: Web failures stay isolated + +| AC | Spec-defined outcome | Evidence | Result | +| --- | --- | --- | --- | +| WD-24 | sync throw: 500 `{ error }` | `tests/web-server.test.ts:196`/`:197` | PASS | +| WD-25 | rejection: 500 `{ error }` | `tests/web-server.test.ts:203`/`:204` | PASS (M24 killed) | +| WD-26 | late failure: end, keep serving | `tests/web-server.test.ts:211`, `:214` | PASS | +| WD-27 | setup handlers return promises | `tests/setup-web.test.ts:280`/`:281`/`:283` | PASS (M19 killed) | +| WD-28 | daemon never imports `src/web/server.ts` or opens HTTP | `tests/web-supervisor.test.ts:287`, `:291`-`:304` | PASS (M27, M27b, M28 killed). The only listener in the daemon is the Unix socket at `src/daemon/daemon.ts:356`. | + +### P1: Long-lived server authentication + +| AC | Spec-defined outcome | Evidence | Result | +| --- | --- | --- | --- | +| WD-29 | API without matching cookie: 403 `forbidden` before the handler | `tests/web-security.test.ts:175` | PASS (M13, M14 killed) | +| WD-30 | page without credentials: 403 `open this page with codedeck ui` | `tests/web-security.test.ts:140`, `:150` | PASS | +| WD-31 | valid `t`: cookie + 303 keeping the query | `tests/web-security.test.ts:115`/`:117`, `:130`/`:131` | PASS | +| WD-32 | Host and POST Origin checks kept | `tests/web-security.test.ts:96`, `:188`-`:198` | PASS | + +### P1: Review reads the requested repository + +| AC | Spec-defined outcome | Evidence | Result | +| --- | --- | --- | --- | +| WD-33 | load from `repo` | `tests/review.test.ts:229` | PASS | +| WD-34 | 400 `repo query parameter is required` | `tests/review.test.ts:249` | PASS | +| WD-35 | 400 `repo must be an absolute path` | `tests/review.test.ts:256` | PASS (M15 killed) | +| WD-36 | 404 with the git error | `tests/review.test.ts:241`/`:242` | PASS | +| WD-37 | page forwards `repo` | `tests/review.test.ts:310` | PASS | +| WD-38 | no `repo`: message, no request | `tests/review.test.ts:316`/`:317` | PASS | +| WD-39 | draft key includes `repo` | `tests/review.test.ts:326`/`:328` | PASS (M16 killed) | + +### P2: Web child restarts on build change + +| AC | Spec-defined outcome | Evidence | Result | +| --- | --- | --- | --- | +| WD-40 | child computes its build at start (newest `.js` mtime under the dist root) and sends it | `tests/web-child.test.ts:95`, `:103`/`:104`; `tests/build-id.test.ts:32`/`:37`/`:42` | PASS (M18, M18b, M18c killed) | +| WD-41 | SIGTERM, 3000 ms, SIGKILL, spawn, new result | `tests/web-supervisor.test.ts:205`/`:206`/`:212`/`:213`, `:176`-`:178` | PASS (M2, M7, M30 killed) | +| WD-42 | no `build`: reuse | `tests/web-supervisor.test.ts:75`/`:76` | PASS (M1 killed) | +| WD-43 | sessions untouched | `tests/daemon-web.test.ts:119` | PASS (NOTE: row equality only; the session process is not observable in the test) | + +### P2: In-process fallback + +| AC | Spec-defined outcome | Evidence | Result | +| --- | --- | --- | --- | +| WD-44 | any non-listen error: notice, full routes, URLSearchParams query, ephemeral fallback | `tests/web-launch.test.ts:111`, `:116`, `:118`-`:126`, `:135`-`:137` | PASS (M10, M11 killed) | +| WD-45 | daemon start failure notice | `tests/web-launch.test.ts:145`-`:147` | PASS | +| WD-46 | in-process keeps running until SIGINT/SIGTERM | `tests/web-launch.test.ts:143`/`:147`; `tests/web-server.test.ts:133` | PASS (NOTE: compositional) | + +### Edge cases + +| AC | Spec-defined outcome | Evidence | Result | +| --- | --- | --- | --- | +| WD-47 | `interval` with the existing normalization | `tests/usage-web.test.ts:96` | PASS (M25 killed) | +| WD-48 | one `POST /api/setup/catalog/refresh` | `tests/setup-page.test.ts:609`, `:619` | PASS | +| WD-49 | `WEB_BAD_ENTRY`, nothing spawned | `tests/web-supervisor.test.ts:161`, `:162` | PASS | +| WD-50 | stderr appended to `logs/web-child.log` | `tests/web-supervisor.test.ts:277` | PASS | + +**Status**: all 50 ACs covered with discriminating evidence. No spec-precision gap. Three NOTEs (WD-43, WD-46, WD-04 by composition) do not fail their ACs. + +--- + +## Discrimination sensor + +Scratch: a detached worktree at `<scratchpad>/verify-wt` on HEAD (a290498 in round 1, ffeaf8a in round 2, 7bddffa in round 3), with `node_modules` symlinked. Each mutation is an exact-string replace guarded by a count of 1 (the two append mutants add a line at the end of `src/config/paths.ts`). Each run uses one test file per vitest invocation, restores the file after the mutant, and removes the worktree with `git worktree remove --force`. After each round the real worktree's `git status --porcelain` matched its baseline: clean in round 1, and in rounds 2 and 3 only this report and the `.specs` lessons files, untracked. + +### Round 3 (HEAD 7bddffa) + +| # | File:line | Fault | Killed? | +| --- | --- | --- | --- | +| M31 | `src/cli/web-launch.ts:45` | CLI default build replaced by `"0"` | Killed (`web-launch` "sends the build id of its own dist root...") | +| M31b | `src/cli/web-launch.ts:45` | CLI build computed over the module's own directory (one level too shallow) | Killed (same test) | +| M32 | `src/cli/web-launch.ts:46` | CLI default entry `../../web/child.js` | Killed (`web-launch` "opens the daemon page...") | +| M32b | `src/cli/web-launch.ts:46` | CLI default entry `../daemon/daemon.js` | Killed (same test) | +| M18b | `src/web/child.ts:28` | default dist root replaced by a fixed path | Killed (`web-child` "computes its build identity from its own dist root by default") | +| M18c | `src/web/child.ts:28` | child ignores the `distRoot` seam | Killed (`web-child` "computes its build identity from its dist tree...") | +| M27 | `src/daemon/web-supervisor.ts:1` | bare `import "../web/server.js"` in the supervisor | Killed (`web-supervisor` import boundary, both entries) | +| M27b | `src/config/paths.ts` (end) | bare `import "../web/server.js"` in a transitively reached module | Killed (same, both entries) | +| M6, M7, M8, M10, M18, M21, M22, M26, M28, M29, M30 | as in round 2 | round-2 kills re-run | All killed | + +Round-1 sample re-run at 7bddffa: M1, M3, M5, M9, M13, M16, M17, M23, all killed. + +**Round 3 tally**: 19 injected, 19 killed, 0 survived (plus 8 of 8 in the round-1 sample). +**Cumulative**: round 1 had 25 injected with 18 killed; round 2 had 15 with 11 killed; round 3 had 19 with 19 killed. Every survivor from an earlier round is now killed. +**Sensor depth**: expanded (auth and process supervision are critical paths). + +--- + +## Code quality + +| Check | Status | +| --- | --- | +| Minimum code, no scope creep | OK. The fix rounds added two one-field seams (`DaemonOptions.spawnWebChild`, `RunWebChildOptions.distRoot`) and otherwise touched only tests. | +| Surgical changes | OK. | +| Matches patterns | OK. The `vi.mock` partial mock keeps the real `computeBuildId` and stubs a single call, so tests do not walk the repo tree. | +| Spec-anchored outcome check | All ACs match the spec outcome. | +| Every test maps to a requirement | OK. Fix-round tests map to WD ids via T17 and T18. | +| Guidelines | `CLAUDE.md` (scoped vitest runs, Conventional Commits). Followed. | + +Observations that are not gaps: + +- The wait after SIGKILL (`src/daemon/web-supervisor.ts:139`) caps the worst case for one `web.ensure` at 11 s, which stays under the 15 s IPC timeout (`src/daemon/ipc.ts:71`). +- While a start is in flight, a request with a different entry or build receives that in-flight start (`src/daemon/web-supervisor.ts:105`). The spec does not cover this case, and the design accepts it. +- A non-`WebEnsureError` throw is mapped to `WEB_START_FAILED` (`src/daemon/daemon.ts:1384`) and has no test. It is not a spec requirement. + +--- + +## Gate check + +- **Typecheck**: `npx tsc --noEmit` exit 0 at 7bddffa. +- **Round 3 runs** (one vitest per file): web-launch 12, web-child 8, web-supervisor 21, daemon-web 5, web-cli 10, build-id 3, all passed. +- **Unchanged since round 2** (`src/` identical, test files untouched by 7bddffa): web-security 8, web-server 13, setup-web 18, setup-page 19, review 21, review-command 7, usage-web 17, usage-cli 25, setup-cli-contract 38, setup-wizard 83, power-shutdown 11, all passed in round 2. +- **Total**: 318 tests across 17 files, 0 failed, 0 skipped. The count went from 310 in round 1 to 316 in round 2 and 318 in round 3. No test was removed. + +--- + +## Requirement traceability update + +| Requirement | Round 2 | Round 3 | +| --- | --- | --- | +| WD-12, WD-28, WD-40 | Needs fix (tests) | Verified | +| All other WD ids | Verified | Verified | + +--- + +## Summary + +**Overall**: ready. The behavior matches the spec, and every spec-defined value (the timeouts, error codes, log lines, messages, defaults and the import boundary) is pinned by a test that a fault breaks. + +**Spec-anchored check**: 50/50 ACs matched the spec outcome. +**Sensor (round 3)**: 19/19 killed. +**Gate**: 318 passed, tsc clean. + +**Next step**: the orchestrator commits this report (and `.specs/lessons.json` and `.specs/LESSONS.md` from rounds 1 and 2). diff --git a/.specs/lessons.json b/.specs/lessons.json new file mode 100644 index 0000000..2d80c17 --- /dev/null +++ b/.specs/lessons.json @@ -0,0 +1,100 @@ +{ + "schema": 1, + "promote_threshold": 2, + "window_days": 45, + "quarantine_threshold": 2, + "next_id": 6, + "lessons": [ + { + "id": "L-001", + "key": "surviving_mutant::test a spec defined timeout against the default constant not an injected copy of the value and assert nothing fires one tick before the deadline", + "text": "Test a spec-defined timeout against the default constant, not an injected copy of the value, and assert nothing fires one tick before the deadline", + "signal": "surviving_mutant", + "scope": "timers", + "status": "candidate", + "features": [ + "web-daemon" + ], + "recurrence": 1, + "harmful": 0, + "evidence": [ + "validation.md M6, M7, M21 (src/daemon/web-supervisor.ts:14, :15, :169) (timers)" + ], + "created": "2026-09-25T00:47:48Z", + "last_seen": "2026-09-25T00:47:48Z" + }, + { + "id": "L-002", + "key": "surviving_mutant::when tests inject a dependency keep at least one test that omits the injection so the production default is exercised", + "text": "When tests inject a dependency, keep at least one test that omits the injection so the production default is exercised", + "signal": "surviving_mutant", + "scope": "seams", + "status": "candidate", + "features": [ + "web-daemon" + ], + "recurrence": 1, + "harmful": 0, + "evidence": [ + "validation.md M8, M18, M22 (src/daemon/daemon.ts:1373, src/web/child.ts:26, src/daemon/web-supervisor.ts:94) (seams)", + "validation.md round 2 M18b, M31 (src/web/child.ts:28, src/cli/web-launch.ts:45) (seams)" + ], + "created": "2026-09-25T00:47:48Z", + "last_seen": "2026-09-25T00:53:07Z" + }, + { + "id": "L-003", + "key": "surviving_mutant::list every error code the spec names as a trigger in the test table instead of a representative subset", + "text": "List every error code the spec names as a trigger in the test table instead of a representative subset", + "signal": "surviving_mutant", + "scope": "error-mapping", + "status": "candidate", + "features": [ + "web-daemon" + ], + "recurrence": 1, + "harmful": 0, + "evidence": [ + "validation.md M10 (src/cli/web-launch.ts:53, WD-44) (error-mapping)" + ], + "created": "2026-09-25T00:47:48Z", + "last_seen": "2026-09-25T00:47:48Z" + }, + { + "id": "L-004", + "key": "surviving_mutant::assert the exact expected path instead of a pattern that a wrong path also matches", + "text": "Assert the exact expected path instead of a pattern that a wrong path also matches", + "signal": "surviving_mutant", + "scope": "assertions", + "status": "candidate", + "features": [ + "web-daemon" + ], + "recurrence": 1, + "harmful": 0, + "evidence": [ + "validation.md round 2 M32 (tests/web-launch.test.ts:48) (assertions)" + ], + "created": "2026-09-25T00:53:08Z", + "last_seen": "2026-09-25T00:53:08Z" + }, + { + "id": "L-005", + "key": "surviving_mutant::when a test parses source imports cover static from dynamic import and bare side effect imports", + "text": "When a test parses source imports, cover static from, dynamic import() and bare side-effect imports", + "signal": "surviving_mutant", + "scope": "import-boundary", + "status": "candidate", + "features": [ + "web-daemon" + ], + "recurrence": 1, + "harmful": 0, + "evidence": [ + "validation.md round 2 M27 (tests/web-supervisor.test.ts:287) (import-boundary)" + ], + "created": "2026-09-25T00:53:08Z", + "last_seen": "2026-09-25T00:53:08Z" + } + ] +}