diff --git a/.specs/features/web-console/run-notes.md b/.specs/features/web-console/run-notes.md new file mode 100644 index 0000000..9d31408 --- /dev/null +++ b/.specs/features/web-console/run-notes.md @@ -0,0 +1,87 @@ +# web-console run notes + +Append-only. One entry per decision, blocker, or event. + +## 2026-09-23 00:51 -03, entry 1: autonomous mode activated + +- decision: the human invoked `/codedeck:autonomous` during wave 2 (workers f44d and ea84 running). No human questions from here on. +- state at activation: PR #108 (spec) merged as 35555b6. PR #111 (T1-T9, T14) merged as afacb22 after green CI. Waves in flight: f44d (T10, T11 setup page and routes) and ea84 (T15, T16 usage route and page). Remaining after them: T12, T13, T17, T18, T19. + +## entry 2: publishing web-console slices stays authorized + +- decision: keep pushing, opening PRs, and squash-merging web-console slices once CI is green. +- category: bucket 2 (publishing), taken under the human's prior explicit instruction in this session: "se tudo passar bora ja mergear e continuar". +- reason: the human authorized merge and continue before activation. Anything outside the web-console slices stays deferred. + +## entry 3: decisions made before activation, recorded for the report + +- `codedeck setup` without a TTY keeps exit 1. Bucket 1 (spec text, reversible). Reason: an agent calling setup must not block on an HTTP server, and the existing tests stay valid. +- off-catalog models typed by hand save through the web only with an explicit per-role confirmation. Bucket 1. Reason: mirrors the wizard's second-Enter confirm. +- T8 started in parallel with T7, even though tasks.md lists T8 after T7. Bucket 1. Reason: no shared file or code dependency. +- wave 2 page tests go in `tests/setup-page.test.ts` and `tests/usage-page.test.ts` instead of `tests/web-pages.test.ts`. Bucket 1. Reason: two parallel workers would edit one file. The coverage matrix is updated at integration. + +## entry 4: dependency installs in worktrees + +- event: workers c301, b177, 18b3 (and likely f44d, ea84) ran `npm ci` from the lockfile in their worktrees, because worktrees have no node_modules. This happened before activation. +- category: bucket 2 (installing dependencies). It no longer happens in future briefings. +- decision going forward: new briefings symlink `node_modules` from the main checkout (`/home/andreello/dev/codedeck/node_modules`) instead of installing. + +## entry 5: deferred issue for codedeck diff --stat + +- finding: `codedeck diff --stat` omits untracked new files. For c301 it showed 2 files and hid `src/core/usage-query.ts`, the task's main file. +- category: bucket 2 (opening a GitHub issue is a network write). Deferred. The evidence goes in the report. +- workaround in this run: verify each slice with a `git status --short` on its worktree, not only the stat. + +## entry 6: issue #109 opened before activation + +- the human requested it: `session.create` fails on 4-char session id collisions (seen as "UNIQUE constraint failed: sessions.id" while dispatching wave 1). The fix is not part of this run. + +## entry 7: slice ea84 (T15, T16) accepted + +- evidence: 4 new files only (`src/web/usage-page.ts`, `src/web/usage-routes.ts`, `tests/usage-page.test.ts`, `tests/usage-web.test.ts`). Orchestrator reran `npx vitest run tests/usage-web.test.ts tests/usage-page.test.ts tests/usage-cli.test.ts tests/web-server.test.ts tests/web-security.test.ts`: 5 files, 54 tests passed. `tsc --noEmit` exit 0. +- probes: worker: drop agent filter (killed, 2), clear last good result on error (killed, 1). Orchestrator: remove `?? []` for missing byOrigin (killed, 2, including the node:vm test). +- commit on worker branch: "feat(web): Add the usage page and usage query route". + +## entry 8: slice f44d (T10, T11) accepted + +- evidence: 4 new files only (`src/web/setup-page.ts`, `src/web/setup-routes.ts`, `tests/setup-page.test.ts`, `tests/setup-web.test.ts`). Orchestrator reran `npx vitest run tests/setup-page.test.ts tests/setup-web.test.ts tests/setup-plan.test.ts tests/web-security.test.ts tests/web-server.test.ts`: 5 files, 49 tests passed. `tsc --noEmit` exit 0. +- probes: worker: dry-run saves config (killed), apply accepts an off-catalog changed binding without confirmation (killed). Orchestrator: bypass the invalid-config guard `setupReadProblem` (killed; the test also asserts apply returns code 14 with saved=false, `tests/setup-web.test.ts:462,469`). +- the config read goes through the batch `SetupConfigRead` path, not `loadConfig`, so a corrupt config is never replaced by defaults. + +## entry 9: wave 2 is not published on its own + +- decision: no separate PR for T10, T11, T15, T16. They are unwired code until T12, T13, T17, T18 land. One PR covers waves 2 and 3 on `feat/web-console-pages`. Bucket 1. +- dispatched e041 for T12, T13, T17, T18, T19 plus removing the duplicate Host check in `dispatchRequest`. It symlinks node_modules instead of running `npm ci`. + +## entry 10: slice e041 (T12, T13, T17, T18, T19) accepted + +- evidence: worker batches passed (67, 122, 56 tests) and `tsc` was silent. The worker reported no commit and no drift outside the briefing. The orchestrator committed its work as d9b5e15 "feat(web): Open setup and usage in the web console". +- probes: worker: setup without a TTY no longer exits 1 (killed, `expected +0 to be 1`), `usage --web` skips `usage.get` (killed). Orchestrator: drop the setup routes from `createUiRoutes` (killed, 2 tests in `tests/web-cli.test.ts`), restored and confirmed with `cmp`. +- known leftovers, bucket 1, left as is: `setup --refresh` wraps the `/setup` response to call `setupPage.refreshCatalog()` after the page is ready; removing the duplicate Host check in `dispatchRequest` means a bad Host with a malformed URL now answers 400 instead of 403 (the request is still refused). + +## entry 11: integration of feat/web-console-pages + +- integration batches on d9b5e15, one at a time: 67 tests (5 files), 122 (4), 56 (6), 126 (4: usage, usage-query, open-args, statusline). `npm run build` exit 0. Updated gates after the coverage matrix change: P4 71 tests (4 files), P5 plus web-pages 58 tests (6 files). +- the build in the main checkout also updates the installed `codedeck`, because `~/.run-agent/bin/codedeck` runs `dist/cli/index.js`. The running daemon was not restarted. + +## entry 12: smoke test of the built `codedeck ui` + +- `node dist/cli/index.js ui --no-open --port 3197`, checked with curl: the token URL answers 303 with `Set-Cookie: codedeck_ui_token_3197` (HttpOnly, SameSite=Strict) and `Location: /`. The home links `/review`, `/setup`, `/usage`. `/review`, `/setup`, `/usage`, `/api/setup/state`, `/api/setup/catalog`, `/api/usage?period=today` answer 200 with the cookie. Foreign Host: 403. POST without the cookie: 403. POST with the cookie and a foreign Origin: 403. POST `{}`: 400 "invalid shape". POST of the current state as a no-change selection to `/api/setup/dry-run`: 200 `unchanged`, `saved:false`, and the config file sha256 is unchanged. `frame-ancestors 'none'` is present on pages. Server stopped afterwards. +- coverage matrix and spec status updated: 66 traceability rows moved to Implemented, T10 and T16 point at `tests/setup-page.test.ts` and `tests/usage-page.test.ts`. `validate_spec` 0 errors, 0 warnings. `validate_tasks` 0 errors, 1 warning (T19 Tests none, which matches the Documentation row marked none). +- decision: open one PR for waves 2 and 3 and run the final read-only reviewer while CI runs. Merge only after review findings are handled and CI is green. Bucket 2 (publishing), under entry 2. + +## entry 13: final review 52db and remediation + +- PR #112 opened for feat/web-console-pages. Reviewer 52db (claude opus, read-only, worktree at f3e3597) ran 5 files, 66 tests passed. +- finding 1, blocker, confirmed by the orchestrator: `refreshCatalog` in `src/web/setup-page.ts` called `loadJson` without init, so the browser sent GET to the POST-only `/api/setup/catalog/refresh`. The page showed a JSON parse error and `setup --refresh` did nothing. The page test stub ignored the method. Fix 5320af3: `loadJson(path, init?)`, refresh passes `{ method: "POST" }`, and the test asserts `[["/api/setup/catalog/refresh", "POST"]]`. Probe: revert the method, 1 test failed (killed), restored with `cmp`. Scoped batch setup-page, setup-web, web-cli: 3 files, 38 tests passed. tsc exit 0, build exit 0. +- finding 2, minor, rejected: a missing active profile answers code 14. WEB-93 requires `resultado.code=14`, so this is the specified behavior. +- suspicion, not acted on: `usage --web --tui` exits 2 on the flag conflict before `usage.get`. Bucket 1 reading: WEB-95 (flag conflict) wins over WEB-56. Recorded as an assumption. +- 52db left check 3 (requirement by requirement conformance for P4 and P5) undone. Re-review 9daf covers the fix and that table. + +## entry 14: re-review 9daf and last fixes + +- 9daf (claude opus, read-only, worktree at 5320af3) confirmed the refresh fix end to end: POST from the page, the cookie and Origin accepted by `checkWebRequest`, and the route reads no body. It found no other GET/POST mismatch across setup, usage and review. It produced the P4 and P5 conformance table: every criterion has code and a test, except WEB-82 (README text, no test by design). Batches: setup-web plus setup-page 29 tests passed; usage-web, usage-page, usage-cli, web-cli 54 tests passed. +- finding 1, minor, accepted: the browser adapter `fetcher: (url, init) => fetch(url, init)` was not covered. Dropping `init` survived every test. Fix: the node:vm test records the method and calls `refreshCatalog()`, expecting `POST /api/setup/catalog/refresh`. The same probe is now killed (1 test failed), restored with `cmp`. +- finding 2, minor, accepted: WEB-82 cited README line numbers that had drifted. It now cites the setup and usage command sections. `validate_spec` 0 errors, 0 warnings. +- suspicion, recorded, not fixed: `usage --web --days 14` pre-fills Since with a full ISO timestamp, which an `` shows as empty. The controller state keeps the value, and `setFilter` changes one field at a time, so the query stays correct. The issue is display only and was not checked in a real browser. +- no third review round: the only changes after 9daf are a test assertion and a spec sentence, both checked above. diff --git a/.specs/features/web-console/spec.md b/.specs/features/web-console/spec.md index 694c748..074f7cc 100644 --- a/.specs/features/web-console/spec.md +++ b/.specs/features/web-console/spec.md @@ -185,7 +185,7 @@ Setup and usage analytics currently require the terminal, while review already s 32. IF model discovery is incomplete or any requested harness returns an error THEN the refresh response SHALL return getBatchModels cache fallback and discoveryError without partial network results. WEB-59 33. IF codedeck setup runs without batch flags and either stdin or stdout is not a TTY THEN it SHALL exit with code 1, print `${getCliName()} setup needs a terminal on both stdin and stdout.`, and start no server. WEB-69 34. IF codedeck setup receives both --json and --port THEN it SHALL report a setup usage error and start no server. WEB-70 -35. WHEN setup guidance is updated THEN README.md lines 154 and 186 SHALL describe browser setup as the default, --tui as the picker entry, and --refresh as the catalog refresh option. WEB-82 +35. WHEN setup guidance is updated THEN the README.md setup and usage command sections SHALL describe browser setup as the default, --tui as the picker entry, and --refresh as the catalog refresh option. WEB-82 36. IF a changed binding's model is absent from the cached catalog THEN web apply SHALL return HTTP 422 with saved=false unless the request includes offCatalogConfirmed[role]=true for that binding's role. WEB-88 37. IF a changed binding's model is absent from the cached catalog and the request includes offCatalogConfirmed[role]=true THEN web apply SHALL save that binding and return resultado.status=applied with saved=true. WEB-89 38. IF catalog refresh returns status=unavailable THEN setup page logic SHALL retain the previously loaded catalog and show the response's discoveryError. WEB-90 @@ -252,8 +252,8 @@ Each acceptance criterion has one requirement ID and maps to the task that imple | WEB-05 | P1: Shared local server and home page | P1 | Implemented | T3 | | WEB-06 | P1: Shared local server and home page | P1 | Implemented | T3 | | WEB-07 | P1: Shared local server and home page | P1 | Implemented | T3 | -| WEB-08 | P1: Shared local server and home page | P1 | In Tasks | T2, T4, T5, T13, T18 | -| WEB-09 | P2: Local request security | P2 | In Tasks | T7, T12, T17 | +| WEB-08 | P1: Shared local server and home page | P1 | Implemented | T2, T4, T5, T13, T18 | +| WEB-09 | P2: Local request security | P2 | Implemented | T7, T12, T17 | | WEB-10 | P2: Local request security | P2 | Implemented | T6, T7 | | WEB-11 | P2: Local request security | P2 | Implemented | T6 | | WEB-12 | P2: Local request security | P2 | Implemented | T6, T7 | @@ -264,80 +264,80 @@ Each acceptance criterion has one requirement ID and maps to the task that imple | WEB-17 | P3: Shared setup planning | P3 | Implemented | T8 | | WEB-18 | P3: Shared setup planning | P3 | Implemented | T8 | | WEB-19 | P3: Shared setup planning | P3 | Implemented | T8 | -| WEB-20 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-21 | P4: Browser setup | P4 | In Tasks | T11 | +| WEB-20 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-21 | P4: Browser setup | P4 | Implemented | T11 | | WEB-22 | P3: Shared setup planning | P3 | Implemented | T9 | -| WEB-23 | P4: Browser setup | P4 | In Tasks | T10 | -| WEB-24 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-25 | P4: Browser setup | P4 | In Tasks | T10 | -| WEB-26 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-27 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-28 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-29 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-30 | P4: Browser setup | P4 | In Tasks | T8, T11 | -| WEB-31 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-32 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-33 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-34 | P4: Browser setup | P4 | In Tasks | T12 | -| WEB-35 | P4: Browser setup | P4 | In Tasks | T12 | -| WEB-36 | P4: Browser setup | P4 | In Tasks | T9, T12 | -| WEB-37 | P4: Browser setup | P4 | In Tasks | T10 | -| WEB-38 | P4: Browser setup | P4 | In Tasks | T10 | -| WEB-39 | P4: Browser setup | P4 | In Tasks | T10 | -| WEB-40 | P4: Browser setup | P4 | In Tasks | T10 | -| WEB-41 | P4: Browser setup | P4 | In Tasks | T10 | -| WEB-42 | P4: Browser setup | P4 | In Tasks | T12, T13 | -| WEB-43 | P5: Browser usage analytics | P5 | In Tasks | T16, T18 | -| WEB-44 | P5: Browser usage analytics | P5 | In Tasks | T14, T15 | -| WEB-45 | P5: Browser usage analytics | P5 | In Tasks | T16 | -| WEB-46 | P5: Browser usage analytics | P5 | In Tasks | T16 | -| WEB-47 | P5: Browser usage analytics | P5 | In Tasks | T16 | -| WEB-48 | P5: Browser usage analytics | P5 | In Tasks | T16 | -| WEB-49 | P5: Browser usage analytics | P5 | In Tasks | T16 | -| WEB-50 | P5: Browser usage analytics | P5 | In Tasks | T16 | -| WEB-51 | P5: Browser usage analytics | P5 | In Tasks | T16 | -| WEB-52 | P5: Browser usage analytics | P5 | In Tasks | T16 | -| WEB-53 | P5: Browser usage analytics | P5 | In Tasks | T16 | -| WEB-54 | P5: Browser usage analytics | P5 | In Tasks | T17 | -| WEB-55 | P5: Browser usage analytics | P5 | In Tasks | T17 | -| WEB-56 | P5: Browser usage analytics | P5 | In Tasks | T17 | +| WEB-23 | P4: Browser setup | P4 | Implemented | T10 | +| WEB-24 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-25 | P4: Browser setup | P4 | Implemented | T10 | +| WEB-26 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-27 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-28 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-29 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-30 | P4: Browser setup | P4 | Implemented | T8, T11 | +| WEB-31 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-32 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-33 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-34 | P4: Browser setup | P4 | Implemented | T12 | +| WEB-35 | P4: Browser setup | P4 | Implemented | T12 | +| WEB-36 | P4: Browser setup | P4 | Implemented | T9, T12 | +| WEB-37 | P4: Browser setup | P4 | Implemented | T10 | +| WEB-38 | P4: Browser setup | P4 | Implemented | T10 | +| WEB-39 | P4: Browser setup | P4 | Implemented | T10 | +| WEB-40 | P4: Browser setup | P4 | Implemented | T10 | +| WEB-41 | P4: Browser setup | P4 | Implemented | T10 | +| WEB-42 | P4: Browser setup | P4 | Implemented | T12, T13 | +| WEB-43 | P5: Browser usage analytics | P5 | Implemented | T16, T18 | +| WEB-44 | P5: Browser usage analytics | P5 | Implemented | T14, T15 | +| WEB-45 | P5: Browser usage analytics | P5 | Implemented | T16 | +| WEB-46 | P5: Browser usage analytics | P5 | Implemented | T16 | +| WEB-47 | P5: Browser usage analytics | P5 | Implemented | T16 | +| WEB-48 | P5: Browser usage analytics | P5 | Implemented | T16 | +| WEB-49 | P5: Browser usage analytics | P5 | Implemented | T16 | +| WEB-50 | P5: Browser usage analytics | P5 | Implemented | T16 | +| WEB-51 | P5: Browser usage analytics | P5 | Implemented | T16 | +| WEB-52 | P5: Browser usage analytics | P5 | Implemented | T16 | +| WEB-53 | P5: Browser usage analytics | P5 | Implemented | T16 | +| WEB-54 | P5: Browser usage analytics | P5 | Implemented | T17 | +| WEB-55 | P5: Browser usage analytics | P5 | Implemented | T17 | +| WEB-56 | P5: Browser usage analytics | P5 | Implemented | T17 | | WEB-57 | P1: Shared local server and home page | P1 | Implemented | T1 | -| WEB-59 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-60 | P5: Browser usage analytics | P5 | In Tasks | T16 | +| WEB-59 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-60 | P5: Browser usage analytics | P5 | Implemented | T16 | | WEB-61 | P3: Shared setup planning | P3 | Implemented | T8 | | WEB-62 | P3: Shared setup planning | P3 | Implemented | T8 | -| WEB-63 | P4: Browser setup | P4 | In Tasks | T10, T11 | -| WEB-64 | P4: Browser setup | P4 | In Tasks | T10 | -| WEB-65 | P4: Browser setup | P4 | In Tasks | T10, T11 | -| WEB-66 | P4: Browser setup | P4 | In Tasks | T8, T10, T11 | -| WEB-67 | P4: Browser setup | P4 | In Tasks | T10 | -| WEB-68 | P4: Browser setup | P4 | In Tasks | T8, T10 | -| WEB-69 | P4: Browser setup | P4 | In Tasks | T12 | -| WEB-70 | P4: Browser setup | P4 | In Tasks | T12 | +| WEB-63 | P4: Browser setup | P4 | Implemented | T10, T11 | +| WEB-64 | P4: Browser setup | P4 | Implemented | T10 | +| WEB-65 | P4: Browser setup | P4 | Implemented | T10, T11 | +| WEB-66 | P4: Browser setup | P4 | Implemented | T8, T10, T11 | +| WEB-67 | P4: Browser setup | P4 | Implemented | T10 | +| WEB-68 | P4: Browser setup | P4 | Implemented | T8, T10 | +| WEB-69 | P4: Browser setup | P4 | Implemented | T12 | +| WEB-70 | P4: Browser setup | P4 | Implemented | T12 | | WEB-71 | P2: Local request security | P2 | Implemented | T6, T7 | | WEB-72 | P2: Local request security | P2 | Implemented | T6, T7 | -| WEB-73 | P2: Local request security | P2 | In Tasks | T10 | +| WEB-73 | P2: Local request security | P2 | Implemented | T10 | | WEB-74 | P2: Local request security | P2 | Implemented | T6, T7 | | WEB-75 | P1: Shared local server and home page | P1 | Implemented | T1, T7 | -| WEB-76 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-77 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-78 | P5: Browser usage analytics | P5 | In Tasks | T15, T16 | -| WEB-80 | P5: Browser usage analytics | P5 | In Tasks | T16, T17 | -| WEB-81 | P5: Browser usage analytics | P5 | In Tasks | T16, T17 | -| WEB-82 | P4: Browser setup | P4 | In Tasks | T19 | -| WEB-83 | P5: Browser usage analytics | P5 | In Tasks | T17 | -| WEB-84 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-85 | P4: Browser setup | P4 | In Tasks | T11 | +| WEB-76 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-77 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-78 | P5: Browser usage analytics | P5 | Implemented | T15, T16 | +| WEB-80 | P5: Browser usage analytics | P5 | Implemented | T16, T17 | +| WEB-81 | P5: Browser usage analytics | P5 | Implemented | T16, T17 | +| WEB-82 | P4: Browser setup | P4 | Implemented | T19 | +| WEB-83 | P5: Browser usage analytics | P5 | Implemented | T17 | +| WEB-84 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-85 | P4: Browser setup | P4 | Implemented | T11 | | WEB-86 | P3: Shared setup planning | P3 | Implemented | T8 | | WEB-87 | P3: Shared setup planning | P3 | Implemented | T8 | -| WEB-88 | P4: Browser setup | P4 | In Tasks | T10, T11 | -| WEB-89 | P4: Browser setup | P4 | In Tasks | T10, T11 | -| WEB-90 | P4: Browser setup | P4 | In Tasks | T10, T11 | -| WEB-91 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-92 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-93 | P4: Browser setup | P4 | In Tasks | T11 | -| WEB-94 | P4: Browser setup | P4 | In Tasks | T12 | -| WEB-95 | P5: Browser usage analytics | P5 | In Tasks | T17 | +| WEB-88 | P4: Browser setup | P4 | Implemented | T10, T11 | +| WEB-89 | P4: Browser setup | P4 | Implemented | T10, T11 | +| WEB-90 | P4: Browser setup | P4 | Implemented | T10, T11 | +| WEB-91 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-92 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-93 | P4: Browser setup | P4 | Implemented | T11 | +| WEB-94 | P4: Browser setup | P4 | Implemented | T12 | +| WEB-95 | P5: Browser usage analytics | P5 | Implemented | T17 | **Coverage**: 93 total requirements, 93 mapped to tasks, 0 unmapped. ## External Dependencies diff --git a/.specs/features/web-console/tasks.md b/.specs/features/web-console/tasks.md index 940f224..9c1a67d 100644 --- a/.specs/features/web-console/tasks.md +++ b/.specs/features/web-console/tasks.md @@ -6,7 +6,7 @@ Implement these tasks with the tlc-spec-driven skill. Keep tests in the task tha **Design**: .specs/features/web-console/design.md -**Status**: In progress. Done: T1-T9, T14 (feat/web-console-foundation) +**Status**: Done. T1-T9, T14 merged in #111 (feat/web-console-foundation); T10-T13, T15-T19 on feat/web-console-pages ## Test Coverage Matrix @@ -20,7 +20,7 @@ Implement these tasks with the tlc-spec-driven skill. Keep tests in the task tha | Setup web handlers | Integration | State prefill and error codes, catalog cache and refresh fallback, dry-run no-write, apply, changed-only validation with per-role off-catalog confirmation, missing profile, malformed body, and save errors | tests/setup-web.test.ts | npx vitest run tests/setup-web.test.ts | | Usage query builder | Unit and command contract | Existing CLI filter precedence and mappings, with fixed options, cwd, and clock | tests/usage-cli.test.ts | npx vitest run tests/usage-cli.test.ts | | Usage web handler | Integration | Query mapping, every supported filter, CLI versus /api/usage parameter equality, query success, and query error response | tests/usage-web.test.ts | npx vitest run tests/usage-web.test.ts | -| HTML pages and page behavior | Unit | Direct Node tests of injected page functions plus node:vm execution of extracted SETUP_PAGE and USAGE_PAGE scripts with stubbed browser globals; setup state, free-text input, discovery errors, 403 message, filters, polling, rendering data, optional byOrigin, and retained result on error | tests/web-pages.test.ts | npx vitest run tests/web-pages.test.ts | +| HTML pages and page behavior | Unit | Direct Node tests of injected page functions plus node:vm execution of extracted SETUP_PAGE and USAGE_PAGE scripts with stubbed browser globals; setup state, free-text input, discovery errors, 403 message, filters, polling, rendering data, optional byOrigin, and retained result on error | tests/web-pages.test.ts, tests/setup-page.test.ts, tests/usage-page.test.ts | npx vitest run tests/web-pages.test.ts tests/setup-page.test.ts tests/usage-page.test.ts | | CLI wiring | Command contract | ui, review, setup, and usage routes and flags; setup batch and non-TTY behavior; usage.get with --web --json without server startup | tests/web-cli.test.ts, tests/review-command.test.ts, tests/setup-cli-contract.test.ts, tests/usage-cli.test.ts, tests/usage-statusline-contract.test.ts | npx vitest run tests/web-cli.test.ts tests/review-command.test.ts tests/setup-cli-contract.test.ts tests/usage-cli.test.ts tests/usage-statusline-contract.test.ts | | Documentation | none | Text-only README update; no test coverage required | None | git diff --check -- README.md | @@ -32,8 +32,8 @@ Implement these tasks with the tlc-spec-driven skill. Keep tests in the task tha | P1 | After shared server and home wiring | npx vitest run tests/web-server.test.ts tests/web-pages.test.ts tests/review.test.ts tests/review-command.test.ts tests/web-cli.test.ts | | P2 | After security integration | npx vitest run tests/web-security.test.ts tests/web-server.test.ts tests/web-pages.test.ts | | P3 | After setup core extraction | npx vitest run tests/setup-plan.test.ts tests/setup-wizard.test.ts tests/setup-cli-contract.test.ts | -| P4 | After setup web wiring | npx vitest run tests/setup-web.test.ts tests/web-pages.test.ts tests/web-cli.test.ts tests/setup-cli-contract.test.ts | -| P5 | After P4 | npx vitest run tests/usage-cli.test.ts tests/usage-web.test.ts tests/web-pages.test.ts tests/web-cli.test.ts tests/usage-statusline-contract.test.ts | +| P4 | After setup web wiring | npx vitest run tests/setup-web.test.ts tests/setup-page.test.ts tests/web-cli.test.ts tests/setup-cli-contract.test.ts | +| P5 | After P4 | npx vitest run tests/usage-cli.test.ts tests/usage-web.test.ts tests/usage-page.test.ts tests/web-cli.test.ts tests/usage-statusline-contract.test.ts | ## Execution Plan @@ -284,8 +284,8 @@ T16 -> T18 - The HTML injects its exported behavior function source; Node tests call those same functions with fake fetch and state callbacks without a DOM. - Tests extract the inline script from SETUP_PAGE, evaluate it in node:vm with an empty context and stubbed fetch, timers, and document, then call the setup page functions from that context. -**Tests**: Unit, tests/web-pages.test.ts -**Gate**: npx vitest run tests/web-pages.test.ts +**Tests**: Unit, tests/setup-page.test.ts +**Gate**: npx vitest run tests/setup-page.test.ts ### T11: Add setup state, catalog, and mutation routes @@ -428,8 +428,8 @@ T16 -> T18 - The HTML injects the exported behavior function source; tests directly call that function in Node with fake fetch, timer, and render adapters. - Tests extract the inline script from USAGE_PAGE, evaluate it in node:vm with an empty context and stubbed fetch, timers, and document, then call the usage page functions from that context. -**Tests**: Unit, tests/web-pages.test.ts -**Gate**: npx vitest run tests/web-pages.test.ts +**Tests**: Unit, tests/usage-page.test.ts +**Gate**: npx vitest run tests/usage-page.test.ts ### T17: Add usage --web command wiring @@ -510,13 +510,13 @@ T16 -> T18 | T7 | Security | Integration | tests/web-security.test.ts, tests/web-server.test.ts | OK | | T8 | Setup core | Unit | tests/setup-plan.test.ts | OK | | T9 | Setup core | Integration | tests/setup-wizard.test.ts, tests/setup-cli-contract.test.ts | OK | -| T10 | HTML pages and page behavior | Unit | tests/web-pages.test.ts | OK | +| T10 | HTML pages and page behavior | Unit | tests/setup-page.test.ts | OK | | T11 | Setup web handlers | Integration | tests/setup-web.test.ts | OK | | T12 | CLI wiring | Command contract | tests/web-cli.test.ts, tests/setup-cli-contract.test.ts | OK | | T13 | CLI wiring | Command contract | tests/web-cli.test.ts | OK | | T14 | Usage query builder | Unit and command contract | tests/usage-cli.test.ts | OK | | T15 | Usage web handler | Integration | tests/usage-web.test.ts | OK | -| T16 | HTML pages and page behavior | Unit | tests/web-pages.test.ts | OK | +| T16 | HTML pages and page behavior | Unit | tests/usage-page.test.ts | OK | | T17 | CLI wiring | Command contract | tests/web-cli.test.ts, tests/usage-cli.test.ts | OK | | T18 | CLI wiring | Command contract | tests/web-cli.test.ts | OK | | T19 | Documentation | none | none | OK | diff --git a/README.md b/README.md index 0ef6a96..1c73477 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,16 @@ A launch carrying `-p`/`--print` answers once and exits, so it never asks anythi Launching never asks. Which harness and model each of the four agents should run on is a question worth answering deliberately, not one to greet someone with, so it lives in its own command: +In an interactive terminal, setup opens the local web console in your browser: + +```bash +npx codedeck setup # opens the setup web console +``` + +The frozen terminal wizard remains available with `--tui`: + ```bash -npx codedeck setup +npx codedeck setup --tui ``` ``` @@ -183,7 +191,7 @@ An id the catalog does not list can still be typed, with its harness as a prefix The prefix is required, because a bare id names half a binding and there is no honest way to guess the other half. Enter asks once to confirm, and a second Enter writes it. -The catalog is cached for four hours. `codedeck setup --refresh` ignores the cache and rediscovers. +The catalog is cached for four hours. `codedeck setup --refresh` rediscovers it when the web console opens. Add `--tui` to use the frozen terminal picker instead. `codedeck run --role reviewer ""` then needs no other flag: the role's binding supplies both the harness and the model. `--agent` and `--model` still win over it, and a `--role` whose harness disagrees with an explicit `--agent` keeps the flag and drops the bound model, rather than hand one harness another's id. diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 9fe44ae..8030626 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -55,6 +55,9 @@ 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"; export { diffConfig, SetupUsageError }; export type { JsonValue, SetupEnvelope }; @@ -814,6 +817,9 @@ export interface SetupCliOptions { nonInteractive: boolean; json: boolean; dryRun: boolean; + tui: boolean; + noOpen: boolean; + port?: string; binds: ParsedSetupBinding[]; batch: boolean; profile?: string; @@ -875,6 +881,9 @@ export function parseSetupArgs(args: readonly string[]): SetupParseResult { let refresh = false; let nonInteractive = false; let dryRun = false; + let tui = false; + let noOpen = false; + let port: string | undefined; let profile: string | undefined; for (let index = 0; index < args.length; index += 1) { @@ -883,6 +892,27 @@ export function parseSetupArgs(args: readonly string[]): SetupParseResult { refresh = true; continue; } + if (arg === "--tui") { + tui = true; + continue; + } + if (arg === "--no-open") { + noOpen = true; + continue; + } + if (arg === "--port") { + const value = args[index + 1]; + if (value === undefined || isSetupFlag(value)) { + return parseError('Option "--port" expects a port.', json); + } + port = value; + index += 1; + continue; + } + if (arg.startsWith("--port=")) { + port = arg.slice("--port=".length); + continue; + } if (arg === "--profile") { const value = args[index + 1]; if (value === undefined || isSetupFlag(value)) { @@ -941,6 +971,12 @@ export function parseSetupArgs(args: readonly string[]): SetupParseResult { return parseError(`Unexpected argument "${arg}".`, json); } + const batch = nonInteractive || json || dryRun || binds.length > 0; + if (tui && batch) return parseError('Option "--tui" cannot be used with batch setup flags.', json); + if (port !== undefined && (json || dryRun || nonInteractive)) { + return parseError('Option "--port" cannot be used with --json, --dry-run, or --non-interactive.', json); + } + return { ok: true, options: { @@ -948,8 +984,11 @@ export function parseSetupArgs(args: readonly string[]): SetupParseResult { nonInteractive, json, dryRun, + tui, + noOpen, + ...(port === undefined ? {} : { port }), binds, - batch: nonInteractive || json || dryRun || binds.length > 0, + batch, ...(profile === undefined ? {} : { profile }), }, }; @@ -1212,12 +1251,17 @@ export interface SetupCommandDependencies extends SetupBatchDependencies { stdout?: NodeJS.WritableStream & { isTTY?: boolean; rows?: number; columns?: number }; isTTY?: boolean; wizardDiscoverModels?: ModelWizardOptions["discoverModels"]; + runWizard?: typeof runModelSetupWizard; saveConfig?: (config: RunAgentConfig) => void; + startServer?: typeof startWebServer; } function commandTokens(opts: Record, command: Command): string[] { const tokens: string[] = []; if (opts.refresh) tokens.push("--refresh"); + if (opts.tui) tokens.push("--tui"); + if (opts.open === false) tokens.push("--no-open"); + if (typeof opts.port === "string") tokens.push("--port", opts.port); if (opts.nonInteractive) tokens.push("--non-interactive"); if (opts.json) tokens.push("--json"); if (opts.dryRun) tokens.push("--dry-run"); @@ -1235,6 +1279,24 @@ export interface SetupActionResult { envelope?: SetupEnvelope; } +function createSetupCommandRoutes(options: SetupCliOptions): WebRoute[] { + const routes = createSetupRoutes({ profile: options.profile }); + 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( + "", + "\n", + )); + }, + } + : route); +} + export async function executeSetupAction( args: readonly string[], dependencies: SetupCommandDependencies = {}, @@ -1257,19 +1319,42 @@ export async function executeSetupAction( writeError(message); return { code: 1 }; } + if (parsed.options.tui) { + try { + await (dependencies.runWizard ?? runModelSetupWizard)({ + registry: dependencies.registry, + input: dependencies.input, + output: dependencies.stdout, + refresh: parsed.options.refresh, + discoverModels: dependencies.wizardDiscoverModels, + save: dependencies.saveConfig, + isTTY: tty, + ...(parsed.options.profile === undefined ? {} : { profile: parsed.options.profile }), + }); + } catch (error) { + writeError(error instanceof Error ? error.message : String(error)); + return { code: 1 }; + } + return { code: 0 }; + } + + let port: number; + try { + port = parseWebPort(parsed.options.port ?? String(DEFAULT_WEB_PORT)); + } catch (error) { + writeError(error instanceof Error ? error.message : String(error)); + return { code: 1 }; + } try { - await runModelSetupWizard({ - registry: dependencies.registry, - input: dependencies.input, - output: dependencies.stdout, - refresh: parsed.options.refresh, - discoverModels: dependencies.wizardDiscoverModels, - save: dependencies.saveConfig, - isTTY: tty, - ...(parsed.options.profile === undefined ? {} : { profile: parsed.options.profile }), + await (dependencies.startServer ?? startWebServer)({ + routes: createSetupCommandRoutes(parsed.options), + port, + initialPath: "/setup", + title: "CodeDeck setup", + open: !parsed.options.noOpen, }); } catch (error) { - writeError(error instanceof Error ? error.message : String(error)); + writeError(`Failed to listen on 127.0.0.1:${port}: ${error instanceof Error ? error.message : String(error)}`); return { code: 1 }; } return { code: 0 }; @@ -1287,6 +1372,9 @@ export function registerSetupCommand(program: Command, dependencies: SetupComman .allowUnknownOption(true) .allowExcessArguments(true) .option("--refresh", "ignore the cached catalog and rediscover") + .option("--tui", "use the frozen terminal setup wizard") + .option("--port ", "port to listen on (default: 3100)") + .option("--no-open", "serve setup 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/src/cli/commands/ui.ts b/src/cli/commands/ui.ts index b53f382..c01c285 100644 --- a/src/cli/commands/ui.ts +++ b/src/cli/commands/ui.ts @@ -1,7 +1,10 @@ import type { Command } from "commander"; 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 { createUsageRoutes, type UsageRoutesOptions } from "../../web/usage-routes.js"; export interface UiCommandOptions { port?: string; @@ -10,14 +13,20 @@ export interface UiCommandOptions { export interface UiCommandDependencies { startServer?: typeof startWebServer; + setup?: SetupRoutesDependencies; + usage?: UsageRoutesOptions; } -export function createUiRoutes(): WebRoute[] { - const reviewRoutes = createReviewRoutes(); - const pages = reviewRoutes.flatMap((route) => +export function createUiRoutes(dependencies: Pick = {}): WebRoute[] { + const reviewRoutes = createReviewRoutes().filter((route) => route.path !== "/"); + const setupRoutes = createSetupRoutes(dependencies.setup); + const usageRoutes = createUsageRoutes(dependencies.usage ?? { fetchUsageQuery }).map((route) => + route.path === "/usage" ? { ...route, label: "Usage" } : route, + ); + const routes = [...reviewRoutes, ...setupRoutes, ...usageRoutes]; + const pages = routes.flatMap((route) => route.kind === "page" && route.label ? [{ label: route.label, path: route.path }] : [], ); - const reviewRouteTable = reviewRoutes.filter((route) => route.path !== "/"); const home: WebRoute = { path: "/", kind: "page", @@ -26,7 +35,7 @@ export function createUiRoutes(): WebRoute[] { response.end(renderHomePage(pages)); }, }; - return [home, ...reviewRouteTable]; + return [home, ...routes]; } export function registerUiCommand(program: Command, dependencies: UiCommandDependencies = {}): void { @@ -47,7 +56,7 @@ export function registerUiCommand(program: Command, dependencies: UiCommandDepen try { await (dependencies.startServer ?? startWebServer)({ - routes: createUiRoutes(), + routes: createUiRoutes(dependencies), port, initialPath: "/", title: "CodeDeck UI", diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts index 3f1d38f..b026be4 100644 --- a/src/cli/commands/usage.ts +++ b/src/cli/commands/usage.ts @@ -11,6 +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"; export interface UsageCommandOptions { json?: boolean; @@ -31,6 +33,15 @@ export interface UsageCommandOptions { interval?: string; observe?: string; backfill?: boolean; + web?: boolean; + port?: string; + open?: boolean; +} + +export interface UsageCommandDependencies { + fetchUsageQuery?: typeof fetchUsageQuery; + backfillUsage?: typeof backfillUsage; + startServer?: typeof startWebServer; } function parseUsageObservation(value: string | undefined): { nativeId: string; costUsd: number } | undefined { @@ -105,7 +116,10 @@ export async function fetchUsageQuery(params: UsageQueryParams): Promise", "group by dimension: day, repo, model, agent, run, origin") .option("--observe ", "report live orchestrator cost") .option("--backfill", "import historical orchestrator usage") + .option("--web", "open aggregate usage in the browser") + .option("--port ", "port to listen on (default: 3100)", String(DEFAULT_WEB_PORT)) + .option("--no-open", "serve usage 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 ", "refresh interval for --watch (default: 2)", "2") @@ -131,7 +148,7 @@ export function registerUsageCommand(program: Command): void { .action(async (runId: string | undefined, opts: UsageCommandOptions) => { if (opts.backfill) { try { - const summary = await backfillUsage(); + const summary = await runBackfill(); if (opts.json) console.log(JSON.stringify(summary)); else console.log(`Usage backfill: imported ${summary.imported}, skipped ${summary.skipped}`); } catch (error) { @@ -141,6 +158,12 @@ export function registerUsageCommand(program: Command): void { return; } + if (opts.web && opts.tui) { + console.error("Options --web and --tui cannot be used together."); + process.exitCode = 2; + return; + } + const targetRunId = opts.run ?? runId; // 1. Single-Run Branch (100% Backwards Compatible with statusline.sh) @@ -173,7 +196,48 @@ export function registerUsageCommand(program: Command): void { } // 2. Aggregate Analytics Branch - const queryParams = buildUsageQueryParams(opts, process.cwd(), new Date()); + const cwd = process.cwd(); + const queryParams = buildUsageQueryParams(opts, cwd, new Date()); + + if (opts.web) { + let port: number; + try { + port = parseWebPort(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; + } + return; + } // Interactive TUI Mode if (opts.tui) { @@ -183,7 +247,7 @@ export function registerUsageCommand(program: Command): void { return; } const fetcher: DashboardFetcher = { - fetch: async (p) => fetchUsageQuery({ ...queryParams, period: p }), + fetch: async (p) => queryUsage({ ...queryParams, period: p }), }; await runDashboard(fetcher, { input: process.stdin, output: process.stdout }); return; @@ -193,7 +257,7 @@ export function registerUsageCommand(program: Command): void { if (opts.watch) { const intervalSec = Math.max(1, Number(opts.interval) || 2); const printLive = async () => { - const res = await fetchUsageQuery(queryParams); + const res = await queryUsage(queryParams); const snap = renderUsageSnapshot(res, { plain: false, by: opts.by }); process.stdout.write(`\x1b[H\x1b[2J${snap}\n\n \x1b[2mUpdating every ${intervalSec}s... (Ctrl+C to quit)\x1b[0m\n`); }; @@ -209,7 +273,7 @@ export function registerUsageCommand(program: Command): void { // Standard Fetch let result: UsageQueryResult; try { - result = await fetchUsageQuery(queryParams); + result = await queryUsage(queryParams); } catch (error) { console.error(`Failed to fetch usage: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 3; diff --git a/src/web/server.ts b/src/web/server.ts index d507c3f..93878b4 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3,7 +3,7 @@ import type { AddressInfo } from "node:net"; import { spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { InvalidArgumentError } from "commander"; -import { checkWebRequest, createWebSecurity, getTokenUrl, isAllowedWebHost, type WebSecurity } from "./security.js"; +import { checkWebRequest, createWebSecurity, getTokenUrl, type WebSecurity } from "./security.js"; export const DEFAULT_WEB_PORT = 3100; @@ -175,12 +175,6 @@ function dispatchRequest( request: http.IncomingMessage, response: http.ServerResponse, ): void { - if (!isAllowedWebHost(request.headers.host, security.port)) { - response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); - response.end("forbidden"); - return; - } - let pathname: string; try { pathname = new URL(request.url || "/", `http://127.0.0.1:${security.port}`).pathname; diff --git a/src/web/setup-page.ts b/src/web/setup-page.ts new file mode 100644 index 0000000..0ec01c6 --- /dev/null +++ b/src/web/setup-page.ts @@ -0,0 +1,584 @@ +import { REASONING_EFFORTS } from "../core/driver.js"; +import { ROLES, type Role } from "../core/roles.js"; +import type { BatchModelsResult } from "../core/models.js"; +import type { RoleBinding, RunAgentConfig } from "../config/config.js"; +import { ORCHESTRATOR_PRESETS, type OrchestratorMode } from "../config/orchestrator-mode.js"; +import type { SetupSelection } from "../config/setup.js"; + +export const SETUP_SESSION_EXPIRED_MESSAGE = + "This CodeDeck session has expired. Reload the page. If it still fails, restart the command and open its new URL."; + +export interface SetupRoleFormValue { + skip: boolean; + binding: string; + effort: string; +} + +export interface SetupPageFormValues { + roles: Partial>; + orchestrator: string; + investigate: string; + selfWork: string; + tools: string; + parallelism: string; + sandbox: string; + autocompact: string; +} + +export interface SetupPageSelectionOptions { + roles: readonly Role[]; + efforts: readonly string[]; + presets: Record; +} + +export function buildSetupSelection( + values: SetupPageFormValues, + currentBindings: Partial>, + options: SetupPageSelectionOptions, +): SetupSelection { + const agents: Partial> = {}; + + for (const role of options.roles) { + const selected = values.roles[role]; + if (!selected || selected.skip) continue; + + const separator = selected.binding.indexOf(":"); + const harness = separator < 0 ? "" : selected.binding.slice(0, separator).trim(); + const model = separator < 0 ? "" : selected.binding.slice(separator + 1).trim(); + if (!harness || !model) throw new Error(`Enter a harness:model value for ${role}.`); + + const previous = currentBindings[role]; + const sameBinding = previous?.harness === harness && previous.model === model; + let effort: string | undefined; + const effortValue = selected.effort.trim(); + if (harness === "opencode") { + if (sameBinding) effort = previous?.effort; + } else if (effortValue === "" || effortValue === "keep") { + if (sameBinding) effort = previous?.effort; + } else if (options.efforts.includes(effortValue)) { + effort = effortValue; + } else { + throw new Error(`Choose a supported effort value for ${role}.`); + } + + agents[role] = { + harness: harness as RoleBinding["harness"], + model, + ...(effort === undefined ? {} : { effort: effort as RoleBinding["effort"] }), + }; + } + + let orchestrator: OrchestratorMode | undefined; + if (values.orchestrator !== "skip") { + const preset = options.presets[values.orchestrator]; + if (preset) { + orchestrator = { ...preset }; + } else if (values.orchestrator === "custom") { + if ( + !["none", "read", "free"].includes(values.investigate) || + !["none", "trivial", "small"].includes(values.selfWork) || + !["dispatch", "read", "edit"].includes(values.tools) + ) { + throw new Error("Choose valid custom orchestrator parameters."); + } + const parallelismValue = values.parallelism.trim(); + const parallelism = parallelismValue === "" ? undefined : Number(parallelismValue); + if (parallelism !== undefined && (!Number.isFinite(parallelism) || parallelism <= 0)) { + throw new Error("parallelism must be a positive finite number."); + } + orchestrator = { + investigate: values.investigate as OrchestratorMode["investigate"], + selfWork: values.selfWork as OrchestratorMode["selfWork"], + tools: values.tools as OrchestratorMode["tools"], + ...(parallelism === undefined ? {} : { parallelism }), + }; + } else { + throw new Error("Choose an orchestrator preset or custom mode."); + } + } + + let sandbox: SetupSelection["sandbox"]; + if (values.sandbox === "workspace-write" || values.sandbox === "danger-full-access") { + sandbox = values.sandbox; + } else if (values.sandbox !== "skip") { + throw new Error("Choose a supported sandbox value."); + } + + let autocompact: SetupSelection["autocompact"]; + if (values.autocompact === "on") autocompact = { enabled: true }; + else if (values.autocompact === "off") autocompact = { enabled: false }; + else if (values.autocompact !== "skip") throw new Error("Choose on or off for autocompact."); + + return { + agents, + ...(orchestrator === undefined ? {} : { orchestrator }), + ...(sandbox === undefined ? {} : { sandbox }), + ...(autocompact === undefined ? {} : { autocompact }), + }; +} + +export interface SetupPageElement { + value?: string; + checked?: boolean; + disabled?: boolean; + hidden?: boolean; + textContent?: string | null; + innerHTML?: string; + addEventListener?(name: string, callback: () => void): void; +} + +export interface SetupPageDocument { + getElementById(id: string): SetupPageElement | null; +} + +export interface SetupPageResponse { + ok: boolean; + status: number; + json(): Promise; +} + +export interface SetupPageFetchInit { + method?: string; + headers?: Record; + body?: string; +} + +export interface SetupPageControllerOptions { + fetcher: (url: string, init?: SetupPageFetchInit) => Promise; + buildSelection: ( + values: SetupPageFormValues, + currentBindings: Partial>, + ) => ReturnType; + roles: readonly Role[]; + expiredMessage: string; + document?: SetupPageDocument; + confirm?: (message: string) => boolean; + onChange?: (state: SetupPageClientState) => void; +} + +export interface SetupPageTargetState { + target: { kind: "global" | "profile"; profile?: string }; + bindings: Partial>; + efforts: Partial>; + orchestrator?: OrchestratorMode; + sandbox?: RunAgentConfig["defaultSandbox"]; + autocompact?: RunAgentConfig["autocompact"]; +} + +export interface SetupPageClientState { + target?: SetupPageTargetState; + catalog?: BatchModelsResult; + discoveryError?: string; + refreshing: boolean; + loading: boolean; + error?: string; + envelope?: unknown; +} + +export function createSetupPageController(options: SetupPageControllerOptions) { + const state: SetupPageClientState = { refreshing: false, loading: true }; + let refreshInFlight: Promise | undefined; + const element = (id: string) => options.document?.getElementById(id) ?? null; + + function update(): void { + const status = element("setup-status"); + if (status) { + status.textContent = state.error ?? (state.refreshing + ? "Discovering models..." + : state.loading + ? "Loading setup..." + : "Setup loaded."); + } + const targetLabel = element("setup-target"); + if (targetLabel && state.target) { + targetLabel.textContent = state.target.target.kind === "profile" + ? `Profile: ${state.target.target.profile ?? ""}` + : "Global configuration"; + } + const catalogLabel = element("catalog-status"); + if (catalogLabel) { + const catalogStatus = state.catalog?.status ?? "not loaded"; + catalogLabel.textContent = state.discoveryError + ? `${catalogStatus}: ${state.discoveryError}` + : catalogStatus; + } + const refreshButton = element("setup-refresh"); + if (refreshButton) refreshButton.disabled = state.refreshing; + const result = element("setup-result"); + if (result) result.textContent = state.envelope === undefined ? "" : JSON.stringify(state.envelope, null, 2); + const currentOrchestrator = element("current-orchestrator"); + if (currentOrchestrator && state.target) { + currentOrchestrator.textContent = state.target.orchestrator === undefined + ? "not set" + : JSON.stringify(state.target.orchestrator); + } + const currentSandbox = element("current-sandbox"); + if (currentSandbox && state.target) currentSandbox.textContent = state.target.sandbox ?? "not set"; + const currentAutocompact = element("current-autocompact"); + if (currentAutocompact && state.target) { + currentAutocompact.textContent = state.target.autocompact === undefined + ? "not set" + : JSON.stringify(state.target.autocompact); + } + + for (const role of options.roles) { + const skip = element(`skip-${role}`); + const binding = element(`binding-${role}`); + const effort = element(`effort-${role}`); + const skipped = Boolean(skip?.checked); + if (binding) binding.disabled = skipped; + const harness = (binding?.value ?? "").split(":", 1)[0].trim(); + const noEffort = harness === "opencode"; + if (effort) { + effort.disabled = skipped || noEffort; + effort.hidden = noEffort; + } + } + + const custom = element("orchestrator-custom"); + if (custom) custom.hidden = element("orchestrator-mode")?.value !== "custom"; + options.onChange?.(state); + } + + function fillCatalogChoices(): void { + const catalog = state.catalog; + for (const role of options.roles) { + const list = element(`catalog-models-${role}`); + if (!list) continue; + const choices = (catalog?.models ?? []).flatMap((harness) => + harness.providers.flatMap((provider) => provider.models.flatMap((model) => { + const values = [model.id, ...(model.aliases ?? [])].filter((value) => value.trim() !== ""); + return values.map((value) => `${harness.agent}:${value}`); + })), + ); + list.innerHTML = [...new Set(choices)] + .map((value) => ``) + .join(""); + } + } + + function prefillTarget(): void { + const target = state.target; + if (!target) return; + for (const role of options.roles) { + const binding = target.bindings[role]; + const skip = element(`skip-${role}`); + const bindingInput = element(`binding-${role}`); + const effort = element(`effort-${role}`); + if (skip) skip.checked = binding === undefined; + if (bindingInput) bindingInput.value = binding === undefined ? "" : `${binding.harness}:${binding.model}`; + if (effort) effort.value = "keep"; + } + update(); + } + + function readForm(): SetupPageFormValues { + const roles: SetupPageFormValues["roles"] = {}; + for (const role of options.roles) { + roles[role] = { + skip: Boolean(element(`skip-${role}`)?.checked), + binding: element(`binding-${role}`)?.value ?? "", + effort: element(`effort-${role}`)?.value ?? "keep", + }; + } + return { + roles, + orchestrator: element("orchestrator-mode")?.value ?? "skip", + investigate: element("orchestrator-investigate")?.value ?? "none", + selfWork: element("orchestrator-self-work")?.value ?? "none", + tools: element("orchestrator-tools")?.value ?? "dispatch", + parallelism: element("orchestrator-parallelism")?.value ?? "", + sandbox: element("setup-sandbox")?.value ?? "skip", + autocompact: element("setup-autocompact")?.value ?? "skip", + }; + } + + function bindEvents(): void { + for (const role of options.roles) { + element(`skip-${role}`)?.addEventListener?.("change", update); + element(`binding-${role}`)?.addEventListener?.("input", update); + } + element("orchestrator-mode")?.addEventListener?.("change", update); + element("setup-refresh")?.addEventListener?.("click", () => { void refreshCatalog(); }); + element("setup-dry-run")?.addEventListener?.("click", () => { void dryRun(); }); + element("setup-apply")?.addEventListener?.("click", () => { void apply(); }); + } + + async function loadJson( + path: string, + init?: SetupPageFetchInit, + ): Promise<{ response: SetupPageResponse; payload: Record }> { + const response = await options.fetcher(path, init); + const payload = await response.json(); + return { + response, + payload: typeof payload === "object" && payload !== null + ? payload as Record + : {}, + }; + } + + async function start(): Promise { + bindEvents(); + try { + const { response, payload } = await loadJson("/api/setup/state"); + if (!response.ok) { + state.error = typeof payload.error === "string" ? payload.error : "Could not load setup state."; + state.loading = false; + update(); + return state; + } + state.target = payload as unknown as SetupPageTargetState; + prefillTarget(); + const catalog = await loadJson("/api/setup/catalog"); + if (catalog.response.ok) { + state.catalog = catalog.payload as unknown as BatchModelsResult; + state.discoveryError = typeof catalog.payload.discoveryError === "string" ? catalog.payload.discoveryError : undefined; + fillCatalogChoices(); + } else { + state.discoveryError = typeof catalog.payload.error === "string" ? catalog.payload.error : "Could not load model catalog."; + } + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + } + state.loading = false; + update(); + return state; + } + + function refreshCatalog(): Promise { + if (refreshInFlight) return refreshInFlight; + state.error = undefined; + state.discoveryError = undefined; + state.refreshing = true; + update(); + refreshInFlight = (async () => { + try { + const { response, payload } = await loadJson("/api/setup/catalog/refresh", { method: "POST" }); + if (response.status === 403) { + state.error = options.expiredMessage; + return { ok: false, status: 403, payload }; + } + if (!response.ok) { + state.discoveryError = typeof payload.error === "string" ? payload.error : "Catalog refresh failed."; + return { ok: false, status: response.status, payload }; + } + if (payload.status === "unavailable") { + state.discoveryError = typeof payload.discoveryError === "string" ? payload.discoveryError : "Catalog unavailable."; + return { ok: true, status: response.status, payload }; + } + state.catalog = payload as unknown as BatchModelsResult; + state.discoveryError = typeof payload.discoveryError === "string" ? payload.discoveryError : undefined; + fillCatalogChoices(); + return { ok: true, status: response.status, payload }; + } catch (error) { + state.discoveryError = error instanceof Error ? error.message : String(error); + return { ok: false, status: 500, payload: { error: state.discoveryError } }; + } finally { + state.refreshing = false; + refreshInFlight = undefined; + update(); + } + })(); + return refreshInFlight; + } + + function buildSelection(values: SetupPageFormValues = readForm()): ReturnType { + if (!state.target) throw new Error("Setup state is not loaded."); + return options.buildSelection(values, state.target.bindings); + } + + async function postAction( + path: string, + selection: ReturnType, + ): Promise<{ ok: boolean; status: number; payload: unknown }> { + state.error = undefined; + try { + const response = await options.fetcher(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(selection), + }); + const payload = await response.json(); + if (response.status === 403) { + state.error = options.expiredMessage; + } else if (!response.ok) { + const body = typeof payload === "object" && payload !== null ? payload as Record : {}; + const result = typeof body.resultado === "object" && body.resultado !== null + ? body.resultado as Record + : {}; + state.error = typeof result.message === "string" + ? result.message + : typeof body.error === "string" + ? body.error + : "Setup request failed."; + } + state.envelope = payload; + update(); + return { ok: response.ok, status: response.status, payload }; + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + update(); + return { ok: false, status: 500, payload: { error: state.error } }; + } + } + + async function dryRun(values: SetupPageFormValues = readForm()): Promise<{ ok: boolean; status: number; payload: unknown } | undefined> { + try { + return await postAction("/api/setup/dry-run", buildSelection(values)); + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + update(); + } + } + + async function apply(values: SetupPageFormValues = readForm()): Promise<{ ok: boolean; status: number; payload: unknown } | undefined> { + let selection: ReturnType; + try { + selection = buildSelection(values); + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + update(); + return; + } + + const confirmed: Partial> = {}; + for (const role of options.roles) { + const binding = selection.agents[role]; + const previous = state.target?.bindings[role]; + if (!binding || (previous?.harness === binding.harness && previous.model === binding.model)) continue; + const harness = state.catalog?.models.find((candidate) => candidate.agent === binding.harness); + if (!harness?.available) continue; + const listed = harness.providers.some((provider) => provider.models.some((model) => + model.id === binding.model || model.aliases?.includes(binding.model), + )); + if (listed) continue; + const accept = options.confirm?.( + `Model "${binding.model}" is not in the ${binding.harness} catalog for ${role}. Apply it anyway?`, + ) ?? false; + if (!accept) { + state.error = `Apply cancelled for the off-catalog model selected for ${role}.`; + update(); + return; + } + confirmed[role] = true; + } + if (Object.keys(confirmed).length > 0) selection.offCatalogConfirmed = confirmed; + return await postAction("/api/setup/apply", selection); + } + + return { state, start, refreshCatalog, buildSelection, dryRun, apply }; +} + +const roleControls = ROLES.map((role) => ` +
+ ${role} + + + + + + +
`).join(""); + +export const SETUP_PAGE = ` + + + + + CodeDeck setup + + + +
+

CodeDeck setup

+

Review the current target, prepare a proposal, then apply it.

+
+ Loading target... +

Loading setup...

+

Model catalog: not loaded

+ +
+ +

Role bindings

+
${roleControls} +
+ +

Orchestrator

+
+

Current: unchanged

+ + + +
+ +

Other settings

+
+

Current sandbox: not set

+

Current autocompact: not set

+
+ +
+ + +
+

Proposal

+

+  
+ + +`; diff --git a/src/web/setup-routes.ts b/src/web/setup-routes.ts new file mode 100644 index 0000000..23d70eb --- /dev/null +++ b/src/web/setup-routes.ts @@ -0,0 +1,586 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { DriverRegistry } from "../core/driver.js"; +import { REASONING_EFFORTS } from "../core/driver.js"; +import { getBatchModels, type BatchModelsOptions, type BatchModelsResult } from "../core/models.js"; +import { isAgentId, type AgentId } from "../core/session.js"; +import { ROLES, type Role } from "../core/roles.js"; +import { + DEFAULT_CONFIG, + readConfigForSetup, + saveConfig, + serializeConfig, + type RoleBinding, + type RunAgentConfig, + type SetupConfigRead, +} from "../config/config.js"; +import { isOrchestratorMode } from "../config/orchestrator-mode.js"; +import { + buildSetupPlan, + catalogContains, + resolveSetupTarget, + validateBindings, + type BindingValidation, + type SetupBinding, + type SetupEnvelope, + type SetupSelection, +} from "../config/setup.js"; +import { getPaths } from "../config/paths.js"; +import { getRegistry } from "../drivers/registry.js"; +import { SETUP_PAGE } from "./setup-page.js"; +import type { WebRoute } from "./server.js"; + +const MAX_SETUP_BODY_BYTES = 64 * 1024; +const MODEL_PATTERN = /^[^\p{White_Space}\p{Cc}\p{Cf}=]+$/u; + +export interface SetupRoutesDependencies { + profile?: string; + readConfig?: () => SetupConfigRead; + saveConfig?: (config: RunAgentConfig) => void | boolean; + registry?: DriverRegistry; + getBatchModels?: (options: BatchModelsOptions) => Promise; + configPath?: () => string; +} + +interface LoadedSetup { + read: SetupConfigRead; + current: RunAgentConfig; + target: ReturnType; + state: BuiltSetupState; +} + +interface ReadProblem { + read: SetupConfigRead; + code: 14 | 15; + message: string; +} + +type ReadResult = { loaded: LoadedSetup } | { problem: ReadProblem }; + +export interface BuiltSetupState { + resolvedTarget: ReturnType; + target: { kind: "global" | "profile"; profile?: string }; + bindings: Partial>; + efforts: Partial>; + orchestrator?: RunAgentConfig["orchestrator"]; + sandbox?: RunAgentConfig["defaultSandbox"]; + autocompact?: RunAgentConfig["autocompact"]; +} + +interface ParsedBody { + ok: boolean; + value?: unknown; + message?: string; +} + +function jsonResponse(response: ServerResponse, status: number, value: unknown): void { + response.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(value)); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value: Record, keys: readonly string[]): boolean { + return Object.keys(value).every((key) => keys.includes(key)); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function currentConfigPath(dependencies: SetupRoutesDependencies): string { + return dependencies.configPath?.() ?? getPaths().configFile; +} + +function setupReadProblem( + read: SetupConfigRead, + dependencies: SetupRoutesDependencies, + thrown?: unknown, +): ReadProblem | undefined { + if (thrown !== undefined) { + const error = thrown instanceof Error ? thrown : new Error(String(thrown)); + return { + read: { + status: "invalid", + source: "none", + path: currentConfigPath(dependencies), + config: null, + raw: null, + message: error.message, + readError: error, + }, + code: 15, + message: `Cannot save config "${currentConfigPath(dependencies)}": ${error.message}.`, + }; + } + if (read.status !== "invalid") return undefined; + if (read.readError) { + return { + read, + code: 15, + message: `Cannot save config "${currentConfigPath(dependencies)}": ${read.message ?? read.readError.message}.`, + }; + } + return { + read, + code: 14, + message: read.message ?? `Config file "${read.path}" contains invalid JSON; no changes were written. Repair or move it and retry.`, + }; +} + +export function buildSetupState(read: SetupConfigRead, profileOption?: string): BuiltSetupState { + if (read.status === "invalid") { + throw new Error(read.message ?? `Config file "${read.path}" could not be read.`); + } + const current: RunAgentConfig = { ...DEFAULT_CONFIG, ...(read.config ?? {}) }; + const resolvedTarget = resolveSetupTarget(current, profileOption); + const bindings = resolvedTarget.config.agents ?? {}; + const efforts = Object.fromEntries(ROLES.flatMap((role) => { + const effort = bindings[role]?.effort; + return effort === undefined ? [] : [[role, effort]]; + })) as Partial>; + return { + resolvedTarget, + target: { + kind: resolvedTarget.profile === undefined ? "global" : "profile", + ...(resolvedTarget.profile === undefined ? {} : { profile: resolvedTarget.profile }), + }, + bindings, + efforts, + ...(resolvedTarget.config.orchestrator === undefined ? {} : { orchestrator: resolvedTarget.config.orchestrator }), + ...(resolvedTarget.config.defaultSandbox === undefined ? {} : { sandbox: resolvedTarget.config.defaultSandbox }), + ...(resolvedTarget.config.autocompact === undefined ? {} : { autocompact: resolvedTarget.config.autocompact }), + }; +} + +function readAndResolve(dependencies: SetupRoutesDependencies): ReadResult { + let read: SetupConfigRead; + try { + read = (dependencies.readConfig ?? readConfigForSetup)(); + } catch (error) { + const problem = setupReadProblem({ + status: "invalid", + source: "none", + path: currentConfigPath(dependencies), + config: null, + raw: null, + message: errorMessage(error), + }, dependencies, error); + return { problem: problem! }; + } + + const problem = setupReadProblem(read, dependencies); + if (problem) return { problem }; + + const current: RunAgentConfig = { ...DEFAULT_CONFIG, ...(read.config ?? {}) }; + try { + const state = buildSetupState(read, dependencies.profile); + return { loaded: { read, current, target: state.resolvedTarget, state } }; + } catch (error) { + return { + problem: { + read, + code: 14, + message: errorMessage(error), + }, + }; + } +} + +function configValidation(read: SetupConfigRead): SetupEnvelope["validacoes"]["config"] { + return { + status: read.status, + source: read.source, + path: read.path, + message: read.status === "invalid" ? read.message : null, + }; +} + +function noCatalog(): SetupEnvelope["validacoes"]["catalogo"] { + return { status: "not-needed", source: "none", ageMs: null, message: null }; +} + +function catalogValidation( + catalog: BatchModelsResult | undefined, + message: string | null, +): SetupEnvelope["validacoes"]["catalogo"] { + if (!catalog) return noCatalog(); + return { + status: catalog.status, + source: catalog.source, + ageMs: catalog.ageMs, + message, + }; +} + +function emptyEnvelope(read: SetupConfigRead, code: 14 | 15, message: string): SetupEnvelope { + return { + proposta: null, + validacoes: { + config: configValidation(read), + catalogo: noCatalog(), + bindings: [], + }, + mudancas: [], + resultado: { status: "error", code, saved: false, message }, + }; +} + +function isRole(value: string): value is Role { + return (ROLES as readonly string[]).includes(value); +} + +function isRoleBinding(value: unknown): value is RoleBinding { + if ( + !isObject(value) || + !hasOnlyKeys(value, ["harness", "model", "effort"]) || + !isAgentId(value.harness) || + typeof value.model !== "string" || + !MODEL_PATTERN.test(value.model) + ) { + return false; + } + return value.effort === undefined || (REASONING_EFFORTS as readonly string[]).includes(value.effort as string); +} + +export function isSetupSelection(value: unknown): value is SetupSelection { + if (!isObject(value)) return false; + const allowedKeys = ["agents", "orchestrator", "sandbox", "autocompact", "offCatalogConfirmed"]; + if (Object.keys(value).some((key) => !allowedKeys.includes(key))) return false; + if (!isObject(value.agents)) return false; + if (Object.entries(value.agents).some(([role, binding]) => !isRole(role) || !isRoleBinding(binding))) return false; + if (value.orchestrator !== undefined) { + if (!isObject(value.orchestrator) || !hasOnlyKeys(value.orchestrator, ["investigate", "selfWork", "tools", "parallelism"])) { + return false; + } + if (!isOrchestratorMode(value.orchestrator)) return false; + } + if ( + value.sandbox !== undefined && + value.sandbox !== "workspace-write" && + value.sandbox !== "danger-full-access" + ) return false; + if (value.autocompact !== undefined) { + if (!isObject(value.autocompact) || !hasOnlyKeys(value.autocompact, ["enabled"]) || typeof value.autocompact.enabled !== "boolean") { + return false; + } + } + if (value.offCatalogConfirmed !== undefined) { + if (!isObject(value.offCatalogConfirmed)) return false; + if (Object.entries(value.offCatalogConfirmed).some(([role, confirmed]) => !isRole(role) || typeof confirmed !== "boolean")) { + return false; + } + } + return true; +} + +async function readJsonBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + let oversized = false; + try { + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.byteLength; + if (size > MAX_SETUP_BODY_BYTES) { + oversized = true; + continue; + } + chunks.push(buffer); + } + } catch (error) { + return { ok: false, message: errorMessage(error) }; + } + if (oversized) return { ok: false, message: "Setup request body exceeds 64 KiB." }; + try { + return { ok: true, value: JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown }; + } catch { + return { ok: false, message: "Setup request body must be valid JSON." }; + } +} + +function changedBindings( + targetBindings: Partial> | undefined, + selection: SetupSelection, +): SetupBinding[] { + const current = targetBindings ?? {}; + return ROLES.flatMap((role) => { + const binding = selection.agents[role]; + if (!binding) return []; + const previous = current[role]; + if (previous?.harness === binding.harness && previous.model === binding.model) return []; + return [{ role, binding }]; + }); +} + +function catalogFailure(error: unknown): BatchModelsResult { + return { + models: [], + status: "unavailable", + source: "none", + ageMs: null, + discoveryError: errorMessage(error), + cacheWriteFailed: false, + }; +} + +function errorCodeFor(entries: BindingValidation[]): 11 | 12 | 13 | undefined { + const failed = entries.find((entry) => entry.status !== "accepted"); + if (!failed) return undefined; + if (failed.status === "harness-unavailable") return 11; + if (failed.status === "unknown-model") return 12; + return 13; +} + +function bindingMessage(entries: BindingValidation[]): string | undefined { + return entries.find((entry) => entry.status !== "accepted")?.message; +} + +function confirmedOffCatalog( + entry: BindingValidation, + selection: SetupSelection, + catalog: BatchModelsResult, +): boolean { + if (!selection.offCatalogConfirmed?.[entry.role]) return false; + if (entry.status !== "unknown-model" && entry.status !== "unverified") return false; + const harness = catalog.models.find((candidate) => candidate.agent === entry.harness); + return harness?.available === true && !catalogContains(harness, entry.model); +} + +interface BindingValidationResult { + catalog?: BatchModelsResult; + entries: BindingValidation[]; + code?: 11 | 12 | 13; + message: string | null; +} + +export function createSetupRoutes(dependencies: SetupRoutesDependencies = {}): WebRoute[] { + const loadCatalog = dependencies.getBatchModels ?? ((options: BatchModelsOptions) => + getBatchModels(dependencies.registry ?? getRegistry(), options)); + let refreshInFlight: Promise | undefined; + + function refreshCatalog(): Promise { + if (refreshInFlight) return refreshInFlight; + const pending = loadCatalog({ refresh: true, allowNetwork: true, timeoutMs: 12_000 }); + const inFlight = pending.finally(() => { + if (refreshInFlight === inFlight) refreshInFlight = undefined; + }); + refreshInFlight = inFlight; + return inFlight; + } + + async function validateChanged( + bindings: SetupBinding[], + selection: SetupSelection, + allowConfirmation: boolean, + ): Promise { + if (bindings.length === 0) return { entries: [], message: null }; + let catalog: BatchModelsResult; + try { + catalog = await loadCatalog({ + agents: [...new Set(bindings.map(({ binding }) => binding.harness))] as AgentId[], + allowNetwork: false, + }); + } catch (error) { + catalog = catalogFailure(error); + } + const validation = validateBindings(bindings, catalog); + const entries = validation.entries.map((entry) => + allowConfirmation && confirmedOffCatalog(entry, selection, catalog) + ? { ...entry, status: "accepted" as const, message: "" } + : entry, + ); + const code = errorCodeFor(entries); + const message = bindingMessage(entries) ?? validation.message; + return { catalog, entries, code, message }; + } + + function method(request: IncomingMessage, response: ServerResponse, expected: string): boolean { + if (request.method === expected) return true; + response.writeHead(405, { Allow: expected, "content-type": "text/plain; charset=utf-8" }); + response.end("method not allowed"); + return false; + } + + function page(_request: IncomingMessage, response: ServerResponse): void { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(SETUP_PAGE); + } + + function stateRoute(_request: IncomingMessage, response: ServerResponse): void { + const result = readAndResolve(dependencies); + if ("problem" in result) { + jsonResponse(response, 500, { error: result.problem.message, code: result.problem.code }); + return; + } + const { read, state } = result.loaded; + jsonResponse(response, 200, { + config: { status: read.status, source: read.source, path: read.path }, + target: state.target, + bindings: state.bindings, + efforts: state.efforts, + ...(state.orchestrator === undefined ? {} : { orchestrator: state.orchestrator }), + ...(state.sandbox === undefined ? {} : { sandbox: state.sandbox }), + ...(state.autocompact === undefined ? {} : { autocompact: state.autocompact }), + }); + } + + async function catalogRoute(_request: IncomingMessage, response: ServerResponse): Promise { + try { + const catalog = await loadCatalog({ allowNetwork: false }); + jsonResponse(response, 200, catalog); + } catch (error) { + jsonResponse(response, 500, { error: errorMessage(error) }); + } + } + + async function refreshRoute(_request: IncomingMessage, response: ServerResponse): Promise { + try { + jsonResponse(response, 200, await refreshCatalog()); + } catch (error) { + jsonResponse(response, 500, { error: errorMessage(error) }); + } + } + + async function mutationRoute( + request: IncomingMessage, + response: ServerResponse, + dryRun: boolean, + ): Promise { + const body = await readJsonBody(request); + if (!body.ok || !isSetupSelection(body.value)) { + jsonResponse(response, 400, { error: body.message ?? "Setup request body has an invalid shape." }); + return; + } + const selection = body.value; + const result = readAndResolve(dependencies); + if ("problem" in result) { + jsonResponse(response, 500, emptyEnvelope(result.problem.read, result.problem.code, result.problem.message)); + return; + } + + const { read, current, target } = result.loaded; + const plan = buildSetupPlan(current, target, selection); + const changed = changedBindings(target.config.agents, selection); + let validation: BindingValidationResult; + try { + validation = await validateChanged(changed, selection, !dryRun); + } catch (error) { + validation = { + catalog: catalogFailure(error), + entries: changed.map(({ role, binding }) => ({ + role, + harness: binding.harness, + model: binding.model, + status: "unverified", + message: errorMessage(error), + })), + code: 13, + message: errorMessage(error), + }; + } + const validations: SetupEnvelope["validacoes"] = { + config: configValidation(read), + catalogo: catalogValidation(validation.catalog, validation.message), + bindings: validation.entries, + }; + + if (validation.code !== undefined) { + const failed = bindingMessage(validation.entries) ?? validation.message ?? "Setup validation failed."; + jsonResponse(response, 422, { + proposta: plan.proposedConfig, + validacoes: validations, + mudancas: plan.diff, + resultado: { status: "error", code: validation.code, saved: false, message: failed }, + } satisfies SetupEnvelope); + return; + } + + if (plan.diff.length === 0) { + jsonResponse(response, 200, { + proposta: plan.proposedConfig, + validacoes: validations, + mudancas: plan.diff, + resultado: { status: "unchanged", code: 0, saved: false, message: "Configuration unchanged." }, + } satisfies SetupEnvelope); + return; + } + + if (dryRun) { + jsonResponse(response, 200, { + proposta: plan.proposedConfig, + validacoes: validations, + mudancas: plan.diff, + resultado: { status: "dry-run", code: 0, saved: false, message: "Dry run; configuration not written." }, + } satisfies SetupEnvelope); + return; + } + + const serialized = serializeConfig(plan.proposedConfig); + if (read.source === "canonical" && read.raw === serialized) { + jsonResponse(response, 200, { + proposta: plan.proposedConfig, + validacoes: validations, + mudancas: [], + resultado: { status: "unchanged", code: 0, saved: false, message: "Configuration unchanged." }, + } satisfies SetupEnvelope); + return; + } + + try { + const saved = (dependencies.saveConfig ?? saveConfig)(plan.proposedConfig); + if (saved === false) { + jsonResponse(response, 200, { + proposta: plan.proposedConfig, + validacoes: validations, + mudancas: [], + resultado: { status: "unchanged", code: 0, saved: false, message: "Configuration unchanged." }, + } satisfies SetupEnvelope); + return; + } + } catch (error) { + const message = `Cannot save config "${currentConfigPath(dependencies)}": ${errorMessage(error)}.`; + jsonResponse(response, 500, { + proposta: plan.proposedConfig, + validacoes: validations, + mudancas: plan.diff, + resultado: { status: "error", code: 15, saved: false, message }, + } satisfies SetupEnvelope); + return; + } + + jsonResponse(response, 200, { + proposta: plan.proposedConfig, + validacoes: validations, + mudancas: plan.diff, + resultado: { status: "applied", code: 0, saved: true, message: "Configuration saved." }, + } satisfies SetupEnvelope); + } + + const route = ( + path: string, + kind: WebRoute["kind"], + handler: (request: IncomingMessage, response: ServerResponse) => void | Promise, + label?: string, + ): WebRoute => ({ path, kind, handler, ...(label === undefined ? {} : { label }) }); + + return [ + route("/setup", "page", page, "Setup"), + route("/api/setup/state", "api", (request, response) => { + if (method(request, response, "GET")) stateRoute(request, response); + }), + route("/api/setup/catalog", "api", (request, response) => { + if (method(request, response, "GET")) void catalogRoute(request, response); + }), + route("/api/setup/catalog/refresh", "api", (request, response) => { + if (method(request, response, "POST")) void refreshRoute(request, response); + }), + route("/api/setup/dry-run", "api", (request, response) => { + if (method(request, response, "POST")) void mutationRoute(request, response, true); + }), + route("/api/setup/apply", "api", (request, response) => { + if (method(request, response, "POST")) void mutationRoute(request, response, false); + }), + ]; +} diff --git a/src/web/usage-page.ts b/src/web/usage-page.ts new file mode 100644 index 0000000..9b3b452 --- /dev/null +++ b/src/web/usage-page.ts @@ -0,0 +1,432 @@ +import type { UsageMetricBucket, UsageQueryResult } from "../daemon/protocol.js"; + +export type UsageBreakdown = "day" | "repo" | "model" | "agent" | "run" | "origin"; + +export interface UsagePageFilters { + period: string; + repo: string; + model: string; + agent: string; + since: string; + until: string; +} + +export interface UsagePageData extends Omit { + byOrigin: UsageMetricBucket[]; +} + +export interface UsagePageState { + filters: UsagePageFilters; + result: UsagePageData | null; + error?: string; + loading: boolean; + selectedBreakdown: UsageBreakdown; + breakdowns: UsageBreakdown[]; + intervalSeconds: number; +} + +export interface UsagePageFetchResponse { + ok: boolean; + status?: number; + json(): Promise; +} + +export interface UsagePageControllerOptions { + initialFilters?: Partial; + initialBy?: UsageBreakdown; + interval?: unknown; + fetch: (url: string) => Promise; + setInterval: (callback: () => void, milliseconds: number) => unknown; + clearInterval: (handle: unknown) => void; + render: (state: UsagePageState) => void; +} + +export interface UsagePageController { + getState(): UsagePageState; + refresh(): Promise; + setFilter(name: keyof UsagePageFilters, value: string): Promise; + setBreakdown(name: UsageBreakdown): void; + start(): Promise; + stop(): void; +} + +export interface UsagePageOptions { + by?: UsageBreakdown; + interval?: unknown; + filters?: Partial; +} + +export interface UsagePageEnvironment { + fetch: (url: string) => Promise; + setInterval: (callback: () => void, milliseconds: number) => unknown; + clearInterval: (handle: unknown) => void; + document: UsagePageDocument; +} + +interface UsagePageElement { + hidden: boolean; + textContent: string | null; + className: string; + value: string; + dataset: Record; + querySelector(selector: string): T | null; + querySelectorAll(selector: string): T[]; + setAttribute(name: string, value: string): void; + replaceChildren(...nodes: UsagePageElement[]): void; + append(...nodes: UsagePageElement[]): void; + addEventListener(type: string, listener: (event: UsagePageEvent) => void): void; + closest(selector: string): T | null; +} + +interface UsagePageDocument { + getElementById(id: string): UsagePageElement | null; + createElement(tagName: string): UsagePageElement; +} + +interface UsagePageEvent { + target?: UsagePageElement | null; +} + +export function normalizeUsageInterval(value: unknown): number { + return Math.max(1, Number(value) || 2); +} + +export function buildUsageApiUrl(filters: UsagePageFilters): string { + const params: string[] = []; + for (const name of ["period", "repo", "model", "agent", "since", "until"] as const) { + const value = filters[name]; + if (value) params.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`); + } + const query = params.join("&"); + return query ? `/api/usage?${query}` : "/api/usage"; +} + +export function toUsagePageData(result: UsageQueryResult): UsagePageData { + return { + range: { ...result.range }, + totals: { + sessionCount: result.totals.sessionCount, + activeSessionCount: result.totals.activeSessionCount, + completedSessionCount: result.totals.completedSessionCount, + failedSessionCount: result.totals.failedSessionCount, + inputTokens: result.totals.inputTokens, + outputTokens: result.totals.outputTokens, + cachedTokens: result.totals.cachedTokens, + totalTokens: result.totals.totalTokens, + costUsd: result.totals.costUsd, + costComplete: result.totals.costComplete, + sessionsWithoutCost: result.totals.sessionsWithoutCost, + }, + byDay: [...result.byDay], + byRepository: [...result.byRepository], + byModel: [...result.byModel], + byAgent: [...result.byAgent], + byRun: [...result.byRun], + byOrigin: [...(result.byOrigin ?? [])], + }; +} + +export function createUsagePageController(options: UsagePageControllerOptions): UsagePageController { + const breakdowns: UsageBreakdown[] = ["day", "repo", "model", "agent", "run", "origin"]; + const state: UsagePageState = { + filters: { + period: options.initialFilters?.period ?? (options.initialFilters?.since ? "" : "today"), + repo: options.initialFilters?.repo ?? "", + model: options.initialFilters?.model ?? "", + agent: options.initialFilters?.agent ?? "", + since: options.initialFilters?.since ?? "", + until: options.initialFilters?.until ?? "", + }, + result: null, + error: undefined, + loading: false, + selectedBreakdown: breakdowns.includes(options.initialBy as UsageBreakdown) + ? options.initialBy as UsageBreakdown + : "day", + breakdowns, + intervalSeconds: normalizeUsageInterval(options.interval), + }; + let timer: unknown; + let started = false; + + function getState(): UsagePageState { + return { + ...state, + filters: { ...state.filters }, + breakdowns: [...state.breakdowns], + }; + } + + function publish(): void { + options.render(getState()); + } + + async function refresh(): Promise { + state.loading = true; + publish(); + try { + const response = await options.fetch(buildUsageApiUrl(state.filters)); + let payload: unknown; + try { + payload = await response.json(); + } catch { + payload = undefined; + } + if (!response.ok) { + const message = + payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string" + ? payload.error + : `Usage query failed with HTTP ${response.status ?? 500}`; + throw new Error(message); + } + state.result = toUsagePageData(payload as UsageQueryResult); + state.error = undefined; + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + } finally { + state.loading = false; + publish(); + } + return getState(); + } + + async function setFilter(name: keyof UsagePageFilters, value: string): Promise { + if (!(name in state.filters)) return getState(); + const nextValue = String(value); + if (state.filters[name] === nextValue) return getState(); + state.filters[name] = nextValue; + return refresh(); + } + + function setBreakdown(name: UsageBreakdown): void { + if (!state.breakdowns.includes(name)) return; + state.selectedBreakdown = name; + publish(); + } + + async function start(): Promise { + if (started) return; + started = true; + await refresh(); + timer = options.setInterval(() => { + void refresh(); + }, state.intervalSeconds * 1000); + } + + function stop(): void { + if (!started) return; + started = false; + options.clearInterval(timer); + timer = undefined; + } + + return { getState, refresh, setFilter, setBreakdown, start, stop }; +} + +export function renderUsagePageState(root: UsagePageElement, state: UsagePageState, document: UsagePageDocument): void { + const error = root.querySelector("[data-error]"); + if (error) { + error.hidden = !state.error; + error.textContent = state.error ?? ""; + } + + const loading = root.querySelector("[data-loading]"); + if (loading) loading.textContent = state.loading ? "Updating usage..." : ""; + + for (const button of root.querySelectorAll("[data-breakdown]")) { + button.setAttribute("aria-pressed", String(button.dataset.breakdown === state.selectedBreakdown)); + } + + const totalsRoot = root.querySelector("[data-totals]"); + const breakdownRoot = root.querySelector("[data-breakdowns]"); + totalsRoot?.replaceChildren(); + breakdownRoot?.replaceChildren(); + if (!state.result) return; + + const totalLabels: Record = { + sessionCount: "Sessions", + activeSessionCount: "Active sessions", + completedSessionCount: "Completed sessions", + failedSessionCount: "Failed sessions", + inputTokens: "Input tokens", + outputTokens: "Output tokens", + cachedTokens: "Cached tokens", + totalTokens: "Total tokens", + costUsd: "Cost (USD)", + costComplete: "Cost complete", + sessionsWithoutCost: "Sessions without cost", + }; + for (const [name, label] of Object.entries(totalLabels) as [keyof UsageQueryResult["totals"], string][]) { + const card = document.createElement("article"); + card.className = "usage-total"; + const heading = document.createElement("h3"); + heading.textContent = label; + const value = document.createElement("p"); + const rawValue = state.result.totals[name]; + value.textContent = name === "costUsd" + ? `$${Number(rawValue).toFixed(2)}${state.result.totals.costComplete ? "" : "?"}` + : name === "costComplete" + ? rawValue ? "Complete" : "Incomplete" + : String(rawValue); + card.append(heading, value); + totalsRoot?.append(card); + } + + const groups: [UsageBreakdown, string, UsageMetricBucket[]][] = [ + ["day", "By day", state.result.byDay], + ["repo", "By repository", state.result.byRepository], + ["model", "By model", state.result.byModel], + ["agent", "By agent", state.result.byAgent], + ["run", "By run", state.result.byRun], + ["origin", "By origin", state.result.byOrigin], + ]; + for (const [name, title, buckets] of groups) { + const section = document.createElement("section"); + section.className = name === state.selectedBreakdown ? "usage-breakdown selected" : "usage-breakdown"; + section.dataset.breakdownSection = name; + const heading = document.createElement("h2"); + heading.textContent = title; + section.append(heading); + if (buckets.length === 0) { + const empty = document.createElement("p"); + empty.className = "empty"; + empty.textContent = "No usage recorded."; + section.append(empty); + } + for (const bucket of buckets) { + const row = document.createElement("article"); + row.className = "usage-bucket"; + const label = document.createElement("strong"); + label.textContent = bucket.label ?? bucket.key; + const metrics = document.createElement("span"); + const cost = `$${bucket.costUsd.toFixed(2)}${bucket.costComplete ? "" : "?"}`; + metrics.textContent = `${bucket.sessionCount} sessions · ${bucket.inputTokens} input · ${bucket.outputTokens} output · ${bucket.cachedTokens} cached · ${cost}`; + row.append(label, metrics); + section.append(row); + } + breakdownRoot?.append(section); + } +} + +export function startUsagePage(options: UsagePageOptions, environment: UsagePageEnvironment): UsagePageController | undefined { + const root = environment.document.getElementById("usage-root"); + if (!root) return undefined; + + for (const control of root.querySelectorAll("[data-filter]")) { + const name = control.dataset.filter as keyof UsagePageFilters | undefined; + if (name && options.filters?.[name] !== undefined) control.value = options.filters[name] ?? ""; + } + + const controller = createUsagePageController({ + initialFilters: options.filters, + initialBy: options.by, + interval: options.interval, + fetch: environment.fetch, + setInterval: environment.setInterval, + clearInterval: environment.clearInterval, + render: (state) => renderUsagePageState(root, state, environment.document), + }); + + root.addEventListener("change", (event) => { + const target = event.target ?? null; + const name = target?.dataset.filter as keyof UsagePageFilters | undefined; + if (target && name) void controller.setFilter(name, target.value); + }); + root.addEventListener("click", (event) => { + const target = event.target ?? null; + const button = target?.closest("[data-breakdown]"); + const name = button?.dataset.breakdown as UsageBreakdown | undefined; + if (name) controller.setBreakdown(name); + }); + void controller.start(); + return controller; +} + +export function renderUsagePage(options: UsagePageOptions = {}): string { + const pageOptions = { + by: options.by ?? "day", + interval: options.interval ?? 2, + filters: options.filters ?? {}, + }; + const serializedOptions = JSON.stringify(pageOptions).replaceAll("<", "\\u003c"); + const behaviorSource = [ + normalizeUsageInterval, + buildUsageApiUrl, + toUsagePageData, + createUsagePageController, + renderUsagePageState, + startUsagePage, + ].map((behavior) => Function.prototype.toString.call(behavior)).join("\n\n"); + + return ` + + + + +CodeDeck usage + + + + +

Usage

Token totals, costs, and session breakdowns

+
+
+ + + + + + +
+
+ +

Totals

+

Breakdowns

+ +
+
+
+ + +`; +} + +export const USAGE_PAGE = renderUsagePage(); diff --git a/src/web/usage-routes.ts b/src/web/usage-routes.ts new file mode 100644 index 0000000..b517a0f --- /dev/null +++ b/src/web/usage-routes.ts @@ -0,0 +1,92 @@ +import type { ServerResponse } from "node:http"; +import { buildUsageQueryParams, type UsageQueryOptions } from "../core/usage-query.js"; +import type { UsageQueryParams, UsageQueryResult } from "../daemon/protocol.js"; +import type { WebRoute } from "./server.js"; +import { renderUsagePage, type UsagePageOptions } from "./usage-page.js"; + +export interface UsageRoutesOptions { + fetchUsageQuery: (params: UsageQueryParams) => Promise; + cwd?: string; + now?: () => Date; + page?: UsagePageOptions; +} + +export function parseUsageWebQuery( + searchParams: URLSearchParams, + cwd: string, + now: Date = new Date(), +): UsageQueryParams { + const period = searchParams.get("period"); + const periodDays = period && /^\d+d$/.test(period) ? period.slice(0, -1) : undefined; + const opts: UsageQueryOptions = { + all: readBoolean(searchParams, "all") || period === "all", + today: readBoolean(searchParams, "today") || period === "today", + days: readValue(searchParams, "days") ?? periodDays, + since: readValue(searchParams, "since"), + until: readValue(searchParams, "until"), + repo: readValue(searchParams, "repo"), + current: readBoolean(searchParams, "current"), + model: readValue(searchParams, "model"), + agent: readValue(searchParams, "agent"), + }; + return buildUsageQueryParams(opts, cwd, now); +} + +export function createUsageRoutes(options: UsageRoutesOptions): WebRoute[] { + const cwd = options.cwd ?? process.cwd(); + const now = options.now ?? (() => new Date()); + + return [ + { + path: "/usage", + kind: "page", + handler: (_request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(renderUsagePage(options.page)); + }, + }, + { + path: "/api/usage", + kind: "api", + handler: async (request, response) => { + if (request.method !== "GET") { + writeJson(response, 405, { error: "method not allowed" }); + return; + } + + let searchParams: URLSearchParams; + try { + searchParams = new URL(request.url ?? "/api/usage", "http://127.0.0.1").searchParams; + } catch { + writeJson(response, 400, { error: "bad request" }); + return; + } + + try { + const params = parseUsageWebQuery(searchParams, cwd, now()); + const result = await options.fetchUsageQuery(params); + writeJson(response, 200, result); + } catch (error) { + writeJson(response, 500, { + error: error instanceof Error ? error.message : String(error), + }); + } + }, + }, + ]; +} + +function readValue(searchParams: URLSearchParams, name: string): string | undefined { + const value = searchParams.get(name); + return value ? value : undefined; +} + +function readBoolean(searchParams: URLSearchParams, name: string): boolean { + const value = searchParams.get(name); + return value !== null && (value === "" || value === "1" || value.toLowerCase() === "true"); +} + +function writeJson(response: ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(body)); +} diff --git a/tests/setup-cli-contract.test.ts b/tests/setup-cli-contract.test.ts index 2b45033..9894af1 100644 --- a/tests/setup-cli-contract.test.ts +++ b/tests/setup-cli-contract.test.ts @@ -26,9 +26,11 @@ 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, @@ -652,3 +654,97 @@ describe("config store seam", () => { expect(saveConfig({ defaultModel: "valid" })).toBe(true); }); }); + +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)); + const stderr = new MemoryWritable(); + const result = await executeSetupAction([], { isTTY: false, startServer, 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(); + }); + + it("uses the frozen wizard for --tui and does not start the web server", async () => { + const runWizard = vi.fn(async () => ({})); + const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + + const result = await executeSetupAction(["--tui"], { isTTY: true, runWizard, startServer }); + + expect(result.code).toBe(0); + expect(runWizard).toHaveBeenCalledOnce(); + expect(startServer).not.toHaveBeenCalled(); + }); + + it.each(["--json", "--dry-run", "--non-interactive"])( + "rejects --port with %s before starting the server", + async (flag) => { + const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + const stderr = new MemoryWritable(); + const result = await executeSetupAction([flag, "--port", "3201"], { + isTTY: true, + startServer, + stderr, + }); + + expect(result.code).toBe(2); + expect(stderr.text()).toContain('Option "--port" cannot be used'); + expect(startServer).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 stderr = new MemoryWritable(); + + const result = await executeSetupAction(["--tui", "--non-interactive"], { + isTTY: true, + runWizard, + startServer, + 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 the profile and refresh behavior to the setup page route", async () => { + const configFile = getPaths().configFile; + fs.mkdirSync(path.dirname(configFile), { recursive: true, mode: 0o700 }); + fs.writeFileSync(configFile, serializeConfig({ + ...DEFAULT_CONFIG, + profiles: { staging: { agents: { reviewer: { harness: "codex", model: "gpt-5" } } } }, + }), "utf8"); + + let captured: WebServerOptions | undefined; + const startServer: NonNullable = async (options) => { + captured = options; + return {} as WebServerHandle; + }; + const result = await executeSetupAction( + ["--profile", "staging", "--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: "profile", + profile: "staging", + }); + }); +}); diff --git a/tests/setup-page.test.ts b/tests/setup-page.test.ts new file mode 100644 index 0000000..f3638a7 --- /dev/null +++ b/tests/setup-page.test.ts @@ -0,0 +1,319 @@ +import { runInNewContext } from "node:vm"; +import { describe, expect, it, vi } from "vitest"; +import { REASONING_EFFORTS } from "../src/core/driver.js"; +import { ROLES, type Role } from "../src/core/roles.js"; +import { ORCHESTRATOR_PRESETS } from "../src/config/orchestrator-mode.js"; +import type { RoleBinding } from "../src/config/config.js"; +import { + buildSetupSelection, + createSetupPageController, + SETUP_PAGE, + SETUP_SESSION_EXPIRED_MESSAGE, + type SetupPageDocument, + type SetupPageElement, + type SetupPageFormValues, + type SetupPageResponse, +} from "../src/web/setup-page.js"; + +function form(overrides: Partial = {}): SetupPageFormValues { + return { + roles: Object.fromEntries(ROLES.map((role) => [role, { skip: true, binding: "", effort: "keep" }])), + orchestrator: "skip", + investigate: "none", + selfWork: "none", + tools: "dispatch", + parallelism: "", + sandbox: "skip", + autocompact: "skip", + ...overrides, + }; +} + +function build(values: SetupPageFormValues, bindings: Partial>) { + return buildSetupSelection(values, bindings, { + roles: ROLES, + efforts: REASONING_EFFORTS, + presets: ORCHESTRATOR_PRESETS, + }); +} + +function response(payload: unknown, status = 200): SetupPageResponse { + return { status, ok: status >= 200 && status < 300, json: async () => payload }; +} + +function fakeDocument(): { document: SetupPageDocument; elements: Map } { + const elements = new Map(); + return { + elements, + document: { + getElementById(id) { + let element = elements.get(id); + if (!element) { + element = { value: "", checked: false, disabled: false, hidden: false, textContent: "" }; + elements.set(id, element); + } + return element; + }, + }, + }; +} + +describe("setup page selection", () => { + it("offers every role, free-text binding fields, effort choices, and all setup fields", () => { + for (const role of ROLES) { + expect(SETUP_PAGE).toContain(`id="binding-${role}" type="text"`); + expect(SETUP_PAGE).toContain(`id="effort-${role}"`); + expect(SETUP_PAGE).toContain(`id="skip-${role}" type="checkbox"`); + for (const effort of REASONING_EFFORTS) expect(SETUP_PAGE).toContain(``); + } + expect(SETUP_PAGE).toContain('option value="dispatcher"'); + expect(SETUP_PAGE).toContain('option value="balanced"'); + expect(SETUP_PAGE).toContain('option value="explorer"'); + expect(SETUP_PAGE).toContain('id="orchestrator-investigate"'); + expect(SETUP_PAGE).toContain('id="orchestrator-self-work"'); + expect(SETUP_PAGE).toContain('id="orchestrator-tools"'); + expect(SETUP_PAGE).toContain("danger-full-access"); + expect(SETUP_PAGE).toContain('value="workspace-write"'); + expect(SETUP_PAGE).toContain('value="on"'); + expect(SETUP_PAGE).toContain('value="off"'); + expect(SETUP_PAGE).toContain('value="custom"'); + expect(SETUP_PAGE).toContain('id="orchestrator-parallelism" type="number"'); + }); + + it("preserves skipped bindings and unchanged effort, and leaves an omitted orchestrator untouched", () => { + const values = form({ + roles: { + general: { skip: true, binding: "", effort: "keep" }, + reviewer: { skip: false, binding: "codex:typed-model", effort: "keep" }, + auditor: { skip: false, binding: "opencode:code-model", effort: "high" }, + }, + }); + const selection = build(values, { + general: { harness: "claude", model: "legacy", effort: "medium" }, + reviewer: { harness: "codex", model: "typed-model", effort: "high" }, + auditor: { harness: "opencode", model: "code-model", effort: "low" }, + }); + + expect(selection.agents).toEqual({ + reviewer: { harness: "codex", model: "typed-model", effort: "high" }, + auditor: { harness: "opencode", model: "code-model", effort: "low" }, + }); + expect(Object.hasOwn(selection, "orchestrator")).toBe(false); + }); + + it("keeps the empty agents sentinel when every first-run role is skipped", () => { + expect(build(form(), {})).toEqual({ agents: {} }); + }); + + it("stores custom parallelism as a positive finite number and maps the other controls", () => { + const selection = build(form({ + orchestrator: "custom", + investigate: "free", + selfWork: "small", + tools: "edit", + parallelism: "3.5", + sandbox: "danger-full-access", + autocompact: "off", + }), {}); + + expect(selection.orchestrator).toEqual({ investigate: "free", selfWork: "small", tools: "edit", parallelism: 3.5 }); + expect(selection.sandbox).toBe("danger-full-access"); + expect(selection.autocompact).toEqual({ enabled: false }); + }); + + it("does not expose an effort control for an opencode binding", async () => { + const { document, elements } = fakeDocument(); + const controller = createSetupPageController({ + fetcher: async (path) => path === "/api/setup/state" + ? response({ target: { kind: "global" }, bindings: { general: { harness: "opencode", model: "code-model" } } }) + : response({ models: [], status: "fresh", source: "cache", ageMs: 0, cacheWriteFailed: false }), + buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { + roles: ROLES, + efforts: REASONING_EFFORTS, + presets: ORCHESTRATOR_PRESETS, + }), + roles: ROLES, + expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, + document, + }); + + await controller.start(); + + expect(elements.get("effort-general")?.hidden).toBe(true); + expect(elements.get("effort-general")?.disabled).toBe(true); + }); + + it("shows discovery while refreshing and retains the previous catalog when discovery is unavailable", async () => { + const { document, elements } = fakeDocument(); + let finishRefresh: ((value: SetupPageResponse) => void) | undefined; + const requests: Array<[string, string]> = []; + const previousCatalog = { + models: [{ agent: "codex", available: true, providers: [{ provider: "openai", models: [] }] }], + status: "fresh" as const, + source: "cache" as const, + ageMs: 50, + cacheWriteFailed: false, + }; + const controller = createSetupPageController({ + fetcher: async (path, init) => { + requests.push([path, init?.method ?? "GET"]); + return new Promise((resolve) => { finishRefresh = resolve; }); + }, + buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { + roles: ROLES, + efforts: REASONING_EFFORTS, + presets: ORCHESTRATOR_PRESETS, + }), + roles: ROLES, + expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, + document, + }); + controller.state.catalog = previousCatalog; + + const pending = controller.refreshCatalog(); + expect(controller.state.refreshing).toBe(true); + expect(elements.get("setup-status")?.textContent).toBe("Discovering models..."); + finishRefresh?.(response({ + models: [], + status: "unavailable", + source: "none", + ageMs: null, + cacheWriteFailed: false, + discoveryError: "network discovery failed", + })); + await pending; + + expect(requests).toEqual([["/api/setup/catalog/refresh", "POST"]]); + expect(controller.state.catalog).toBe(previousCatalog); + expect(controller.state.discoveryError).toBe("network discovery failed"); + expect(controller.state.refreshing).toBe(false); + }); + + it("asks per role before sending an off-catalog changed model in apply", async () => { + const { document } = fakeDocument(); + const posted: unknown[] = []; + const confirm = vi.fn(() => true); + const controller = createSetupPageController({ + fetcher: async (_path, init) => { + if (init?.method === "POST") posted.push(JSON.parse(init.body ?? "{}")); + return response({ resultado: { status: "applied", saved: true } }); + }, + buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { + roles: ROLES, + efforts: REASONING_EFFORTS, + presets: ORCHESTRATOR_PRESETS, + }), + roles: ROLES, + expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, + document, + confirm, + }); + controller.state.target = { target: { kind: "global" }, bindings: {}, efforts: {} }; + controller.state.catalog = { + models: [{ + agent: "codex", + available: true, + providers: [{ provider: "openai", models: [{ id: "known", name: "Known", provider: "openai" }] }], + }], + status: "fresh", + source: "cache", + ageMs: 0, + cacheWriteFailed: false, + }; + + await controller.apply(form({ + roles: { ...form().roles, reviewer: { skip: false, binding: "codex:typed-model", effort: "keep" } }, + })); + + expect(confirm).toHaveBeenCalledWith('Model "typed-model" is not in the codex catalog for reviewer. Apply it anyway?'); + expect((posted[0] as { offCatalogConfirmed: unknown }).offCatalogConfirmed).toEqual({ reviewer: true }); + }); + + it("does not send an off-catalog apply when the per-role confirmation is declined", async () => { + const { document } = fakeDocument(); + const fetcher = vi.fn(async () => response({ resultado: { status: "applied", saved: true } })); + const controller = createSetupPageController({ + fetcher, + buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { + roles: ROLES, + efforts: REASONING_EFFORTS, + presets: ORCHESTRATOR_PRESETS, + }), + roles: ROLES, + expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, + document, + confirm: () => false, + }); + controller.state.target = { target: { kind: "global" }, bindings: {}, efforts: {} }; + controller.state.catalog = { + models: [{ agent: "codex", available: true, providers: [{ provider: "openai", models: [] }] }], + status: "fresh", + source: "cache", + ageMs: 0, + cacheWriteFailed: false, + }; + + await controller.apply(form({ + roles: { ...form().roles, reviewer: { skip: false, binding: "codex:typed-model", effort: "keep" } }, + })); + + expect(fetcher).not.toHaveBeenCalled(); + expect(controller.state.error).toBe("Apply cancelled for the off-catalog model selected for reviewer."); + }); + + it("shows the exact reload and restart message after a protected action returns 403", async () => { + const { document } = fakeDocument(); + const controller = createSetupPageController({ + fetcher: async (_path, init) => init?.method === "POST" + ? response({ error: "forbidden" }, 403) + : response({}), + buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { + roles: ROLES, + efforts: REASONING_EFFORTS, + presets: ORCHESTRATOR_PRESETS, + }), + roles: ROLES, + expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, + document, + }); + controller.state.target = { target: { kind: "global" }, bindings: {}, efforts: {} }; + + await controller.apply(form()); + + expect(controller.state.error).toBe( + "This CodeDeck session has expired. Reload the page. If it still fails, restart the command and open its new URL.", + ); + }); +}); + +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(/