From 0309ebaad7fe8c8865be58962c4b67b6eeb3e0dd Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:01:45 -0300 Subject: [PATCH 1/6] feat(web): Add the usage page and usage query route --- src/web/usage-page.ts | 432 +++++++++++++++++++++++++++++++++++++++ src/web/usage-routes.ts | 92 +++++++++ tests/usage-page.test.ts | 248 ++++++++++++++++++++++ tests/usage-web.test.ts | 158 ++++++++++++++ 4 files changed, 930 insertions(+) create mode 100644 src/web/usage-page.ts create mode 100644 src/web/usage-routes.ts create mode 100644 tests/usage-page.test.ts create mode 100644 tests/usage-web.test.ts 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/usage-page.test.ts b/tests/usage-page.test.ts new file mode 100644 index 0000000..35c0043 --- /dev/null +++ b/tests/usage-page.test.ts @@ -0,0 +1,248 @@ +import vm from "node:vm"; +import { describe, expect, it, vi } from "vitest"; +import type { UsageMetricBucket, UsageQueryResult } from "../src/daemon/protocol.js"; +import { + createUsagePageController, + normalizeUsageInterval, + renderUsagePage, + toUsagePageData, + USAGE_PAGE, + type UsagePageControllerOptions, + type UsagePageFetchResponse, + type UsagePageState, +} from "../src/web/usage-page.js"; + +const totals = { + sessionCount: 2, + activeSessionCount: 1, + completedSessionCount: 1, + failedSessionCount: 0, + inputTokens: 100, + outputTokens: 20, + cachedTokens: 5, + totalTokens: 125, + costUsd: 0.25, + costComplete: true, + sessionsWithoutCost: 0, +}; + +const bucket: UsageMetricBucket = { + key: "codex", + label: "Codex", + sessionCount: 2, + inputTokens: 100, + outputTokens: 20, + cachedTokens: 5, + totalTokens: 125, + costUsd: 0.25, + costComplete: true, + trend: [0.1, 0.15], +}; + +const usageResult: UsageQueryResult = { + range: { period: "7d", since: "2026-09-16T00:00:00.000Z", until: "2026-09-22T23:59:59.999Z" }, + totals, + byDay: [bucket], + byRepository: [{ ...bucket, key: "/workspace" }], + byModel: [{ ...bucket, key: "gpt-5.6-luna" }], + byAgent: [bucket], + byRun: [{ ...bucket, key: "run-1" }], + byOrigin: [{ ...bucket, key: "orchestrator" }], +}; + +function response(value: unknown, ok = true): UsagePageFetchResponse { + return { ok, json: async () => value }; +} + +function controllerOptions(overrides: Partial = {}) { + const fetch = vi.fn(async () => response(usageResult)); + const setInterval = vi.fn((_callback: () => void, _milliseconds: number) => 1); + const clearInterval = vi.fn(); + const render = vi.fn((_state: UsagePageState) => {}); + return { + fetch, + setInterval, + clearInterval, + render, + ...overrides, + }; +} + +describe("usage page data", () => { + it("exposes every total and all available breakdown buckets", () => { + const data = toUsagePageData(usageResult); + + expect(data.totals).toEqual(totals); + expect(data.byDay).toEqual([bucket]); + expect(data.byRepository).toEqual([{ ...bucket, key: "/workspace" }]); + expect(data.byModel).toEqual([{ ...bucket, key: "gpt-5.6-luna" }]); + expect(data.byAgent).toEqual([bucket]); + expect(data.byRun).toEqual([{ ...bucket, key: "run-1" }]); + expect(data.byOrigin).toEqual([{ ...bucket, key: "orchestrator" }]); + }); + + it("uses an empty origin breakdown for a result from an older daemon", () => { + const olderResult = { ...usageResult } as UsageQueryResult & { byOrigin?: UsageMetricBucket[] }; + delete olderResult.byOrigin; + + const data = toUsagePageData(olderResult); + + expect(data.byOrigin).toEqual([]); + expect(data.byDay).toEqual([bucket]); + expect(data.totals).toEqual(totals); + }); +}); + +describe("usage page behavior", () => { + it("re-queries when period, repo, model, agent, since, and until change", async () => { + const options = controllerOptions({ + initialFilters: { period: "3d", repo: "/old", model: "", agent: "", since: "", until: "" }, + }); + const controller = createUsagePageController(options); + + for (const [field, value] of [ + ["period", "all"], + ["repo", "/new"], + ["model", "gpt-5.6-luna"], + ["agent", "codex"], + ["since", "2026-09-01"], + ["until", "2026-09-22"], + ] as const) { + await controller.setFilter(field, value); + const requested = new URL(String(options.fetch.mock.lastCall?.[0]), "http://localhost"); + expect(requested.searchParams.get(field)).toBe(value); + expect(controller.getState().filters[field]).toBe(value); + } + + expect(options.fetch).toHaveBeenCalledTimes(6); + }); + + it("keeps an explicit since filter out of the default today period", async () => { + const options = controllerOptions({ initialFilters: { since: "2026-09-01" } }); + const controller = createUsagePageController(options); + + await controller.refresh(); + + expect(controller.getState().filters.period).toBe(""); + expect(options.fetch).toHaveBeenCalledWith("/api/usage?since=2026-09-01"); + }); + + it("polls using the normalized interval and the active filter set", async () => { + let poll: (() => void) | undefined; + const options = controllerOptions({ + initialFilters: { period: "7d", repo: "", model: "", agent: "", since: "", until: "" }, + interval: "2", + setInterval: vi.fn((callback: () => void, milliseconds: number) => { + poll = callback; + expect(milliseconds).toBe(2000); + return 24; + }), + }); + const controller = createUsagePageController(options); + + await controller.start(); + await controller.setFilter("model", "gpt-5.6-luna"); + poll?.(); + + const lastUrl = new URL(String(options.fetch.mock.lastCall?.[0]), "http://localhost"); + expect(options.fetch).toHaveBeenCalledTimes(3); + expect(lastUrl.searchParams.get("period")).toBe("7d"); + expect(lastUrl.searchParams.get("model")).toBe("gpt-5.6-luna"); + controller.stop(); + expect(options.clearInterval).toHaveBeenCalledWith(24); + }); + + it.each([ + ["0", 2], + ["not-a-number", 2], + ["-1", 1], + ["0.5", 1], + ["2", 2], + ])("normalizes interval %s to %s seconds", (value, seconds) => { + expect(normalizeUsageInterval(value)).toBe(seconds); + }); + + it("selects origin initially and keeps every breakdown available", () => { + const controller = createUsagePageController(controllerOptions({ initialBy: "origin" })); + + expect(controller.getState().selectedBreakdown).toBe("origin"); + expect(controller.getState().breakdowns).toEqual(["day", "repo", "model", "agent", "run", "origin"]); + + controller.setBreakdown("run"); + expect(controller.getState().selectedBreakdown).toBe("run"); + }); + + it("keeps the last good result visible and stores an error after a failed query", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(response(usageResult)) + .mockResolvedValueOnce(response({ error: "daemon unavailable" }, false)); + const render = vi.fn((_state: UsagePageState) => {}); + const controller = createUsagePageController(controllerOptions({ fetch, render })); + + await controller.refresh(); + const successfulResult = controller.getState().result; + await controller.refresh(); + + expect(controller.getState().result).toEqual(successfulResult); + expect(controller.getState().error).toBe("daemon unavailable"); + expect(render.mock.lastCall?.[0].result).toEqual(successfulResult); + expect(render.mock.lastCall?.[0].error).toBe("daemon unavailable"); + }); + + it("injects executable behavior functions into the standalone page", async () => { + const script = USAGE_PAGE.match(/ + +`; 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/tests/setup-page.test.ts b/tests/setup-page.test.ts new file mode 100644 index 0000000..b975e7f --- /dev/null +++ b/tests/setup-page.test.ts @@ -0,0 +1,311 @@ +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 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 () => 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(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(/\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/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/usage-cli.test.ts b/tests/usage-cli.test.ts index 85b25fb..7ace7a6 100644 --- a/tests/usage-cli.test.ts +++ b/tests/usage-cli.test.ts @@ -1,6 +1,9 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildUsageQueryParams } from "../src/core/usage-query.js"; +import { normalizeUsageInterval } from "../src/web/usage-page.js"; +import type { WebServerHandle, WebServerOptions } from "../src/web/server.js"; +import type { UsageCommandDependencies } from "../src/cli/commands/usage.js"; const ensureDaemonStarted = vi.fn(async () => {}); const request = vi.fn(); @@ -69,6 +72,13 @@ function runProgram(argv: string[]): Promise { return program.parseAsync(["node", "codedeck", "usage", ...argv], { from: "node" }); } +function runProgramWithDependencies(argv: string[], dependencies: UsageCommandDependencies): Promise { + const program = new Command(); + program.exitOverride(); + registerUsageCommand(program, dependencies); + return program.parseAsync(["node", "codedeck", "usage", ...argv], { from: "node" }); +} + beforeEach(() => { process.exitCode = undefined; logs = []; @@ -213,3 +223,39 @@ describe("usage CLI", () => { }); }); }); + +describe("usage web options", () => { + it("runs backfill before web startup", async () => { + const backfill = vi.fn(async () => ({ imported: 2, skipped: 1 })); + const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + + await runProgramWithDependencies(["--backfill", "--web"], { + backfillUsage: backfill, + startServer, + }); + + expect(backfill).toHaveBeenCalledOnce(); + expect(startServer).not.toHaveBeenCalled(); + expect(logs).toEqual(["Usage backfill: imported 2, skipped 1"]); + }); + + it("forwards the web polling interval and applies its normalization rule", async () => { + let captured: WebServerOptions | undefined; + const startServer: NonNullable = async (options) => { + captured = options; + return {} as WebServerHandle; + }; + + await runProgramWithDependencies(["--web", "--interval", "0"], { startServer }); + + const pageRoute = captured?.routes.find((route) => route.path === "/usage"); + const response = { writeHead: vi.fn(), end: vi.fn() }; + pageRoute?.handler({} as never, response as never); + expect(String(response.end.mock.calls[0]?.[0])).toContain('"interval":"0"'); + expect(normalizeUsageInterval("0")).toBe(2); + expect(normalizeUsageInterval("not-a-number")).toBe(2); + expect(normalizeUsageInterval("-0.5")).toBe(1); + expect(normalizeUsageInterval("0.5")).toBe(1); + expect(normalizeUsageInterval("3")).toBe(3); + }); +}); diff --git a/tests/web-cli.test.ts b/tests/web-cli.test.ts index 8aced0b..8ceb572 100644 --- a/tests/web-cli.test.ts +++ b/tests/web-cli.test.ts @@ -3,7 +3,20 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Command } from "commander"; import { createCliProgram } from "../src/cli/index.js"; import { registerUiCommand } from "../src/cli/commands/ui.js"; -import { startWebServer, type WebServerHandle } from "../src/web/server.js"; +import { registerSetupCommand } from "../src/cli/commands/setup.js"; +import { registerUsageCommand, type UsageCommandDependencies } from "../src/cli/commands/usage.js"; +import { DEFAULT_CONFIG, serializeConfig, type SetupConfigRead } from "../src/config/config.js"; +import type { BatchModelsOptions, BatchModelsResult } from "../src/core/models.js"; +import type { UsageQueryResult } from "../src/daemon/protocol.js"; +import { startWebServer, type WebServerHandle, type WebServerOptions } from "../src/web/server.js"; + +const usageIpc = vi.hoisted(() => ({ ensureDaemonStarted: vi.fn(), request: vi.fn() })); +vi.mock("../src/daemon/ipc.js", () => ({ + IpcClient: class { + ensureDaemonStarted = usageIpc.ensureDaemonStarted; + request = usageIpc.request; + }, +})); const handles: WebServerHandle[] = []; const originalExitCode = process.exitCode; @@ -11,11 +24,64 @@ const originalExitCode = process.exitCode; afterEach(async () => { await Promise.all(handles.splice(0).map((handle) => handle.close())); process.exitCode = originalExitCode; + usageIpc.ensureDaemonStarted.mockReset(); + usageIpc.request.mockReset(); vi.restoreAllMocks(); }); +async function startEphemeralServer(options: WebServerOptions): Promise { + const handle = await startWebServer({ + ...options, + port: 0, + signalTarget: new EventEmitter(), + exit: vi.fn(), + }); + handles.push(handle); + return handle; +} + +const setupRead: SetupConfigRead = { + status: "ok", + source: "canonical", + path: "/tmp/codedeck-config.json", + config: { ...DEFAULT_CONFIG, agents: {} }, + raw: serializeConfig({ ...DEFAULT_CONFIG, agents: {} }), + message: null, +}; + +const emptyCatalog: BatchModelsResult = { + models: [], + status: "fresh", + source: "cache", + ageMs: 0, + cacheWriteFailed: false, +}; + +const emptyUsage: UsageQueryResult = { + range: { period: "today", since: "2026-09-22T00:00:00.000Z", until: "2026-09-22T23:59:59.999Z" }, + totals: { + sessionCount: 0, + activeSessionCount: 0, + completedSessionCount: 0, + failedSessionCount: 0, + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + costUsd: 0, + costComplete: true, + sessionsWithoutCost: 0, + }, + byDay: [], + byRepository: [], + byModel: [], + byAgent: [], + byRun: [], + byOrigin: [], +}; + describe("ui CLI command", () => { - it("appears in root help and serves only its registered home and review pages", async () => { + it("appears in root help and serves its registered home, review, setup, and usage pages", async () => { const root = createCliProgram(); expect(root.helpInformation()).toContain("ui"); @@ -43,13 +109,18 @@ describe("ui CLI command", () => { const rootHtml = await rootResponse.text(); expect(rootResponse.status).toBe(200); expect(rootHtml).toContain('href="/review"'); - expect(rootHtml).not.toContain('href="/setup"'); - expect(rootHtml).not.toContain('href="/usage"'); + expect(rootHtml).toContain('href="/setup"'); + expect(rootHtml).toContain('href="/usage"'); const reviewResponse = await fetch(`${started?.baseUrl}/review`); expect(reviewResponse.status).toBe(200); expect(reviewResponse.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); expect(await reviewResponse.text()).toContain("Review local"); + + const setupResponse = await fetch(`${started?.baseUrl}/setup`); + const usageResponse = await fetch(`${started?.baseUrl}/usage`); + expect(setupResponse.status).toBe(200); + expect(usageResponse.status).toBe(200); }); it("rejects an invalid port without starting a server", async () => { @@ -82,3 +153,196 @@ describe("ui CLI command", () => { expect(process.exitCode).toBe(1); }); }); + +describe("ui setup and usage routes", () => { + it("serves setup state, catalog, actions, and usage query routes", async () => { + const getBatchModels = vi.fn(async (_options: BatchModelsOptions) => emptyCatalog); + const fetchUsageQuery = vi.fn(async () => emptyUsage); + let started: WebServerHandle | undefined; + const program = new Command(); + registerUiCommand(program, { + setup: { readConfig: () => setupRead, getBatchModels }, + usage: { fetchUsageQuery, cwd: "/repo" }, + startServer: async (options) => { + started = await startEphemeralServer(options); + return started; + }, + }); + await program.parseAsync(["node", "codedeck", "ui", "--no-open"], { from: "node" }); + + const baseUrl = started!.baseUrl; + const home = await fetch(`${baseUrl}/`); + const homeHtml = await home.text(); + expect(homeHtml).toContain('href="/setup"'); + expect(homeHtml).toContain('href="/usage"'); + expect((await fetch(`${baseUrl}/api/setup/state`)).status).toBe(200); + expect((await fetch(`${baseUrl}/api/setup/catalog`)).status).toBe(200); + + const headers = { + cookie: `codedeck_ui_token_${started!.port}=${started!.security.token}`, + origin: baseUrl, + "content-type": "application/json", + }; + const emptySelection = JSON.stringify({ agents: {} }); + const refresh = await fetch(`${baseUrl}/api/setup/catalog/refresh`, { method: "POST", headers }); + const dryRun = await fetch(`${baseUrl}/api/setup/dry-run`, { + method: "POST", + headers, + body: emptySelection, + }); + const apply = await fetch(`${baseUrl}/api/setup/apply`, { + method: "POST", + headers, + body: emptySelection, + }); + expect(refresh.status).toBe(200); + expect(dryRun.status).toBe(200); + expect(apply.status).toBe(200); + + const usage = await fetch(`${baseUrl}/api/usage`); + expect(usage.status).toBe(200); + expect(await usage.json()).toEqual(emptyUsage); + expect(fetchUsageQuery).toHaveBeenCalledOnce(); + expect(getBatchModels).toHaveBeenCalledWith({ allowNetwork: false }); + expect(getBatchModels).toHaveBeenCalledWith({ refresh: true, allowNetwork: true, timeoutMs: 12_000 }); + }); +}); + +describe("setup and usage web commands", () => { + it("starts setup on its selected port without opening a browser", async () => { + let requested: WebServerOptions | undefined; + let started: WebServerHandle | undefined; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = new Command(); + registerSetupCommand(program, { + isTTY: true, + startServer: async (options) => { + requested = options; + started = await startEphemeralServer(options); + return started; + }, + }); + + await program.parseAsync(["node", "codedeck", "setup", "--port", "32123", "--no-open"], { from: "node" }); + + expect(requested).toMatchObject({ initialPath: "/setup", port: 32123, open: false }); + expect(started?.initialUrl).toContain("?t="); + expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); + expect((await fetch(`${started?.baseUrl}/setup`)).status).toBe(200); + }); + + it("prints the token URL and keeps serving when the browser opener fails", async () => { + let started: WebServerHandle | undefined; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = new Command(); + registerUiCommand(program, { + startServer: async (options) => { + started = await startWebServer({ + ...options, + port: 0, + openBrowser: () => false, + signalTarget: new EventEmitter(), + exit: vi.fn(), + }); + handles.push(started); + return started; + }, + }); + + await program.parseAsync(["node", "codedeck", "ui"], { from: "node" }); + + expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); + expect((await fetch(`${started?.baseUrl}/`)).status).toBe(200); + }); + + it("opens aggregate usage with the selected filters, breakdown, interval, and token URL", async () => { + const cwd = "/web-current/repo"; + vi.spyOn(process, "cwd").mockReturnValue(cwd); + const fetchUsageQuery = vi.fn(async () => emptyUsage); + let requested: WebServerOptions | undefined; + let started: WebServerHandle | undefined; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const startServer: NonNullable = async (options) => { + requested = options; + started = await startEphemeralServer(options); + return started; + }; + const program = new Command(); + registerUsageCommand(program, { startServer, fetchUsageQuery }); + + await program.parseAsync([ + "node", "codedeck", "usage", "--web", "--json", "--port", "32124", "--no-open", + "--since", "2026-09-01", "--until", "2026-09-20", "--repo", "/selected/repo", + "--current", "--model", "gpt-5", "--agent", "codex", "--by", "origin", "--interval", "0", + ], { from: "node" }); + + expect(requested).toMatchObject({ initialPath: "/usage", port: 32124, open: false }); + expect(started?.initialUrl).toContain("?t="); + expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); + const page = await (await fetch(`${started?.baseUrl}/usage`)).text(); + expect(page).toContain(JSON.stringify({ + by: "origin", + interval: "0", + filters: { + period: "", + repo: cwd, + model: "gpt-5", + agent: "codex", + since: "2026-09-01", + until: "2026-09-20", + }, + })); + + const query = await fetch(`${started?.baseUrl}/api/usage?since=2026-09-01&until=2026-09-20&repo=${encodeURIComponent(cwd)}&model=gpt-5&agent=codex`); + expect(query.status).toBe(200); + expect(fetchUsageQuery).toHaveBeenCalledWith({ + period: undefined, + since: "2026-09-01", + until: "2026-09-20", + repository: cwd, + model: "gpt-5", + agent: "codex", + }); + }); + + it("rejects usage --web --tui without starting a server", async () => { + const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const program = new Command(); + registerUsageCommand(program, { startServer }); + + await program.parseAsync(["node", "codedeck", "usage", "--web", "--tui"], { from: "node" }); + + expect(startServer).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith("Options --web and --tui cannot be used together."); + expect(process.exitCode).toBe(2); + }); + + it("keeps usage --web --json on usage.get without a server", async () => { + const summary = { + runId: "run-web", + inputTokens: 12, + outputTokens: 3, + cachedTokens: 1, + costUsd: 0.1, + sessionCount: 1, + activeSessionCount: 0, + costComplete: true, + sessionsWithoutCost: 0, + orchestrator: { costUsd: 0, costComplete: true, inputTokens: 0, outputTokens: 0, cachedTokens: 0, sources: [] }, + total: { costUsd: 0.1 }, + }; + usageIpc.ensureDaemonStarted.mockResolvedValue(undefined); + usageIpc.request.mockResolvedValue(summary); + const startServer = vi.fn(async (_options: WebServerOptions) => ({} as WebServerHandle)); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = new Command(); + registerUsageCommand(program, { startServer }); + + await program.parseAsync(["node", "codedeck", "usage", "run-web", "--web", "--json"], { from: "node" }); + + expect(usageIpc.request).toHaveBeenCalledWith("usage.get", { runId: "run-web" }); + expect(JSON.parse(log.mock.calls[0]![0] as string)).toEqual(summary); + expect(startServer).not.toHaveBeenCalled(); + }); +}); From f3e3597c100978192913cb8e86b21597f4f642cf Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:39:40 -0300 Subject: [PATCH 4/6] docs(web): Mark the web console tasks and requirements done --- .specs/features/web-console/run-notes.md | 71 ++++++++++++ .specs/features/web-console/spec.md | 132 +++++++++++------------ .specs/features/web-console/tasks.md | 20 ++-- 3 files changed, 147 insertions(+), 76 deletions(-) create mode 100644 .specs/features/web-console/run-notes.md diff --git a/.specs/features/web-console/run-notes.md b/.specs/features/web-console/run-notes.md new file mode 100644 index 0000000..691f4da --- /dev/null +++ b/.specs/features/web-console/run-notes.md @@ -0,0 +1,71 @@ +# 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. diff --git a/.specs/features/web-console/spec.md b/.specs/features/web-console/spec.md index 694c748..ea31868 100644 --- a/.specs/features/web-console/spec.md +++ b/.specs/features/web-console/spec.md @@ -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 | From 5320af3aa63755c732dfc3556b2388bdc6eebacb Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:42:30 -0300 Subject: [PATCH 5/6] fix(web): Send the setup catalog refresh as a POST --- src/web/setup-page.ts | 9 ++++++--- tests/setup-page.test.ts | 7 ++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/web/setup-page.ts b/src/web/setup-page.ts index 9f8bce2..0ec01c6 100644 --- a/src/web/setup-page.ts +++ b/src/web/setup-page.ts @@ -304,8 +304,11 @@ export function createSetupPageController(options: SetupPageControllerOptions) { element("setup-apply")?.addEventListener?.("click", () => { void apply(); }); } - async function loadJson(path: string): Promise<{ response: SetupPageResponse; payload: Record }> { - const response = await options.fetcher(path); + 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, @@ -351,7 +354,7 @@ export function createSetupPageController(options: SetupPageControllerOptions) { update(); refreshInFlight = (async () => { try { - const { response, payload } = await loadJson("/api/setup/catalog/refresh"); + 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 }; diff --git a/tests/setup-page.test.ts b/tests/setup-page.test.ts index b975e7f..52f166a 100644 --- a/tests/setup-page.test.ts +++ b/tests/setup-page.test.ts @@ -146,6 +146,7 @@ describe("setup page selection", () => { 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, @@ -154,7 +155,10 @@ describe("setup page selection", () => { cacheWriteFailed: false, }; const controller = createSetupPageController({ - fetcher: async () => new Promise((resolve) => { finishRefresh = resolve; }), + 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, @@ -179,6 +183,7 @@ describe("setup page selection", () => { })); 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); From b3be29be0d6767dc5dfad6be048b6aa282e60f9d Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:46:39 -0300 Subject: [PATCH 6/6] test(web): Cover the browser fetch adapter forwarding the method --- .specs/features/web-console/run-notes.md | 16 ++++++++++++++++ .specs/features/web-console/spec.md | 2 +- tests/setup-page.test.ts | 7 +++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.specs/features/web-console/run-notes.md b/.specs/features/web-console/run-notes.md index 691f4da..9d31408 100644 --- a/.specs/features/web-console/run-notes.md +++ b/.specs/features/web-console/run-notes.md @@ -69,3 +69,19 @@ Append-only. One entry per decision, blocker, or event. - `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 ea31868..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 diff --git a/tests/setup-page.test.ts b/tests/setup-page.test.ts index 52f166a..f3638a7 100644 --- a/tests/setup-page.test.ts +++ b/tests/setup-page.test.ts @@ -293,8 +293,8 @@ describe("setup page inline behavior", () => { const { document } = fakeDocument(); const calls: string[] = []; const context = { - fetch: async (path: string) => { - calls.push(path); + fetch: async (path: string, init?: { method?: string }) => { + calls.push(init?.method ? `${init.method} ${path}` : path); return path === "/api/setup/state" ? response({ target: { kind: "global" }, bindings: {}, efforts: {} }) : response({ models: [], status: "fresh", source: "cache", ageMs: 10, cacheWriteFailed: false }); @@ -312,5 +312,8 @@ describe("setup page inline behavior", () => { expect(calls).toEqual(["/api/setup/state", "/api/setup/catalog"]); expect(selected.agents).toEqual({}); expect(page.state.target?.target.kind).toBe("global"); + + await page.refreshCatalog(); + expect(calls).toEqual(["/api/setup/state", "/api/setup/catalog", "POST /api/setup/catalog/refresh"]); }); });