diff --git a/CLAUDE.md b/CLAUDE.md index 1ac436a0..d327adc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,8 +45,8 @@ This rule does NOT apply to internal contracts that ship as a single unit with t - **[mistake]**: Never fabricate history in changelogs, commit messages, or comments. Do not claim code "replaces" or "fixes" a prior implementation unless that implementation verifiably exists in the codebase or git history. (context: changelog|hallucination|fabrication) - **[mistake]**: Before adding a fallback or recovery path, verify the triggering condition can actually occur. Dead fallbacks that read from files never written or variables never set create false confidence in error handling. (context: dead-code|fallback|unreachable) -### Agent Monitor & Sidecar Security -- **[mistake]**: Treat localhost sidecar routes and iframe messages as privileged surfaces. Mutating routes need origin/trusted-action guards, explicit target origins, and regression coverage. (context: agent-monitor|sidecar|security) +### Agent Monitor Security +- **[mistake]**: Treat localhost Agent Monitor hook/listener routes and renderer IPC as privileged surfaces. Mutating routes need explicit local-only binding, trusted-action guards where applicable, and regression coverage. (context: agent-monitor|listener|ipc|security) ### Process Spawning & Secrets - **[mistake]**: Keep large or sensitive data out of spawned argv/env. Use stdin or files for prompts, quote shell args, set approved cwd, and pass minimal child environments. (context: spawn|argv|env|secrets) @@ -54,9 +54,6 @@ This rule does NOT apply to internal contracts that ship as a single unit with t ### Boundary Validation - **[mistake]**: Runtime-validate gateway, IPC, and persisted payloads before path or file use. TypeScript casts and preload promise types do not protect missing or null fields. (context: validation|ipc|gateway) -### Generated Agent Monitor Runtime -- **[pattern]**: When generated sidecar overlays, snippets, or patch inputs change, update stamp/materialization inputs and verify generated output so stale assets or bypassed patches cannot ship. (context: agent-monitor|generated|build) - ### State & Lifecycle - **[mistake]**: Setting toggles must update persisted state and in-memory side effects together. Avoid one-way restart guards, stale tray state, or stale cloud presence. (context: settings|lifecycle|state) diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md index b08711c1..83cd39f1 100644 --- a/apps/desktop/CLAUDE.md +++ b/apps/desktop/CLAUDE.md @@ -138,15 +138,12 @@ The Diagnostics tab shows the current in-memory gateway log plus a bounded previ ## Agent Monitor -> **Status (FEA-1504):** Agent Monitor has three boot modes. The default user -> experience is the legacy sidecar-backed dashboard (`agentMonitorEnabled=true`, -> `agentDashboardDesignSystemEnabled=false`): pnpm-managed upstream packages are -> materialized into `.generated/agent-monitor`, shipped unpacked, and rendered in -> the legacy iframe shell. The in-process design-system dashboard is a Labs -> opt-in only. When `agentDashboardDesignSystemEnabled` is not the literal -> boolean `true`, the main process must not load `src/main/database/`, -> `src/main/collectors/`, `AgentHookListener`, `desktop:db:*`, or the `app://` -> design renderer path. +> **Status (FEA-1550):** Agent Monitor is an in-process, PGlite-backed desktop +> feature. The legacy generated runtime tree, embedded web shell, and +> `agentDashboardDesignSystemEnabled` boot split have been removed. When +> `agentMonitorEnabled=false`, the main process must not start collectors, +> `AgentHookListener`, the `desktop:db:*` IPC handlers, cloud session sync, or +> dashboard-derived cost reads. The desktop app provides local Claude Code (and opt-in Codex) session/agent observability. It powers the **Dashboard** and the agent nav items (Sessions, @@ -155,23 +152,20 @@ is gated by the persisted `agentMonitorEnabled` desktop setting, which **defaults ON**; when disabled, the agent nav items are hidden and only the Gateway section remains. -- **Legacy sidecar (default):** `src/main/agent-monitor-sidecar.ts` launches the - generated Claude-Code-Agent-Monitor runtime tree. `build:agent-monitor` - materializes the tree from pnpm-managed upstream packages; package/stage logic - must keep `.generated/agent-monitor` available for default users. -- **Design-system runtime (Labs opt-in):** `src/main/agent-dashboard-design-system-runtime.ts` +- **Runtime:** `src/main/agent-dashboard-design-system-runtime.ts` is the only module allowed to import `src/main/database/`, `src/main/collectors/`, `AgentHookListener`, or register `desktop:db:*`. It is - reached only through `await import()` after boot mode resolves to - `design-system`. -- **Disabled mode:** `agentMonitorEnabled=false` starts no sidecar, no - design-system runtime, no dashboard-derived sync source, and no - dashboard-derived cost source. + reached only through `await import()` after the Agent Monitor setting is + enabled. +- **Disabled mode:** `agentMonitorEnabled=false` starts no design-system + runtime, no dashboard-derived sync source, and no dashboard-derived cost + source. - **Hook listener:** in design-system mode, `src/main/agent-monitor-listener.ts` binds `127.0.0.1:4820` in the main process and accepts the hook payload - (`POST /api/hooks/event`, `GET /api/health`). Each event is gated by the - FEA-1407 sandbox check, harness-stamped from `__provider`, and applied by the - lifecycle state machine. + (`POST /api/hooks/event`, `GET /api/health`). Each event is + harness-stamped from `__provider` and applied by the lifecycle state machine. + Local import is ungated — all hook events are written to the local DB + regardless of the sandbox directory (FEA-1550). - **Collection layer (`src/main/collectors/`):** design-system mode uses `CollectorManager` for best-effort boot bulk import and live file watchers for all five agent CLIs, writing through the first-party `importSession` into the @@ -182,12 +176,12 @@ Gateway section remains. `~/.claude/settings.json` at install time, so 4820 means hooks need zero per-hook env. 4820 is outside `PORT_PROBE_ORDER`, so it never collides with the gateway. (FEA-1500 tracks migrating this transport later.) -- **Durable DB:** `app.getPath("userData")/agent-dashboard.sqlite` (schema in - `src/main/database/schema.ts`), Node's built-in `node:sqlite`. Persisted - collector caches live under `/agent-monitor/`. -- **UI:** a first-party React app in the main window (`src/renderer/`) — NO - iframe. The left sidebar drives the **Dashboard** + agent nav items; live - updates arrive via the `desktop:db:changed` IPC push after each write. +- **Durable DB:** `app.getPath("userData")/agent-dashboard.pgdata` (PGlite, + schema in `src/main/database/pglite.ts`). Persisted collector caches live + under `/agent-dashboard-ingest/`. +- **UI:** a first-party React app in the main window (`src/renderer/`). The + left sidebar drives the **Dashboard** + agent nav items; live updates arrive + via the `desktop:db:changed` IPC push after each write. - **Hooks are explicit opt-in (consent-bearing).** The user enables/disables tracking via the toggle → `src/main/agent-monitor-hooks.ts` writes/removes the hook entries in `~/.claude/settings.json` (and, opt-in, `~/.codex/hooks.json`). @@ -206,10 +200,12 @@ Gateway section remains. tree. - **Security model (by design):** the collectors + listener read the agent-CLI home dirs (`~/.claude`, `~/.codex`, …) **directly**, outside the gateway - `isPathAllowed` sandbox, but every captured session is dropped unless its - `cwd` is inside the FEA-1407 sandbox base directory (fail-closed). The listener - is bound to `127.0.0.1` only; no cloud egress from collectors. Hooks only - mutate global Claude/Codex config on explicit user opt-in and are reversible. + `isPathAllowed` sandbox. Local import and hook capture are ungated — all + sessions are written to the local PGlite DB regardless of working directory + (FEA-1550). Cloud sync sends all local sessions without sandbox filtering; + payloads are sanitized before upload. The listener is bound to `127.0.0.1` + only; no cloud egress from collectors. Hooks only mutate global Claude/Codex + config on explicit user opt-in and are reversible. - **Multi-harness support (5 agent tools):** ingests sessions from **Claude Code** (hooks live + file historical), **OpenAI Codex** (rollout JSONL under `~/.codex/sessions/`), **Cursor** (agent transcripts under `~/.cursor/projects/`), diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index aaac6ee9..c6f34218 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -14,20 +14,6 @@ extraResources: to: trayIconTemplate.png - from: resources/trayIconTemplate@2x.png to: trayIconTemplate@2x.png - # Generated Claude-Code-Agent-Monitor runtime tree, shipped unpacked - # (outside the asar) so the spawned Node server, the built client, and the - # hook scripts resolve as real files. Built by scripts/build-agent-monitor.mjs - # before packaging (chained into `build`). `client/dist/**/*` (NOT - # `client/**/*`) — the server resolves ../client/dist relative to server/, so - # the server/ <-> client/dist/ layout must be preserved. - - from: .generated/agent-monitor - to: agent-monitor - filter: - - server/**/* - - client/dist/**/* - - scripts/**/* - - package.json - - LICENSE # First-party agent-monitor hook handlers (FEA-1503), shipped unpacked outside # the asar so they resolve as real files. agent-monitor-hooks.ts copies them # into userData at install time; the installed hook command runs them via the diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 969b3809..5bdd23a3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.15.117", + "version": "0.16.0", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, @@ -8,15 +8,13 @@ "main": "dist/main/index.js", "scripts": { "dev": "pnpm build && ELECTRON_BIN=$(bash scripts/patch-electron-plist.sh | tail -1) && \"$ELECTRON_BIN\" .", - "start": "pnpm build:agent-monitor && ELECTRON_BIN=$(bash scripts/patch-electron-plist.sh | tail -1) && \"$ELECTRON_BIN\" .", + "start": "ELECTRON_BIN=$(bash scripts/patch-electron-plist.sh | tail -1) && \"$ELECTRON_BIN\" .", "clean:dist": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", "clean:package": "node -e \"require('fs').rmSync('dist-dmg',{recursive:true,force:true})\"", "prebuild": "node -e \"const{execSync:e}=require('child_process'),{writeFileSync:w}=require('fs');const h=e('git rev-parse HEAD').toString().trim();w('src/shared/build-info.ts','// AUTO-GENERATED — do not edit\\nexport const BUILD_COMMIT_HASH = \\\"'+h+'\\\";\\n');\"", - "build": "pnpm clean:dist && pnpm prebuild && tsc -p tsconfig.json && pnpm build:renderer && pnpm build:agent-monitor", + "build": "pnpm clean:dist && pnpm prebuild && tsc -p tsconfig.json && pnpm build:renderer", "build:renderer": "vite build --config vite.renderer.config.ts", - "build:agent-monitor": "node scripts/build-agent-monitor.mjs", "dashboard:reset": "node scripts/reset-dashboard-db.mjs", - "dashboard:reset-packs": "node scripts/reset-dashboard-db.mjs --packs-only", "stage:package": "node scripts/stage-packaging-app.mjs", "typecheck": "tsc -p tsconfig.json --noEmit && pnpm typecheck:renderer", "typecheck:renderer": "tsc -p tsconfig.renderer.json --noEmit", @@ -25,28 +23,15 @@ "test:boot:design-system-off": "node --import tsx scripts/assert-design-system-boot-off.mjs", "measure:agent-dashboard-storage": "node scripts/measure-agent-dashboard-storage.mjs", "verify:electron-binary": "node scripts/ensure-electron-binary.mjs", - "test": "tsx --test --test-concurrency=1 test/*.test.ts && node --test \"scripts/agent-monitor-packs/__tests__/*.test.js\" \"scripts/agent-monitor-pull-requests/__tests__/*.test.js\"", - "pretest:contract": "pnpm build:agent-monitor", - "test:contract": "node --test \"test-e2e/agent-monitor/specs/api-contract/*.test.mjs\"", - "pretest:e2e": "pnpm build:agent-monitor", - "test:e2e": "playwright test --config test-e2e/agent-monitor/playwright.config.ts", - "pretest:audit": "pnpm build:agent-monitor && pnpm audit:scan && pnpm audit:classify", - "audit:scan": "node test-e2e/agent-monitor/inventory/scan-tiles.mjs", - "audit:classify": "node test-e2e/agent-monitor/inventory/coverage-classifier.mjs", - "audit:coverage": "bash scripts/check-audit-coverage.sh", - "test:audit": "node --test \"test-e2e/agent-monitor/specs/audit/*.test.mjs\"", - "pretest:audit:ui": "pnpm build:agent-monitor", - "test:audit:ui": "playwright test --config test-e2e/agent-monitor/playwright.audit.config.ts", - "preaudit:report": "pnpm build:agent-monitor", - "audit:report": "node test-e2e/agent-monitor/inventory/run-report.mjs", + "test": "tsx --test --test-concurrency=1 test/*.test.ts", "package": "pnpm clean:package && pnpm build && pnpm stage:package && node scripts/run-electron-builder.mjs", "release": "pnpm clean:package && pnpm build && pnpm stage:package && node scripts/run-electron-builder.mjs --publish always" }, "dependencies": { "@closedloop-ai/design-system": "0.1.1-dev.26892521643.1", "@closedloop-ai/loops-api": ">=0.3.1", + "@electric-sql/pglite": "^0.4.6", "@pydantic/genai-prices": "0.0.62", - "agent-dashboard": "github:hoangsonww/Claude-Code-Agent-Monitor#840c518d7fa69231de049e41b893938228b67e40", "busboy": "^1.6.0", "electron-log": "^5.4.3", "electron-store": "^8.2.0", @@ -72,7 +57,6 @@ "@typescript-eslint/eslint-plugin": "^8.57.1", "@typescript-eslint/parser": "^8.57.1", "@vitejs/plugin-react": "^5.1.3", - "agent-dashboard-client": "github:hoangsonww/Claude-Code-Agent-Monitor#840c518d7fa69231de049e41b893938228b67e40&path:/client", "autoprefixer": "10.4.20", "electron": "^35.0.2", "electron-builder": "^26.8.1", diff --git a/apps/desktop/scripts/agent-monitor-billing/billing-mode.js b/apps/desktop/scripts/agent-monitor-billing/billing-mode.js deleted file mode 100644 index 1998f560..00000000 --- a/apps/desktop/scripts/agent-monitor-billing/billing-mode.js +++ /dev/null @@ -1,262 +0,0 @@ -/** - * @file billing-mode.js - * @description Canonical billing-mode engine for the agent-monitor sidecar - * (CommonJS). Classifies each tracked session as METERED (real per-token API - * spend) vs SUBSCRIPTION-covered (Claude Pro/Max, ChatGPT/Codex, Copilot seat, - * Cursor Pro) so the dashboard can keep two separate ledgers and never sum a - * hypothetical subscription cost into real headline spend. - * - * CLOSEDLOOP FEA-1434. Mirrors the agent-monitor-cost engine pattern: this CJS - * module is the source of truth that runs inside the generated sidecar tree, - * and `src/shared/billing-mode.ts` is a byte-equal ESM twin for desktop-main - * (which must work with the sidecar disabled). A parity test - * (`test/billing-mode.test.ts`) imports BOTH and asserts identical output so - * the twins cannot drift. - * - * ── Two responsibilities ────────────────────────────────────────────────────── - * 1. CLASSIFICATION (pure, total over the BillingMode union): map a stored - * billing mode → a ledger ("metered" | "subscription" | "unknown"). The - * schema column, relay sync, and UI all rely on this being total. - * 2. DETECTION (pure, dependency-injected): infer the billing mode for a - * harness from credential PRESENCE only. Detection takes injected deps - * ({ env, fileExists, homeDir }) so it is testable and so it can run in - * both the sidecar and desktop-main with the right real implementations. - * - * ── Secret-handling rule (non-negotiable) ───────────────────────────────────── - * Detection checks credential EXISTENCE only. It NEVER reads the contents of - * `~/.claude/.credentials.json`, `~/.codex/auth.json`, or any API-key env var - * beyond a non-empty check, and NEVER logs, echoes, or returns those values. - * The only output is an opaque BillingMode string. - * - * ── Tier granularity ────────────────────────────────────────────────────────── - * The BillingMode union carries tier-specific Anthropic values (pro/max_5x/ - * max_20x) and Codex values for the persisted/synced contract, but existence- - * only detection cannot distinguish tiers (that needs `/status` parsing, out of - * scope for this slice — see PRD-414). So OAuth-present Anthropic resolves to - * `subscription_unknown`; the finer tiers arrive later from `/status` or cloud - * sync. The ledger mapping is total over every value regardless. - */ -"use strict"; - -const path = require("node:path"); - -/** - * Every valid billing mode. Persisted in the sessions.billing_mode column and - * carried on the relay sync contract, so this is a stable, additive list. - * Exported so callers/tests can iterate the full domain. - */ -const BILLING_MODES = [ - "api", - "subscription_unknown", - "pro", - "max_5x", - "max_20x", - "codex_subscription", - "cursor_api", - "cursor_pro", - "copilot_seat", - "opencode", - "unknown", -]; - -// Real per-token API spend → counts toward headline metered cost. -const METERED_MODES = new Set(["api", "cursor_api"]); -// Subscription-covered → priced only as a hypothetical "would have cost" -// equivalent, NEVER summed into headline spend. -const SUBSCRIPTION_MODES = new Set([ - "subscription_unknown", - "pro", - "max_5x", - "max_20x", - "codex_subscription", - "cursor_pro", - "copilot_seat", -]); - -/** - * Map a billing mode to its ledger. Total over the union: anything not metered - * or subscription (opencode BYOK, the literal "unknown", or any unrecognized - * future value read from disk/relay) lands in "unknown" so it is neither - * charged nor mislabeled as covered. - * @param {string} mode - * @returns {"metered"|"subscription"|"unknown"} - */ -function billingLedger(mode) { - if (METERED_MODES.has(mode)) return "metered"; - if (SUBSCRIPTION_MODES.has(mode)) return "subscription"; - return "unknown"; -} - -/** True when the mode represents real, per-token API spend. */ -function isMeteredApi(mode) { - return billingLedger(mode) === "metered"; -} - -/** True when the mode is covered by a flat subscription/seat. */ -function isSubscription(mode) { - return billingLedger(mode) === "subscription"; -} - -/** - * ── Ledger accounting (pure) ────────────────────────────────────────────────── - * The two-ledger invariant lives here so the sidecar routes and any future - * desktop-main caller share one definition and cannot diverge. A LedgerTotals - * accumulator carries the three buckets; addLedgerCost() routes one priced row - * into its bucket via billingLedger(); headlineCost() defines what counts as - * real spend. - * - * Headline = metered + unknown (NOT subscription). Rationale: subscription rows - * are a hypothetical "would have cost" and must never inflate real spend, while - * legacy/opencode rows in the unknown bucket are pre-existing real numbers we - * must not silently zero out. Subscription cost stays visible in its own bucket - * for the two-ledger UI; it is simply excluded from the headline sum. - */ - -/** Fresh zeroed accumulator. Shape is the wire contract for cost_by_ledger. */ -function emptyLedgerTotals() { - return { metered: 0, subscription: 0, unknown: 0 }; -} - -/** - * Add one priced row's cost to the bucket its billing mode maps to. Non-finite - * costs (null/undefined/NaN from an unpriced row) are ignored so an unpriced - * model never corrupts a ledger total — it simply does not contribute. Mutates - * and returns `totals` for fold-style accumulation. - * @param {{metered:number,subscription:number,unknown:number}} totals - * @param {string} billingMode - * @param {number} costUsd - */ -function addLedgerCost(totals, billingMode, costUsd) { - if (typeof costUsd !== "number" || !Number.isFinite(costUsd)) return totals; - totals[billingLedger(billingMode)] += costUsd; - return totals; -} - -/** - * The headline "real spend" number: metered API spend plus unknown-ledger rows - * (legacy/opencode), explicitly EXCLUDING subscription-covered cost. - * @param {{metered:number,subscription:number,unknown:number}} totals - * @returns {number} - */ -function headlineCost(totals) { - return totals.metered + totals.unknown; -} - -/** - * Coerce a possibly-null/legacy/garbage value (e.g. a DB read from a row - * written before this column existed, or a relay payload from an older build) - * to a valid BillingMode. Unrecognized → "unknown". - * @param {unknown} value - * @returns {string} - */ -function normalizeBillingMode(value) { - return typeof value === "string" && BILLING_MODES.includes(value) - ? value - : "unknown"; -} - -/** Non-empty string presence check for an env var (existence only — never logged). */ -function hasNonEmptyEnv(env, key) { - const v = env && typeof env === "object" ? env[key] : undefined; - return typeof v === "string" && v.trim().length > 0; -} - -/** - * Resolve the Codex home dir, honoring the documented $CODEX_HOME override - * (same precedence the codex importer's codex-home.js uses) so a relocated - * Codex install is classified correctly rather than falling through to unknown. - */ -function codexHomeDir(deps) { - if (hasNonEmptyEnv(deps.env, "CODEX_HOME")) { - return deps.env.CODEX_HOME; - } - return path.join(deps.homeDir, ".codex"); -} - -/** - * Anthropic (Claude Code harness): an ANTHROPIC_API_KEY means real metered API - * billing; otherwise a present OAuth credential file means a Pro/Max - * subscription (tier undeterminable here → subscription_unknown). Neither path - * reads the secret's contents. - */ -function detectAnthropicBillingMode(deps) { - if (hasNonEmptyEnv(deps.env, "ANTHROPIC_API_KEY")) return "api"; - if (deps.fileExists(path.join(deps.homeDir, ".claude", ".credentials.json"))) { - return "subscription_unknown"; - } - return "unknown"; -} - -/** - * OpenAI/Codex harness: an OPENAI_API_KEY means metered API billing; otherwise - * a present Codex OAuth file means a ChatGPT/Codex subscription. - */ -function detectOpenAiBillingMode(deps) { - if (hasNonEmptyEnv(deps.env, "OPENAI_API_KEY")) return "api"; - if (deps.fileExists(path.join(codexHomeDir(deps), "auth.json"))) { - return "codex_subscription"; - } - return "unknown"; -} - -/** - * Cursor harness: a CURSOR_API_KEY means metered API billing; otherwise a - * tracked Cursor session (the importer only runs when transcripts exist) is a - * Pro/Business seat. Seat-share allocation math is out of scope (PRD-414). - */ -function detectCursorBillingMode(deps) { - if (hasNonEmptyEnv(deps.env, "CURSOR_API_KEY")) return "cursor_api"; - return "cursor_pro"; -} - -/** GitHub Copilot is always a seat-based subscription (no per-token API). */ -function detectCopilotBillingMode(_deps) { - return "copilot_seat"; -} - -/** OpenCode is bring-your-own-key; per-call billing attribution is deferred. */ -function detectOpencodeBillingMode(_deps) { - return "opencode"; -} - -/** - * Detect the billing mode for a harness from injected deps. Unknown harnesses - * resolve to "unknown" (ledger: unknown) rather than guessing. - * @param {string} harness one of "claude" | "codex" | "cursor" | "copilot" | "opencode" - * @param {{ env: object, fileExists: (p: string) => boolean, homeDir: string }} deps - * @returns {string} a BillingMode - */ -function detectBillingModeForHarness(harness, deps) { - switch (harness) { - case "claude": - return detectAnthropicBillingMode(deps); - case "codex": - return detectOpenAiBillingMode(deps); - case "cursor": - return detectCursorBillingMode(deps); - case "copilot": - return detectCopilotBillingMode(deps); - case "opencode": - return detectOpencodeBillingMode(deps); - default: - return "unknown"; - } -} - -module.exports = { - BILLING_MODES, - billingLedger, - isMeteredApi, - isSubscription, - emptyLedgerTotals, - addLedgerCost, - headlineCost, - normalizeBillingMode, - detectBillingModeForHarness, - // Exported for the parity test + targeted unit coverage. - detectAnthropicBillingMode, - detectOpenAiBillingMode, - detectCursorBillingMode, - detectCopilotBillingMode, - detectOpencodeBillingMode, -}; diff --git a/apps/desktop/scripts/agent-monitor-billing/package.json b/apps/desktop/scripts/agent-monitor-billing/package.json deleted file mode 100644 index bad511e4..00000000 --- a/apps/desktop/scripts/agent-monitor-billing/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "//": "Scopes this dir to CommonJS (parent apps/desktop is type:module). billing-mode.js is build-time-copied into the generated agent-monitor server/lib (a CommonJS tree), mirroring scripts/agent-monitor-cost. Not part of the desktop ESM build.", - "type": "commonjs", - "private": true -} diff --git a/apps/desktop/scripts/agent-monitor-client/Dashboard.tsx b/apps/desktop/scripts/agent-monitor-client/Dashboard.tsx deleted file mode 100644 index 579b3b02..00000000 --- a/apps/desktop/scripts/agent-monitor-client/Dashboard.tsx +++ /dev/null @@ -1,2466 +0,0 @@ -/** - * @file Dashboard.tsx - * @description ClosedLoop-authored override that merges the upstream Analytics - * page into the Monitor tab. Copied verbatim over - * `src/pages/Dashboard.tsx` at build time by scripts/build-agent-monitor.mjs - * via CLIENT_FULL_FILE_OVERRIDES. - * - * Layout (Monitor tab): - * 1. Five stat pills (Sessions / Agents / Tokens / Cost / Events) - * 2. Active Agents section (full width) - * 3. Event Activity heatmap + Last 30 Days sparkline - * 4. Inner tabs: Cost / Tokens / Productivity / Workflow - * - * Health tab carries the upstream SystemHealthTab unchanged. - * - * Helpers from the upstream Analytics page (ChartTooltip, useTooltip, Heatmap, - * Sparkline, CostTrendLine, BarRow, CostBarRow, DonutChart, StatPill) are - * duplicated locally here rather than imported — Analytics.tsx does not export - * them, and inlining keeps the override a single self-contained file. - */ - -import { - useEffect, - useState, - useCallback, - useSyncExternalStore, - useMemo, - useRef, -} from "react"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { - LayoutDashboard, - FolderOpen, - Bot, - Zap, - DollarSign, - Activity, - ArrowRight, - RefreshCw, - GitBranch, - ChevronDown, - ChevronRight, - Server, - HardDrive, - Plug, - Cpu, - BarChart3, - ShieldCheck, - Database, - Search, - Clock, -} from "lucide-react"; -import { api } from "../lib/api"; -import { eventBus } from "../lib/eventBus"; -import { AgentCard } from "../components/AgentCard"; -import { EmptyState } from "../components/EmptyState"; -import { Tip } from "../components/Tip"; -import { fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format"; -import { loadLedgerPrefs, type CostByLedger } from "../lib/closedloop-ledger"; -import type { - Stats, - Agent, - WSMessage, - WorkflowData, - Analytics as AnalyticsData, - CostResult, -} from "../lib/types"; - -// ─── Analytics chart tooltip ────────────────────────────────────────────────── - -function ChartTooltip({ - x, - y, - children, -}: { - x: number; - y: number; - children: React.ReactNode; -}) { - const nearRight = x > window.innerWidth - 200; - return ( -
- {children} -
- ); -} - -function useTooltip() { - const [tooltip, setTooltip] = useState<{ - x: number; - y: number; - content: React.ReactNode; - } | null>(null); - - const show = (e: React.MouseEvent, content: React.ReactNode) => { - setTooltip({ x: e.clientX, y: e.clientY, content }); - }; - const move = (e: React.MouseEvent) => { - setTooltip((t) => t && { ...t, x: e.clientX, y: e.clientY }); - }; - const hide = () => setTooltip(null); - - const node = tooltip ? ( - - {tooltip.content} - - ) : null; - - return { show, move, hide, node }; -} - -// ─── Heatmap ────────────────────────────────────────────────────────────────── - -function cellColor(count: number, max: number) { - if (count === 0) return "#161625"; - const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1); - type RGB = [number, number, number]; - const stops: RGB[] = [ - [22, 20, 60], - [55, 48, 163], - [99, 102, 241], - [199, 210, 254], - ]; - const scaled = t * (stops.length - 1); - const lo = Math.min(Math.floor(scaled), stops.length - 2); - const frac = scaled - lo; - const [r1, g1, b1]: RGB = stops[lo] as RGB; - const [r2, g2, b2]: RGB = stops[lo + 1] as RGB; - const r = Math.round(r1 + (r2 - r1) * frac); - const g = Math.round(g1 + (g2 - g1) * frac); - const b = Math.round(b1 + (b2 - b1) * frac); - return `rgb(${r},${g},${b})`; -} - -function Heatmap({ - weeks, - locale, -}: { - weeks: Array>; - locale: string; -}) { - const { show, move, hide, node } = useTooltip(); - - const monthLabels = useMemo( - () => - Array.from({ length: 12 }, (_, month) => - new Intl.DateTimeFormat(locale, { month: "short" }).format(new Date(2026, month, 1)) - ), - [locale] - ); - - const dayNames = useMemo( - () => - Array.from({ length: 7 }, (_, day) => - new Intl.DateTimeFormat(locale, { weekday: "short" }).format( - new Date(2026, 0, 4 + day) - ) - ), - [locale] - ); - - const dayLabels = [dayNames[0], "", dayNames[2], "", dayNames[4], "", ""]; - const maxCount = Math.max(...weeks.flatMap((w) => w.map((c) => c.count)), 1); - - const monthPositions = useMemo(() => { - const positions: Array<{ label: string; col: number }> = []; - let prevMonth = -1; - weeks.forEach((week, wi) => { - const firstCell = week[0]; - if (!firstCell) return; - const parts = firstCell.date.split("-").map(Number); - const m = (parts[1] || 1) - 1; - if (m !== prevMonth) { - positions.push({ label: monthLabels[m] ?? "", col: wi }); - prevMonth = m; - } - }); - return positions; - }, [weeks, monthLabels]); - - return ( -
- {node} -
- {monthPositions.map((mp, i) => ( -
- {mp.label} -
- ))} -
-
-
- {dayLabels.map((d, i) => ( -
- {d} -
- ))} -
- {weeks.map((week, wi) => ( -
- {week.map((cell) => ( -
{ - const parts = cell.date.split("-").map(Number); - const y = parts[0] || 0; - const m = (parts[1] || 1) - 1; - const d = parts[2] || 1; - const date = new Date(y, m, d, 12); - const dow = date.getDay(); - show( - e, - <> - - {dayNames[dow] ?? ""}, {cell.date} - - - {cell.count.toLocaleString()} events - - - ); - }} - onMouseMove={move} - onMouseLeave={hide} - style={{ - width: 13, - height: 13, - borderRadius: 2, - backgroundColor: cellColor(cell.count, maxCount), - border: "1px solid rgba(255,255,255,0.04)", - flexShrink: 0, - cursor: "default", - }} - /> - ))} -
- ))} -
-
- Less - {[0, 0.25, 0.5, 0.75, 1].map((f) => { - const v = Math.round(f * maxCount); - return ( -
- ); - })} - More -
-
- ); -} - -// ─── Sparkline + cost trend ─────────────────────────────────────────────────── - -function Sparkline({ - data, - color = "#6366f1", -}: { - data: Array<{ date: string; count: number }>; - color?: string; -}) { - const { show, move, hide, node } = useTooltip(); - const max = Math.max(...data.map((d) => d.count), 1); - return ( -
- {node} - {data.map(({ date, count }) => ( -
- show( - e, - <> - {date} - {count.toLocaleString()} events - - ) - } - onMouseMove={move} - onMouseLeave={hide} - /> - ))} -
- ); -} - -function CostTrendLine({ - data, - color = "#10b981", -}: { - data: Array<{ date: string; cost: number }>; - color?: string; -}) { - const { show, move, hide, node } = useTooltip(); - if (data.length === 0) return null; - - const width = 320; - const height = 88; - const padX = 8; - const padY = 8; - const min = Math.min(...data.map((d) => d.cost), 0); - const max = Math.max(...data.map((d) => d.cost), 0); - const span = Math.max(max - min, 0.0001); - const step = data.length > 1 ? (width - padX * 2) / (data.length - 1) : 0; - - const points = data.map(({ date, cost }, i) => { - const x = padX + i * step; - const y = height - padY - ((cost - min) / span) * (height - padY * 2); - return { date, cost, x, y }; - }); - - const linePoints = points.map((p) => `${p.x},${p.y}`).join(" "); - const firstX = points[0]?.x ?? padX; - const lastX = points[points.length - 1]?.x ?? padX; - const areaPoints = `${firstX},${height - padY} ${linePoints} ${lastX},${height - padY}`; - - return ( -
- {node} - - - - - - - - - - {points.map((point) => ( - - - - show( - e, - <> - {point.date} - {fmtCostFull(point.cost)} - - ) - } - onMouseMove={move} - onMouseLeave={hide} - /> - - ))} - -
- ); -} - -// ─── Bar rows + donut ───────────────────────────────────────────────────────── - -function BarRow({ - label, - count, - max, - color = "bg-accent", - pct, -}: { - label: string; - count: number; - max: number; - color?: string; - pct?: number; -}) { - const width = pct !== undefined ? pct : max > 0 ? Math.round((count / max) * 100) : 0; - return ( -
- - {label} - -
-
-
- - {fmt(count)} - -
- ); -} - -function CostBarRow({ - label, - cost, - max, - color = "bg-emerald-400", -}: { - label: string; - cost: number; - max: number; - color?: string; -}) { - const width = max > 0 ? Math.max(2, Math.round((cost / max) * 100)) : 0; - return ( -
- - {label} - -
-
-
- - {fmtCost(cost)} - -
- ); -} - -function DonutChart({ - segments, - formatTotal, -}: { - segments: Array<{ label: string; value: number; color: string }>; - formatTotal?: (total: number) => string; -}) { - const { show, move, hide, node } = useTooltip(); - const total = segments.reduce((s, g) => s + g.value, 0); - if (total === 0) return
No data
; - - const r = 52; - const cx = 64; - const cy = 64; - const stroke = 18; - const circumference = 2 * Math.PI * r; - - let offset = circumference / 4; - return ( -
- {node} - - - {segments.map(({ label, value, color }, i) => { - const dash = (value / total) * circumference; - const gap = circumference - dash; - const pct = Math.round((value / total) * 100); - const currentOffset = offset; - offset -= dash; - return ( - - show( - e, - <> - {label} - {pct}% - - ) - } - onMouseMove={move} - onMouseLeave={hide} - /> - ); - })} - - {(formatTotal ?? fmt)(total)} - - - total - - -
- {segments.map(({ label, value, color }) => ( -
- - {label} - - {Math.round((value / total) * 100)}% - -
- ))} -
-
- ); -} - -// ─── StatPill ───────────────────────────────────────────────────────────────── - -function StatPill({ - label, - value, - raw, - sub, - icon: Icon, - color = "text-accent", - testid, - subTestid, -}: { - label: string; - value: string | number; - raw?: string; - sub?: string; - icon: React.ElementType; - color?: string; - testid?: string; - subTestid?: string; -}) { - return ( -
-
- {label} - -
-

- {raw ? {value} : value} -

- {sub && ( -

- {sub} -

- )} -
- ); -} - -// ─── SystemHealthTab (preserved verbatim from upstream Dashboard) ───────────── - -interface SystemInfo { - db: { - path: string; - size: number; - counts: Record; - pragmas: { - journal_mode: string; - synchronous: number; - auto_vacuum: number; - encoding: string; - foreign_keys: number; - busy_timeout: number; - }; - load_stats: { m5: number; m15: number; h1: number }; - }; - hooks: { installed: boolean; path: string; hooks: Record }; - server: { - uptime: number; - node_version: string; - platform: string; - ws_connections: number; - memory: { rss: number; heapTotal: number; heapUsed: number; external: number }; - cpu_load: number[]; - arch: string; - total_mem: number; - free_mem: number; - cpus: number; - }; - transcript_cache: { - size: number; - maxSize: number; - hits: number; - misses: number; - keys: string[]; - }; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function formatUptime(seconds: number): string { - const d = Math.floor(seconds / 86400); - const h = Math.floor((seconds % 86400) / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (d > 0) return `${d}d ${h}h ${m}m`; - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} - -function SystemHealthTab() { - const [info, setInfo] = useState(null); - const [workflow, setWorkflow] = useState(null); - - const loadData = useCallback(async () => { - try { - const [infoRes, workflowRes] = await Promise.all([ - api.settings.info(), - api.workflows.get(), - ]); - setInfo(infoRes as any); - setWorkflow(workflowRes); - } catch (e) { - console.error(e); - } - }, []); - - useEffect(() => { - loadData(); - const int = setInterval(loadData, 30000); - return () => clearInterval(int); - }, [loadData]); - - const stats = useMemo(() => { - if (!info || !workflow) return null; - - const totalEntries = - (info.db.counts?.sessions || 0) + - (info.db.counts?.agents || 0) + - (info.db.counts?.events || 0); - const sessPct = totalEntries > 0 ? ((info.db.counts?.sessions || 0) / totalEntries) * 100 : 0; - const agentPct = totalEntries > 0 ? ((info.db.counts?.agents || 0) / totalEntries) * 100 : 0; - const eventPct = totalEntries > 0 ? ((info.db.counts?.events || 0) / totalEntries) * 100 : 0; - - const modelStats = (workflow.modelDelegation?.tokensByModel || []) - .sort((a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens)) - .slice(0, 6); - const totalTokens = modelStats.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0); - - const memUsedPct = - info.server.total_mem > 0 ? (1 - info.server.free_mem / info.server.total_mem) * 100 : 0; - const heapUsedPct = - info.server.memory.heapTotal > 0 - ? (info.server.memory.heapUsed / info.server.memory.heapTotal) * 100 - : 0; - - return { - totalEntries, - sessPct, - agentPct, - eventPct, - modelStats, - totalTokens, - memUsedPct, - heapUsedPct, - }; - }, [info, workflow]); - - if (!info || !workflow || !stats) { - return ( -
- {[1, 2, 3, 4, 5, 6].map((i) => ( -
- ))} -
- ); - } - - const { - totalEntries, - sessPct, - agentPct, - eventPct, - modelStats, - totalTokens, - memUsedPct, - heapUsedPct, - } = stats; - const successRate = Math.max(0, Math.min(100, workflow.stats.successRate)); - const errorRate = Math.max( - 0, - Math.min(100, workflow.errorPropagation?.errorRate ?? 100 - successRate) - ); - const cacheHitRate = Math.max( - 0, - Math.min( - 100, - ((info.transcript_cache?.hits ?? 0) / - ((info.transcript_cache?.hits ?? 0) + (info.transcript_cache?.misses ?? 0) || 1)) * - 100 - ) - ); - - const lanes = workflow.concurrency?.aggregateLanes || []; - const maxLaneCount = Math.max(...lanes.map((l) => l.count), 1); - - const topTools = (workflow.toolFlow?.toolCounts || []).slice(0, 8); - const maxToolCount = topTools.length > 0 ? (topTools[0]?.count ?? 1) : 1; - - const effectiveness = (workflow.effectiveness || []).slice(0, 6); - - const healthScore = Math.max( - 0, - Math.min( - 100, - successRate * 0.4 + - cacheHitRate * 0.25 + - Math.max(0, 100 - errorRate) * 0.25 + - Math.max(0, 100 - Math.min(100, heapUsedPct)) * 0.1 - ) - ); - - return ( -
-
-
-
-
- - Runtime -
- - {info.server.cpus} cores · {info.server.arch} - -
- -
-
- Uptime - - {formatUptime(info.server.uptime)} - -
-
- CPU (1/5/15m) -
- {(info.server.cpu_load || []).slice(0, 3).map((load, i) => ( - info.server.cpus ? "bg-red-500/20 text-red-400" : "bg-surface-3 text-gray-300"}`} - > - {load.toFixed(2)} - - ))} -
-
-
- Node RSS - - {formatBytes(info.server.memory.rss)} - -
-
- -
- -
-
- Host Memory - {memUsedPct.toFixed(0)}% -
-
-
90 ? "bg-red-500" : memUsedPct > 70 ? "bg-amber-500" : "bg-emerald-500"}`} - style={{ width: `${memUsedPct}%` }} - /> -
-
- - -
-
- V8 Heap - {heapUsedPct.toFixed(0)}% -
-
-
85 ? "bg-red-500" : heapUsedPct > 60 ? "bg-amber-500" : "bg-blue-500"}`} - style={{ width: `${heapUsedPct}%` }} - /> -
-
- -
-
- -
-
-
- - Storage -
- - - ⚡ {info.db.load_stats?.m5 ?? 0}/{info.db.load_stats?.m15 ?? 0}/ - {info.db.load_stats?.h1 ?? 0} - - -
- -
- Database - - {formatBytes(info.db.size)} · {info.db.pragmas?.journal_mode?.toUpperCase() || "WAL"} - -
- -
- - - - {(() => { - const r = 38, - cx = 48, - cy = 48, - circumference = 2 * Math.PI * r; - const segments = [ - { pct: sessPct, color: "#60a5fa" }, - { pct: agentPct, color: "#8b5cf6" }, - { pct: eventPct, color: "#34d399" }, - ]; - let offset = circumference / 4; - return segments.map((seg, i) => { - if (seg.pct <= 0) return null; - const dash = (seg.pct / 100) * circumference; - const gap = circumference - dash; - const currentOffset = offset; - offset -= dash; - return ( - - ); - }); - })()} - - {totalEntries > 999 ? `${(totalEntries / 1000).toFixed(1)}K` : totalEntries} - - - total - - - -
- {[ - { - label: "Sessions", - value: info.db.counts?.sessions ?? 0, - color: "#60a5fa", - pct: sessPct, - }, - { - label: "Agents", - value: info.db.counts?.agents ?? 0, - color: "#8b5cf6", - pct: agentPct, - }, - { - label: "Events", - value: info.db.counts?.events ?? 0, - color: "#34d399", - pct: eventPct, - }, - ].map((item) => ( - -
- - {item.label} - - {Math.round(item.pct)}% - -
-
- ))} -
-
-
- -
-
-
- - Health Score -
- - - ⓘ Formula - - -
- -
- - - - = 90 ? "#34d399" : healthScore >= 70 ? "#fbbf24" : "#f87171"} - strokeWidth="10" - strokeLinecap="round" - strokeDasharray={`${healthScore * 3.016} ${301.6 - healthScore * 3.016}`} - strokeDashoffset={301.6 / 4} - className="transition-all duration-1000" - /> - - {healthScore.toFixed(0)} - - - / 100 - - - -
- -
- -
-

Cache

-

- {cacheHitRate.toFixed(0)}% -

-
-
- 15% = critical`} - > -
-

Errors

-

- {errorRate.toFixed(1)}% -

-
-
- -
-

Compact

-

- {workflow.compaction?.totalCompactions ?? 0} -

-
-
- -
-

Saved

-

- {((workflow.compaction?.tokensRecovered ?? 0) / 1000).toFixed(1)}K -

-
-
-
-
-
- -
-
-
-
- - Token Usage -
- - {(totalTokens / 1000).toFixed(1)}K total - -
- -
- {modelStats.map((m, i) => { - const pct = - totalTokens > 0 ? ((m.input_tokens + m.output_tokens) / totalTokens) * 100 : 0; - const colors = [ - "bg-blue-400", - "bg-violet-400", - "bg-emerald-400", - "bg-amber-400", - "bg-pink-400", - "bg-cyan-400", - ]; - return ( - -
- - {formatModelName(m.model) ?? m.model} - -
-
-
- - {pct.toFixed(1)}% - -
- - ); - })} - {modelStats.length === 0 && ( -

No model data

- )} -
-
- -
-
-
- - Concurrency -
- {lanes.length} intervals -
- - l.count > 0).length}\nAvg: ${lanes.length > 0 ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) : "0"}`} - > -
- {lanes.slice(-Math.min(lanes.length, 40)).map((lane, i) => { - const barPct = - maxLaneCount > 0 ? Math.max(4, Math.round((lane.count / maxLaneCount) * 100)) : 4; - const color = - lane.count > 5 - ? "#f87171" - : lane.count > 2 - ? "#fbbf24" - : lane.count > 0 - ? "#34d399" - : "#1e1e2e"; - return ( -
- ); - })} - {lanes.length === 0 && ( -

- No concurrency data -

- )} -
- - -
- -
-

Peak

-

{maxLaneCount}

-
-
- l.count > 0).length} of ${lanes.length} intervals have active sessions.`} - > -
-

Active

-

- {lanes.filter((l) => l.count > 0).length} -

-
-
- -
-

Avg

-

- {lanes.length > 0 - ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) - : "0"} -

-
-
-
-
-
- -
-
-
-
- - Tool Usage -
- top {topTools.length} -
- -
- {topTools.map((tool, i) => { - const pct = maxToolCount > 0 ? Math.round((tool.count / maxToolCount) * 100) : 0; - const colors = [ - "bg-amber-400", - "bg-blue-400", - "bg-emerald-400", - "bg-violet-400", - "bg-pink-400", - "bg-cyan-400", - "bg-red-400", - "bg-indigo-400", - ]; - return ( - -
- - {tool.tool_name} - -
-
-
- - {tool.count > 999 ? `${(tool.count / 1000).toFixed(1)}K` : tool.count} - -
- - ); - })} - {topTools.length === 0 && ( -

No tool data yet

- )} -
-
- -
-
-
- - - Subagent Effectiveness - -
-
- -
- {effectiveness.map((item, i) => { - const color = - item.successRate >= 90 - ? "bg-emerald-400" - : item.successRate >= 70 - ? "bg-amber-400" - : "bg-red-400"; - return ( - -
- - {item.subagent_type || "default"} - -
-
-
- = 90 ? "text-emerald-400" : item.successRate >= 70 ? "text-amber-400" : "text-red-400"}`} - > - {item.successRate.toFixed(0)}% - -
- - ); - })} - {effectiveness.length === 0 && ( -

No subagent data yet

- )} -
-
-
- -
-
-
-
- - Integration -
- - {info.hooks.installed ? "Active" : "Offline"} - -
- - {Object.entries(info.hooks.hooks || {}).length > 0 ? ( -
- {Object.entries(info.hooks.hooks).map(([cwd, active]) => ( - -
-
- - {cwd.split("/").pop() || cwd} - -
- - ))} -
- ) : ( -
- -

No project hooks registered

-
- )} - - -
- -
-

WebSocket Active

-

- {info.server.ws_connections} connection - {info.server.ws_connections !== 1 ? "s" : ""} -

-
-
-
-
- -
-
-
- - Platform -
- {info.server.node_version} -
- -
- {[ - { - label: "Journal Mode", - value: info.db.pragmas?.journal_mode?.toUpperCase() || "WAL", - }, - { - label: "Synchronous", - value: - info.db.pragmas?.synchronous === 2 - ? "FULL" - : info.db.pragmas?.synchronous === 1 - ? "NORMAL" - : "OFF", - }, - { label: "Auto-Vacuum", value: info.db.pragmas?.auto_vacuum > 0 ? "FULL" : "OFF" }, - { label: "Foreign Keys", value: info.db.pragmas?.foreign_keys ? "ON" : "OFF" }, - { label: "Busy Timeout", value: `${info.db.pragmas?.busy_timeout || 5000}ms` }, - { label: "Platform", value: `${info.server.platform} / ${info.server.arch}` }, - ].map((row) => ( -
- {row.label} - {row.value} -
- ))} -
- - -
- - {info.db.path} -
-
-
-
-
- ); -} - -// ─── Main Dashboard ─────────────────────────────────────────────────────────── - -function localDateStr(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} - -export function Dashboard() { - const navigate = useNavigate(); - const { t, i18n } = useTranslation("dashboard"); - const locale = i18n.resolvedLanguage ?? i18n.language; - - const [activeTab, setActiveTab] = useState<"monitor" | "health">(() => { - return (localStorage.getItem("dashboard_tab") as "monitor" | "health") || "monitor"; - }); - - useEffect(() => { - localStorage.setItem("dashboard_tab", activeTab); - }, [activeTab]); - - const [analyticsTab, setAnalyticsTab] = useState< - "cost" | "tokens" | "productivity" | "workflow" - >("cost"); - - // Active-agents + headline stats (carried over from upstream Dashboard). - const [stats, setStats] = useState(null); - const [activeAgents, setActiveAgents] = useState([]); - const [allSubagents, setAllSubagents] = useState([]); - const [expandedAgents, setExpandedAgents] = useState>(new Set()); - const [error, setError] = useState(null); - - // Analytics data (merged in from the upstream Analytics page). - const [analyticsData, setAnalyticsData] = useState(null); - const [costData, setCostData] = useState(null); - // CLOSEDLOOP FEA-1434: opt-in display of the subscription "would have cost". - // Read once on mount from the localStorage pref written in Settings; the - // Settings/Dashboard routes unmount on navigation, so returning here re-reads - // the latest value. Default off — subscription cost is hypothetical and is - // never part of the billed headline regardless of this flag. - const [showHypotheticalCost] = useState(() => loadLedgerPrefs().showHypotheticalCost); - - const agentsContainerRef = useRef(null); - const [visibleAgentCount, setVisibleAgentCount] = useState(5); - - useEffect(() => { - const AGENT_ROW_H = 56; - const HEADER_H = 40; - - function recalc() { - if (agentsContainerRef.current) { - const h = agentsContainerRef.current.clientHeight; - setVisibleAgentCount(Math.max(3, Math.floor((h - HEADER_H) / AGENT_ROW_H))); - } - } - - const ro = new ResizeObserver(recalc); - if (agentsContainerRef.current) ro.observe(agentsContainerRef.current); - recalc(); - - return () => ro.disconnect(); - }, [activeTab]); - - const load = useCallback(async () => { - try { - const [statsRes, workingRes, waitingRes, costRes, analyticsRes] = await Promise.all([ - api.stats.get(), - api.agents.list({ status: "working", limit: 20 }), - api.agents.list({ status: "waiting", limit: 20 }), - api.pricing.totalCost().catch(() => null), - api.analytics.get().catch(() => null), - ]); - setStats(statsRes); - const active = [...workingRes.agents, ...waitingRes.agents]; - setActiveAgents(active); - setCostData(costRes); - setAnalyticsData(analyticsRes); - setError(null); - - const activeSessionIds = [ - ...new Set(active.filter((a) => a.type === "main").map((a) => a.session_id)), - ]; - const subagentResults = await Promise.all( - activeSessionIds.map((sid) => api.agents.list({ session_id: sid, limit: 100 })) - ); - const subs = subagentResults.flatMap((r) => r.agents).filter((a) => a.type === "subagent"); - setAllSubagents(subs); - } catch (err) { - setError(err instanceof Error ? err.message : t("failedLoad")); - } - }, [t]); - - useEffect(() => { - load(); - const interval = setInterval(load, 10000); - return () => clearInterval(interval); - }, [load]); - - useEffect(() => { - const parentsWithActive = new Set(); - for (const a of allSubagents) { - if (a.parent_agent_id && a.status === "working") { - parentsWithActive.add(a.parent_agent_id); - } - } - if (parentsWithActive.size === 0) return; - - const subMap = new Map(allSubagents.map((a) => [a.id, a])); - const toExpand = new Set(); - for (const pid of parentsWithActive) { - let cur = pid; - while (cur) { - toExpand.add(cur); - const parent = subMap.get(cur); - cur = parent?.parent_agent_id ?? ""; - } - } - setExpandedAgents((prev) => { - const newIds = [...toExpand].filter((id) => !prev.has(id)); - if (newIds.length === 0) return prev; - return new Set([...prev, ...newIds]); - }); - }, [allSubagents]); - - useEffect(() => { - const debounceRef = { timer: null as ReturnType | null }; - return eventBus.subscribe((msg: WSMessage) => { - if ( - msg.type === "agent_created" || - msg.type === "agent_updated" || - msg.type === "session_created" || - msg.type === "session_updated" || - msg.type === "new_event" - ) { - if (debounceRef.timer) clearTimeout(debounceRef.timer); - debounceRef.timer = setTimeout(load, 300); - } - }); - }, [load]); - - const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); - - const agentTree = useMemo(() => { - const childrenByParent = new Map(); - for (const a of allSubagents) { - if (a.parent_agent_id) { - const list = childrenByParent.get(a.parent_agent_id) || []; - list.push(a); - childrenByParent.set(a.parent_agent_id, list); - } - } - - const descendantCache = new Map(); - function getDescendants(id: string): { total: number; active: number } { - if (descendantCache.has(id)) return descendantCache.get(id)!; - const kids = childrenByParent.get(id) || []; - const result = kids.reduce( - (acc, k) => { - const child = getDescendants(k.id); - return { - total: acc.total + 1 + child.total, - active: acc.active + (k.status === "working" ? 1 : 0) + child.active, - }; - }, - { total: 0, active: 0 } - ); - descendantCache.set(id, result); - return result; - } - for (const a of allSubagents) getDescendants(a.id); - - return { childrenByParent, getDescendants }; - }, [allSubagents]); - - // ── Analytics-derived data ──────────────────────────────────────────────── - - const dailyMap = useMemo(() => { - const m: Record = {}; - for (const d of analyticsData?.daily_events ?? []) { - m[d.date] = (m[d.date] ?? 0) + d.count; - } - return m; - }, [analyticsData]); - - const today = useMemo(() => { - const d = new Date(); - d.setHours(12, 0, 0, 0); - return d; - }, []); - - const weeks = useMemo(() => { - const startDate = new Date(today); - startDate.setDate(today.getDate() - 364); - const startDow = startDate.getDay(); - startDate.setDate(startDate.getDate() - startDow); - - const result: Array> = []; - for (let w = 0; w < 53; w++) { - const week: Array<{ date: string; count: number }> = []; - for (let d = 0; d < 7; d++) { - const cell = new Date(startDate); - cell.setDate(startDate.getDate() + w * 7 + d); - if (cell > today) break; - const dateStr = localDateStr(cell); - week.push({ date: dateStr, count: dailyMap[dateStr] ?? 0 }); - } - if (week.length > 0) result.push(week); - } - return result; - }, [today, dailyMap]); - - const last30 = useMemo( - () => - Array.from({ length: 30 }, (_, i) => { - const d = new Date(today); - d.setDate(today.getDate() - (29 - i)); - const dateStr = localDateStr(d); - return { date: dateStr, count: dailyMap[dateStr] ?? 0 }; - }), - [today, dailyMap] - ); - - const dailySessionsLocal = useMemo(() => { - const result: Array<{ date: string; count: number }> = []; - const sessMap: Record = {}; - for (const d of analyticsData?.daily_sessions ?? []) { - sessMap[d.date] = (sessMap[d.date] ?? 0) + d.count; - } - for (const [date, count] of Object.entries(sessMap)) { - result.push({ date, count }); - } - result.sort((a, b) => a.date.localeCompare(b.date)); - return result; - }, [analyticsData]); - - const costMap = useMemo(() => { - const m: Record = {}; - for (const d of costData?.daily_costs ?? []) { - m[d.date] = (m[d.date] ?? 0) + d.cost; - } - return m; - }, [costData]); - - const dailyCostLast30 = useMemo( - () => - Array.from({ length: 30 }, (_, i) => { - const d = new Date(today); - d.setDate(today.getDate() - (29 - i)); - const dateStr = localDateStr(d); - return { date: dateStr, cost: costMap[dateStr] ?? 0 }; - }), - [today, costMap] - ); - - const peakCostDay = dailyCostLast30.reduce( - (max, curr) => (curr.cost > max.cost ? curr : max), - dailyCostLast30[0] ?? { date: "", cost: 0 } - ); - const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0); - const costBreakdown = [...(costData?.breakdown ?? [])] - .filter((b) => b.cost > 0) - .sort((a, b) => b.cost - a.cost); - - // CLOSEDLOOP FEA-1434 two-ledger headline. `costData.total_cost` is already - // the billed figure (metered + unknown) — the server excludes - // subscription-covered spend — so the Total Cost pill value stays the honest - // billed number. `cost_by_ledger.subscription` is the hypothetical "would - // have cost" of subscription sessions; it is surfaced in the pill subtitle - // ONLY when the user opts in via Settings, and is never added to the headline. - // The upstream CostResult type predates cost_by_ledger, so read it via cast. - const costByLedger = (costData as (CostResult & { cost_by_ledger?: CostByLedger }) | null) - ?.cost_by_ledger; - const subscriptionCost = costByLedger?.subscription ?? 0; - const modelCountSub = costData - ? `${costData.breakdown.length} model${costData.breakdown.length === 1 ? "" : "s"}` - : "No cost data yet"; - const costPillSub = - showHypotheticalCost && subscriptionCost > 0 - ? `${modelCountSub} · +${fmtCost(subscriptionCost)} subscription-covered` - : modelCountSub; - - const weekdayCosts = useMemo(() => { - const weekdayOrder = [1, 2, 3, 4, 5, 6, 0]; - return weekdayOrder.map((dow) => { - const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format( - new Date(Date.UTC(2026, 0, 4 + dow)) - ); - const cost = dailyCostLast30 - .filter((day) => new Date(day.date + "T12:00:00").getDay() === dow) - .reduce((sum, day) => sum + day.cost, 0); - return { label, cost }; - }); - }, [locale, dailyCostLast30]); - const maxWeekdayCost = Math.max(...weekdayCosts.map((d) => d.cost), 1); - - const totalTokens = - (analyticsData?.tokens.total_input ?? 0) + - (analyticsData?.tokens.total_output ?? 0) + - (analyticsData?.tokens.total_cache_read ?? 0) + - (analyticsData?.tokens.total_cache_write ?? 0); - - const tokenMixSegments = [ - { label: "Input", value: analyticsData?.tokens.total_input ?? 0, color: "#60a5fa" }, - { label: "Output", value: analyticsData?.tokens.total_output ?? 0, color: "#34d399" }, - { label: "Cache Read", value: analyticsData?.tokens.total_cache_read ?? 0, color: "#a78bfa" }, - { - label: "Cache Write", - value: analyticsData?.tokens.total_cache_write ?? 0, - color: "#facc15", - }, - ].filter((s) => s.value > 0); - - const maxToolCount = analyticsData?.tool_usage[0]?.count ?? 1; - const maxAgentTypeCount = analyticsData?.agent_types[0]?.count ?? 1; - const maxEventTypeCount = analyticsData?.event_types[0]?.count ?? 1; - - const cacheHitPct = - totalTokens > 0 - ? Math.round(((analyticsData?.tokens.total_cache_read ?? 0) / totalTokens) * 100) - : 0; - - const sessionOutcomeSegments = [ - { label: "Completed", value: analyticsData?.sessions_by_status?.completed ?? 0, color: "#8b5cf6" }, - { label: "Active", value: analyticsData?.sessions_by_status?.active ?? 0, color: "#10b981" }, - { label: "Error", value: analyticsData?.sessions_by_status?.error ?? 0, color: "#ef4444" }, - { - label: "Abandoned", - value: analyticsData?.sessions_by_status?.abandoned ?? 0, - color: "#f59e0b", - }, - ].filter((s) => s.value > 0); - - const agentStatusSegments = [ - { label: "Completed", value: analyticsData?.agents_by_status?.completed ?? 0, color: "#8b5cf6" }, - { label: "Working", value: analyticsData?.agents_by_status?.working ?? 0, color: "#10b981" }, - { label: "Waiting", value: analyticsData?.agents_by_status?.waiting ?? 0, color: "#eab308" }, - { label: "Error", value: analyticsData?.agents_by_status?.error ?? 0, color: "#ef4444" }, - ].filter((s) => s.value > 0); - - const EVENT_TYPE_COLORS: Record = { - PreToolUse: "bg-emerald-400", - PostToolUse: "bg-blue-400", - Stop: "bg-violet-400", - SubagentStop: "bg-yellow-400", - Notification: "bg-orange-400", - }; - - if (error) { - return ( -
-

{t("failedConnect")}

-

{error}

- -
- ); - } - - return ( -
-
-
-
- -
-
-
-

{t("title")}

- {wsConnected ? ( - - - {t("common:live")} - - ) : ( - - - {t("common:offline")} - - )} -
-

{t("subtitle")}

-
-
-
-
- - -
- -
-
- - {activeTab === "monitor" ? ( -
- {/* 5 stat pills */} -
- - - - - -
- - {/* Active Agents — full width, directly under the stat pills */} -
-
-

{t("activeAgentsSection")}

- -
- {activeAgents.length === 0 ? ( - - ) : ( -
- {(() => { - const { childrenByParent, getDescendants } = agentTree; - - function renderAgentNode(agent: Agent, depth: number) { - const children = childrenByParent.get(agent.id) || []; - const isExpanded = expandedAgents.has(agent.id); - const hasChildren = children.length > 0; - const isSubagent = depth > 0; - const { total: totalDesc, active: activeDesc } = hasChildren - ? getDescendants(agent.id) - : { total: 0, active: 0 }; - const toggleExpanded = () => - setExpandedAgents((prev) => { - const next = new Set(prev); - if (next.has(agent.id)) next.delete(agent.id); - else next.add(agent.id); - return next; - }); - - return ( -
-
- {hasChildren && ( - - )} - {!hasChildren && } - {isSubagent && ( - - )} -
- -
-
- - {hasChildren && isExpanded && ( -
- {children.map((child) => renderAgentNode(child, depth + 1))} -
- )} - - {hasChildren && !isExpanded && ( - - )} -
- ); - } - - return ( - <> - {activeAgents - .filter((a) => a.type === "main") - .slice(0, visibleAgentCount) - .map((main) => renderAgentNode(main, 0))} - {activeAgents - .filter((a) => a.type === "subagent") - .map((agent) => ( -
- -
- ))} - - ); - })()} -
- )} -
- - {/* Event activity heatmap + Last 30 days sparkline */} -
-
-

Event Activity

-

Last 52 weeks · daily event counts

-
-
- -
-
-
-
-

Last 30 Days

-

Daily event count

- -
- {last30[0]?.date?.slice(5)} - {last30[last30.length - 1]?.date?.slice(5)} -
-
-
- Peak day - - d.count)).toLocaleString()}> - {fmt(Math.max(...last30.map((d) => d.count)))} - {" "} - events - -
-
- Total (30d) - - s + d.count, 0).toLocaleString()}> - {fmt(last30.reduce((s, d) => s + d.count, 0))} - {" "} - events - -
-
-
-
- - {/* Inner analytics tabs */} -
-
- {( - [ - { key: "cost" as const, label: "Cost Analytics" }, - { key: "tokens" as const, label: "Token Analytics" }, - { key: "productivity" as const, label: "Productivity Analytics" }, - { key: "workflow" as const, label: "Workflow Intelligence" }, - ] as const - ).map(({ key, label }) => ( - - ))} -
- - {analyticsTab === "cost" && ( -
-
-

Daily Cost Trends

- {Object.keys(costMap).length === 0 ? ( -

No daily cost data yet

- ) : ( - <> -

Cost per day

- -
- {dailyCostLast30[0]?.date?.slice(5)} - - {dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)} - -
-
-
- Peak cost day - - - {fmtCost(peakCostDay.cost)} - - -
-
- Total (30d) - - {fmtCost(totalCost30d)} - -
-
- - )} -
- -
-

Cost by Model

- {costBreakdown.length > 0 ? ( - <> - ({ - label: formatModelName(b.model) ?? b.model, - value: Math.round(b.cost * 100), - color: - ["#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#ec4899"][ - i % 6 - ] ?? "#6b7280", - }))} - formatTotal={(cents) => fmtCost(cents / 100)} - /> -
- {costBreakdown.map((b) => ( -
- - {formatModelName(b.model)} - - - {fmtCost(b.cost)} - -
- ))} -
- Total - - - {fmtCost(costData?.total_cost ?? 0)} - - -
-
- - ) : ( -

No cost data yet

- )} -
- -
-

Cost by Weekday

- {Object.keys(costMap).length === 0 ? ( -

No daily cost data yet

- ) : ( - <> -

Last 30 days

-
- {weekdayCosts.map(({ label, cost }) => ( - - ))} -
-
- Total - - {fmtCost(totalCost30d)} - -
- - )} -
-
- )} - - {analyticsTab === "tokens" && ( -
-
-

Token Distribution

-
- {[ - { - label: "Input", - value: analyticsData?.tokens.total_input ?? 0, - color: "bg-blue-400", - }, - { - label: "Output", - value: analyticsData?.tokens.total_output ?? 0, - color: "bg-emerald-400", - }, - { - label: "Cache Read", - value: analyticsData?.tokens.total_cache_read ?? 0, - color: "bg-violet-400", - }, - { - label: "Cache Write", - value: analyticsData?.tokens.total_cache_write ?? 0, - color: "bg-yellow-400", - }, - ].map(({ label, value, color }) => ( - - ))} -
-
-
- Total tokens - - {fmt(totalTokens)} - -
-
- Cache efficiency - {cacheHitPct}% -
-
-
- -
-

Token Breakdown

-
- {[ - { - label: "Input", - value: analyticsData?.tokens.total_input ?? 0, - color: "text-blue-400", - }, - { - label: "Output", - value: analyticsData?.tokens.total_output ?? 0, - color: "text-emerald-400", - }, - { - label: "Cache Read", - value: analyticsData?.tokens.total_cache_read ?? 0, - color: "text-violet-400", - }, - { - label: "Cache Write", - value: analyticsData?.tokens.total_cache_write ?? 0, - color: "text-yellow-400", - }, - { label: "Total", value: totalTokens, color: "text-gray-100" }, - ].map(({ label, value, color }) => ( -
- {label} - - {value.toLocaleString()} - -
- ))} -
- {totalTokens === 0 && ( -

- Token data will appear once sessions report usage. -

- )} -
- -
-

Token Mix

- {tokenMixSegments.length === 0 ? ( -

No data

- ) : ( - <> - fmt(total)} /> -
- {tokenMixSegments.map((segment) => ( -
- {segment.label} - - {fmt(segment.value)} - -
- ))} -
- - )} -
-
- )} - - {analyticsTab === "productivity" && ( -
-
-

Tool Usage

- {(analyticsData?.tool_usage ?? []).length === 0 ? ( -

No tool data yet

- ) : ( -
- {(analyticsData?.tool_usage ?? []) - .slice(0, 12) - .map(({ tool_name, count }) => ( - - ))} -
- )} -
- -
-

Session Outcomes

- -
-
- Total sessions - - - {fmt(analyticsData?.overview.total_sessions ?? 0)} - - -
- {sessionOutcomeSegments.map((s) => ( -
- - - {s.label} - - - {fmt(s.value)} - -
- ))} -
-
- -
-

Daily Session Trends

- {dailySessionsLocal.length === 0 ? ( -

No session trend data

- ) : ( - <> - -
- {dailySessionsLocal - .slice(-7) - .reverse() - .map(({ date, count }) => { - const maxD = Math.max( - ...(dailySessionsLocal.length > 0 - ? dailySessionsLocal - : [{ count: 1 }] - ).map((d) => d.count) - ); - return ( -
- - {date.slice(5)} - -
-
-
- - {count} - -
- ); - })} -
-

Last 7 days

- - )} -
-
- )} - - {analyticsTab === "workflow" && ( -
-
-

Subagent Types

- {(analyticsData?.agent_types ?? []).length === 0 ? ( -

No subagent data yet

- ) : ( -
- {(analyticsData?.agent_types ?? []) - .slice(0, 10) - .map(({ subagent_type, count }) => ( - - ))} -
- )} -
- -
-

Agent Status

- -
-
- Total agents - - - {fmt(analyticsData?.overview.total_agents ?? 0)} - - -
- {agentStatusSegments.map((s) => ( -
- - - {s.label} - - - {fmt(s.value)} - -
- ))} -
-
- -
-

Event Types

- {(analyticsData?.event_types ?? []).length === 0 ? ( -

No event data yet

- ) : ( -
- {(analyticsData?.event_types ?? []).map(({ event_type, count }) => ( - - ))} -
- )} -
-
- )} -
-
- ) : ( - - )} -
- ); -} diff --git a/apps/desktop/scripts/agent-monitor-client/Sessions.tsx b/apps/desktop/scripts/agent-monitor-client/Sessions.tsx deleted file mode 100644 index 8ad81c10..00000000 --- a/apps/desktop/scripts/agent-monitor-client/Sessions.tsx +++ /dev/null @@ -1,453 +0,0 @@ -/** - * @file Sessions.tsx - * @description Displays a list of all recorded sessions with filtering, - * searching, and pagination features. - */ - -import { useEffect, useState, useCallback, useSyncExternalStore } from "react"; -import { Link, useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { - FolderOpen, - Search, - ChevronRight, - RefreshCw, - SortDesc, - SortAsc, - ChevronDown, - Play, -} from "lucide-react"; -import { api } from "../lib/api"; -import { eventBus } from "../lib/eventBus"; -import { SessionStatusBadge, HarnessBadge, BillingBadge } from "../components/StatusBadge"; -import { EmptyState } from "../components/EmptyState"; -import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format"; -import { effectiveSessionStatus, isSessionAwaitingInput } from "../lib/types"; -import type { Session, DashboardEvent } from "../lib/types"; - -const PAGE_SIZE = 10; -export function Sessions() { - const navigate = useNavigate(); - const { t } = useTranslation("sessions"); - const [sessions, setSessions] = useState([]); - const [total, setTotal] = useState(0); - const [filter, setFilter] = useState(""); - const [searchInput, setSearchInput] = useState(""); - const [search, setSearch] = useState(""); - const [loading, setLoading] = useState(true); - const [page, setPage] = useState(0); - - const [cwd, setCwd] = useState(""); - const [sortBy, setSortBy] = useState("time"); - const [sortDesc, setSortDesc] = useState(true); - const [directories, setDirectories] = useState([]); - const [dashboardRunIds, setDashboardRunIds] = useState>(new Set()); - const [harness, setHarness] = useState(""); - - const HARNESS_OPTIONS: Array<{ label: string; value: string }> = [ - { label: "All Harnesses", value: "" }, - { label: "Claude", value: "claude" }, - { label: "Codex", value: "codex" }, - { label: "Cursor", value: "cursor" }, - { label: "Copilot", value: "copilot" }, - { label: "OpenCode", value: "opencode" }, - ]; - - const FILTER_OPTIONS: Array<{ label: string; value: string }> = [ - { label: t("filterAll"), value: "" }, - { label: t("filterActive"), value: "active" }, - { label: t("filterWaiting"), value: "waiting" }, - { label: t("filterCompleted"), value: "completed" }, - { label: t("filterError"), value: "error" }, - { label: t("filterAbandoned"), value: "abandoned" }, - ]; - - useEffect(() => { - const id = window.setTimeout(() => setSearch(searchInput.trim()), 300); - return () => window.clearTimeout(id); - }, [searchInput]); - - useEffect(() => { - api.sessions - .facets() - .then((res) => { - setDirectories(res.cwds); - }) - .catch(console.error); - }, []); - - const load = useCallback(async () => { - try { - if (filter === "waiting") { - const res = await api.sessions.list({ - status: "active", - q: search || undefined, - cwd: cwd || undefined, - harness: harness || undefined, - sort_by: sortBy, - sort_desc: sortDesc, - limit: 10000, - offset: 0, - }); - let rows = res.sessions; - rows = rows.filter(isSessionAwaitingInput); - setTotal(rows.length); - setSessions(rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)); - return; - } - const params: { - status?: string; - q?: string; - cwd?: string; - harness?: string; - sort_by?: string; - sort_desc?: boolean; - limit: number; - offset: number; - } = { - limit: PAGE_SIZE, - offset: page * PAGE_SIZE, - sort_by: sortBy, - sort_desc: sortDesc, - }; - if (filter) params.status = filter; - if (search) params.q = search; - if (cwd) params.cwd = cwd; - if (harness) params.harness = harness; - const res = await api.sessions.list(params); - setSessions(res.sessions); - setTotal(res.total); - } finally { - setLoading(false); - } - }, [filter, harness, search, cwd, sortBy, sortDesc, page]); - - useEffect(() => { - load(); - }, [load]); - - useEffect(() => { - setPage(0); - }, [filter, harness, search, cwd, sortBy, sortDesc]); - - useEffect(() => { - return eventBus.subscribe((msg) => { - if (msg.type === "session_created" || msg.type === "session_updated") { - load(); - } - if (msg.type === "new_event") { - const ev = msg.data as DashboardEvent; - if (ev.event_type === "Stop" || ev.event_type === "SessionEnd") { - load(); - } - } - if (msg.type === "run_status") { - loadDashboardRuns(); - } - }); - }, [load]); - - const loadDashboardRuns = useCallback(() => { - api.run - .list() - .then((r) => { - const ids = new Set(); - for (const h of r.items) { - if (h.sessionId) ids.add(h.sessionId); - } - setDashboardRunIds(ids); - }) - .catch(() => undefined); - }, []); - - useEffect(() => { - loadDashboardRuns(); - const t = setInterval(loadDashboardRuns, 15000); - return () => clearInterval(t); - }, [loadDashboardRuns]); - - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); - - return ( -
-
-
-
- -
-
-
-

{t("title")}

- {wsConnected ? ( - - - {t("common:live")} - - ) : ( - - - {t("common:offline")} - - )} -
-

- {t("sessionCount", { count: total })} - {filter ? ` ${filter}` : ""} -

-
-
- -
- -
-
-
- - setSearchInput(e.target.value)} - className="input w-full pl-10" - /> -
- -
- - -
- -
-
- - -
-
- -
-
- -
-
- {HARNESS_OPTIONS.map((opt) => ( - - ))} -
- -
- {FILTER_OPTIONS.map((opt) => ( - - ))} -
-
-
- - {!loading && sessions.length === 0 ? ( - - ) : ( - <> -
- - - - - - - - - - - - - - - {sessions.map((session) => ( - navigate(`/sessions/${session.id}`)} - className="hover:bg-surface-4 transition-colors cursor-pointer group" - > - - - - - - - - - - ))} - -
- {t("tableSession")} - - {t("tableStatus")} - - {t("tableLastActive")} - - {t("tableDuration")} - - {t("tableAgents")} - - {t("tableCost")} - - {t("tableDirectory")} -
-
-
-

- {session.name || `${t("defaultName")}${session.id.slice(0, 8)}`} -

- - - {dashboardRunIds.has(session.id) && ( - e.stopPropagation()} - className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-300 bg-emerald-500/10 border border-emerald-500/25 hover:bg-emerald-500/20 hover:text-emerald-200 px-1.5 py-0.5 rounded-full transition-colors" - title={t("dashboardRunBadge", "Driven by Run page · click to open")} - > - - {t("common:dashboardRun", "Run")} - - )} -
-

- {session.id.slice(0, 12)} -

-
-
- - - {formatDateTime(session.last_activity || session.started_at)} - - {session.ended_at - ? formatDuration(session.started_at, session.ended_at) - : t("common:running")} - - {session.agent_count ?? "-"} - - {/* CLOSEDLOOP FEA-1433: never render a silent $0 for models the - token-cost engine (genai-prices) cannot price. A priced total - still shows; any unpriced models surface as an amber badge whose - tooltip names them, distinguishing "unpriced" from a real $0. */} - {session.unpriced_models && session.unpriced_models.length > 0 ? ( - - {session.cost != null && session.cost > 0 ? ( - {fmtCost(session.cost)} - ) : null} - - {session.cost != null && session.cost > 0 - ? t("costPartial", "partial") - : t("costUnpriced", "not priced")} - - - ) : session.cost != null && session.cost > 0 ? ( - fmtCost(session.cost) - ) : ( - "-" - )} - - {session.cwd ? truncate(session.cwd, 30) : "-"} - - -
-
- {totalPages > 1 && ( -
- - {t("common:pagination.showing", { - from: page * PAGE_SIZE + 1, - to: Math.min((page + 1) * PAGE_SIZE, total), - total, - })} - -
- - - {page + 1} / {totalPages} - - -
-
- )} - - )} -
- ); -} diff --git a/apps/desktop/scripts/agent-monitor-client/Settings.tsx b/apps/desktop/scripts/agent-monitor-client/Settings.tsx deleted file mode 100644 index 717b0007..00000000 --- a/apps/desktop/scripts/agent-monitor-client/Settings.tsx +++ /dev/null @@ -1,1165 +0,0 @@ -/** - * @file Settings.tsx - * @description Provides a settings page for managing model pricing rules, notification preferences, and system information with real-time updates and actionable controls for data management and hook configuration. - * @author Son Nguyen - */ - -import { useEffect, useState, useCallback, useRef, useSyncExternalStore } from "react"; -import { useTranslation } from "react-i18next"; -import { - DollarSign, - RefreshCw, - Database, - Plug, - HardDrive, - AlertTriangle, - RotateCcw, - CheckCircle, - XCircle, - Server, - Bell, - BellOff, - BellRing, - FileDown, - Eraser, - Play, - Zap, - AlertCircle, - GitBranch, - ShieldCheck, - ShieldAlert, - ShieldX, - Clock, - Cpu, - Globe, - Wifi, - Activity, - Users, - Layers, - Coins, - BarChart3, - Settings as SettingsIcon, - FolderOpen, -} from "lucide-react"; -import { api } from "../lib/api"; -import { eventBus } from "../lib/eventBus"; -import { fmt, fmtCost } from "../lib/format"; -import { subscribeToPush, unsubscribeFromPush } from "../lib/push"; -import { Tip } from "../components/Tip"; -import { ImportHistory } from "../components/ImportHistory"; -// CLOSEDLOOP FEA-1433: the hand-editable pricing table is gone. genai-prices is -// the single source of truth for rates, so the ModelPricing CRUD type is no -// longer used by this read-only catalog view. -import type { WSMessage } from "../lib/types"; -// CLOSEDLOOP FEA-1434: shared two-ledger client helper. Settings is the writer -// of the "show hypothetical API cost" preference; the Dashboard reads it. -import { - loadLedgerPrefs, - saveLedgerPrefs, - type LedgerPrefs, -} from "../lib/closedloop-ledger"; - -// ─── Notification preferences ─── - -const NOTIF_KEY = "agent-monitor-notifications"; - -interface NotifPrefs { - enabled: boolean; - onNewSession: boolean; - onSessionError: boolean; - onSessionComplete: boolean; - onSubagentSpawn: boolean; -} - -const defaultNotif: NotifPrefs = { - enabled: false, - onNewSession: true, - onSessionError: true, - onSessionComplete: false, - onSubagentSpawn: false, -}; - -function loadNotifPrefs(): NotifPrefs { - try { - const raw = localStorage.getItem(NOTIF_KEY); - if (!raw) return { ...defaultNotif }; - return { ...defaultNotif, ...JSON.parse(raw) }; - } catch { - return { ...defaultNotif }; - } -} - -function saveNotifPrefs(prefs: NotifPrefs) { - localStorage.setItem(NOTIF_KEY, JSON.stringify(prefs)); -} - -// ─── Helpers ─── - -// CLOSEDLOOP FEA-1433: the read-only pricing catalog reflects what the -// canonical token-cost engine (genai-prices) computed for the models actually -// used. Each row mirrors a /api/pricing/cost breakdown entry, including the -// `priced` flag and `unpriced_reason`, so unpriced models surface honestly -// instead of collapsing to a silent $0. The upstream CostBreakdown type does -// not declare these engine fields, so we type them locally. -interface PricedBreakdownRow { - model: string; - provider: string | null; - cost: number | null; - input_cost: number | null; - output_cost: number | null; - input_tokens: number; - output_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; - priced: boolean; - unpriced_reason: string | null; -} - -interface PricingEngineStamp { - name: string; - version: string; -} - -interface SystemInfo { - db: { path: string; size: number; counts: Record }; - hooks: { installed: boolean; path: string; hooks: Record }; - server: { uptime: number; node_version: string; platform: string; ws_connections: number }; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function formatUptime(seconds: number): string { - const d = Math.floor(seconds / 86400); - const h = Math.floor((seconds % 86400) / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (d > 0) return `${d}d ${h}h ${m}m`; - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} - -function useCountUp(end: number | null, durationMs = 1000) { - const [count, setCount] = useState(0); - - useEffect(() => { - if (end === null) { - setCount(0); - return; - } - - let startTimestamp: number | null = null; - let animationFrameId: number; - const startValue = count; - - const step = (timestamp: number) => { - if (!startTimestamp) startTimestamp = timestamp; - const progress = Math.min((timestamp - startTimestamp) / durationMs, 1); - // easeOutQuart - const easeProgress = 1 - Math.pow(1 - progress, 4); - setCount(startValue + (end - startValue) * easeProgress); - - if (progress < 1) { - animationFrameId = window.requestAnimationFrame(step); - } else { - setCount(end); - } - }; - - animationFrameId = window.requestAnimationFrame(step); - return () => window.cancelAnimationFrame(animationFrameId); - }, [end, durationMs]); - - return count; -} - -// ─── Toggle component ─── - -function Toggle({ - checked, - onChange, - label, - description, -}: { - checked: boolean; - onChange: (v: boolean) => void; - label: string; - description?: string; -}) { - return ( - - ); -} - -// ─── Main component ─── - -export function Settings() { - const { t } = useTranslation("settings"); - // CLOSEDLOOP FEA-1433: read-only catalog state. `breakdown` is the engine's - // per-model output for the models actually used; `engine` is the genai-prices - // source-of-truth stamp. There is no editor state — the pricing table is no - // longer host-editable (genai-prices owns the rates). - const [breakdown, setBreakdown] = useState([]); - const [engine, setEngine] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [totalCost, setTotalCost] = useState(null); - const [sysInfo, setSysInfo] = useState(null); - const [actionLoading, setActionLoading] = useState(null); - const [actionResult, setActionResult] = useState<{ - key: string; - message: string; - isError: boolean; - } | null>(null); - const [confirmAction, setConfirmAction] = useState(null); - const [notifPrefs, setNotifPrefs] = useState(loadNotifPrefs); - // CLOSEDLOOP FEA-1434: per-user "show hypothetical API cost for subscription - // sessions" preference (localStorage, default off). Read by the Dashboard. - const [ledgerPrefs, setLedgerPrefs] = useState(loadLedgerPrefs); - const [abandonHours, setAbandonHours] = useState("24"); - const [purgeDays, setPurgeDays] = useState("90"); - const [claudeHome, setClaudeHomeState] = useState(""); - const [claudeHomeInput, setClaudeHomeInput] = useState(""); - const [claudeHomeSaving, setClaudeHomeSaving] = useState(false); - const [claudeHomeError, setClaudeHomeError] = useState(null); - - const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); - const animatedTotalCost = useCountUp(totalCost); - - const load = useCallback(async () => { - try { - const [pricingRes, costRes, infoRes, claudeHomeRes] = await Promise.all([ - api.pricing.list(), - api.pricing.totalCost(), - api.settings.info(), - api.settings.claudeHome.get(), - ]); - // The sidecar's GET /api/pricing returns the genai-prices engine stamp - // (single source of truth) alongside the legacy rows; the upstream client - // type predates the stamp, so read it through a narrow cast. - setEngine((pricingRes as { engine?: PricingEngineStamp }).engine ?? null); - setTotalCost(costRes.total_cost); - // The cost breakdown carries the engine's per-model priced/unpriced verdict - // (fields the upstream CostBreakdown type does not declare). - setBreakdown((costRes.breakdown ?? []) as unknown as PricedBreakdownRow[]); - setSysInfo(infoRes); - setClaudeHomeState(claudeHomeRes.claude_home); - setClaudeHomeInput(claudeHomeRes.claude_home); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : t("messages.failedLoad")); - } finally { - setLoading(false); - } - }, [t]); - - useEffect(() => { - load(); - }, [load]); - - useEffect(() => { - const refreshInfo = () => - api.settings - .info() - .then(setSysInfo) - .catch(() => {}); - const interval = setInterval(refreshInfo, 10000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - return eventBus.subscribe((msg: WSMessage) => { - if ( - msg.type === "session_created" || - msg.type === "session_updated" || - msg.type === "agent_created" || - msg.type === "agent_updated" || - msg.type === "new_event" - ) { - api.settings - .info() - .then(setSysInfo) - .catch(() => {}); - } - }); - }, []); - - useEffect(() => { - if (!actionResult) return; - const timeout = setTimeout(() => setActionResult(null), 5000); - return () => clearTimeout(timeout); - }, [actionResult]); - - const updateNotifPrefs = (patch: Partial) => { - setNotifPrefs((prev) => { - const next = { ...prev, ...patch }; - saveNotifPrefs(next); - return next; - }); - }; - - const updateLedgerPrefs = (patch: Partial) => { - setLedgerPrefs((prev) => { - const next = { ...prev, ...patch }; - saveLedgerPrefs(next); - return next; - }); - }; - - const requestNotifPermission = async () => { - if (!("Notification" in window)) return; - const perm = await Notification.requestPermission(); - if (perm === "granted") { - updateNotifPrefs({ enabled: true }); - await subscribeToPush(); - } - }; - - // CLOSEDLOOP FEA-1433: the add/edit/delete pricing handlers were removed. - // genai-prices is the single source of truth for rates, so there is no - // host-editable rule table to write to (the PUT/DELETE /api/pricing routes - // are gone). The catalog below is read-only. - - const runAction = async (key: string, fn: () => Promise) => { - setActionLoading(key); - setActionResult(null); - setConfirmAction(null); - try { - const message = await fn(); - setActionResult({ key, message, isError: false }); - await load(); - } catch (err) { - setActionResult({ - key, - message: t("messages.actionFailed", { - message: err instanceof Error ? err.message : t("messages.unknownError"), - }), - isError: true, - }); - } finally { - setActionLoading(null); - } - }; - - const handleClearData = () => - runAction("clear", async () => { - const res = await api.settings.clearData(); - const total = Object.values(res.cleared).reduce((s, n) => s + n, 0); - return t("danger.clearedResult", { count: total }); - }); - - const handleReinstallHooks = () => - runAction("hooks", async () => { - const res = await api.settings.reinstallHooks(); - return res.ok ? t("hooks.success") : t("hooks.failed"); - }); - - // CLOSEDLOOP FEA-1433: "Reset pricing to defaults" was removed. It reseeded - // the legacy model_pricing table, which no longer feeds any cost calculation - // (genai-prices owns the rates). There is nothing host-editable to reset. - - const handleCleanup = () => - runAction("cleanup", async () => { - const params: { abandon_hours?: number; purge_days?: number } = {}; - const ah = parseFloat(abandonHours); - const pd = parseFloat(purgeDays); - if (ah > 0) params.abandon_hours = ah; - if (pd > 0) params.purge_days = pd; - const res = await api.settings.cleanup(params); - const parts = []; - if (res.abandoned > 0) parts.push(`${res.abandoned}${t("data.abandonedResult")}`); - if (res.purged_sessions > 0) - parts.push( - `${res.purged_sessions}${t("data.purgedResult", { events: res.purged_events, agents: res.purged_agents })}` - ); - return parts.length > 0 ? parts.join(". ") : t("data.nothingToClean"); - }); - - const handleSaveClaudeHome = async () => { - if (claudeHomeInput === claudeHome) return; - setClaudeHomeSaving(true); - setClaudeHomeError(null); - try { - const res = await api.settings.claudeHome.set(claudeHomeInput); - setClaudeHomeState(res.claude_home); - setClaudeHomeInput(res.claude_home); - } catch (err) { - setClaudeHomeError(err instanceof Error ? err.message : t("claudeHome.saveFailed")); - } finally { - setClaudeHomeSaving(false); - } - }; - - // CLOSEDLOOP FEA-1433: the editable pricing-row form (renderEditCells) and its - // edit-mode derivations were removed. The catalog renders the engine's - // per-model verdict read-only; there is no inline editing. - const pricedRows = breakdown.filter((r) => r.priced); - const unpricedRows = breakdown.filter((r) => !r.priced); - - const actionBanner = (keys: string[]) => { - const match = actionResult && keys.includes(actionResult.key) ? actionResult : null; - if (!match) return null; - return ( -
- {match.message} -
- ); - }; - - if (loading) { - return ( -
- {t("common:loading")} -
- ); - } - - return ( -
- {/* Header */} -
-
-
- -
-
-
-

{t("title")}

- {wsConnected ? ( - - - {t("common:live")} - - ) : ( - - - {t("common:offline")} - - )} -
-

{t("subtitle")}

-
-
-
- - - {t("exportData")} - - -
-
- - {/* Cost summary card */} -
-
-
-
- -
-
-

{t("common:cost.totalEstimatedCost")}

-

- - {totalCost !== null ? fmtCost(animatedTotalCost) : "$-.--"} - -

-
-
-
-

{t("acrossSessions")}

-

{t("basedOnUsage")}

-
-
-
- - {/* CLOSEDLOOP FEA-1434: two-ledger display preference. The headline total - counts only really-billed spend (metered API + unknown). Sessions - covered by a flat subscription (Claude Pro/Max, Codex, Cursor Pro, - Copilot) are priced as a hypothetical "would have cost" and kept out - of that total. This opt-in surfaces that hypothetical on the Dashboard; - it never changes the billed headline. Default off. */} -
-
- - updateLedgerPrefs({ showHypotheticalCost: v })} - label={t( - "ledger.showHypothetical", - "Show hypothetical API cost for subscription sessions", - )} - description={t( - "ledger.showHypotheticalDescription", - "Display what subscription-covered usage (Pro/Max, Codex, Cursor Pro, Copilot) would have cost at metered API rates. This is never added to the billed total.", - )} - /> -
-
- - {/* ─── MODEL PRICING (read-only catalog, FEA-1433) ─── - genai-prices is the single source of truth for rates. The host no - longer edits a pricing table; this section shows the engine version - stamp and the engine's per-model verdict for the models actually used, - surfacing unpriced models honestly instead of as a silent $0. */} -
-
-
-

- - {t("pricing.title")} - - {t("pricing.readOnlyBadge", "read-only")} - -

-

- {t( - "pricing.catalogDescription", - "Token costs are computed by the pricing engine below — the single source of truth for rates. This catalog is read-only and lists the models you've actually used.", - )} -

-
- {engine && ( - - - {engine.name} v{engine.version} - - )} -
- - {error && ( -
- {error} -
- )} - - {breakdown.length === 0 ? ( -
- {t( - "pricing.catalogEmpty", - "No token usage yet. Models will appear here with their engine-computed costs once you run sessions.", - )} -
- ) : ( -
- - - - - - - - - - - - - {pricedRows.map((row) => ( - - - - - - - - - ))} - {unpricedRows.map((row) => ( - - - - - - - - - ))} - -
- {t("common:cost.model")} - - {t("pricing.provider", "Provider")} - - {t("pricing.inputCost", "Input cost")} - - {t("pricing.outputCost", "Output cost")} - - {t("pricing.totalCost", "Cost")} - - {t("pricing.status", "Status")} -
{row.model}{row.provider ?? "—"} - {row.input_cost != null ? fmtCost(row.input_cost) : "—"} - - {row.output_cost != null ? fmtCost(row.output_cost) : "—"} - - {row.cost != null ? fmtCost(row.cost) : "—"} - - - {t("pricing.statusPriced", "priced")} - -
{row.model}{row.provider ?? "—"} - - {t("pricing.statusUnpriced", "not priced")} - -
-
- )} - - {unpricedRows.length > 0 && ( -

- {t("pricing.unpricedNote", { - count: unpricedRows.length, - defaultValue: - "{{count}} model(s) could not be priced by the engine and are excluded from cost totals. Update the pricing engine to add coverage.", - })} -

- )} -
- - {/* ─── HOOK CONFIGURATION ─── */} -
-

- - {t("hooks.title")} -

-

{t("hooks.description")}

- -
-
-
- {sysInfo?.hooks.installed ? ( - - {t("hooks.allInstalled")} - - ) : ( - - {t("hooks.incomplete")} - - )} -
- -
- - {actionBanner(["hooks"])} - - {sysInfo && ( - <> -
- {Object.entries(sysInfo.hooks.hooks).map(([hook, active]) => ( -
- {active ? ( - - ) : ( - - )} - {hook} -
- ))} -
-

{sysInfo.hooks.path}

- - )} -
-
- - {/* ─── CLAUDE HOME ─── */} -
-

- - {t("claudeHome.title")} -

-

{t("claudeHome.description")}

- -
-
- { - setClaudeHomeInput(e.target.value); - setClaudeHomeError(null); - }} - className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-violet-500/50" - placeholder={t("claudeHome.placeholder")} - /> - -
- {claudeHomeError &&

{claudeHomeError}

} - {claudeHome && ( -

- {t("claudeHome.current")} {claudeHome} -

- )} -
-
- - {/* ─── IMPORT HISTORY ─── */} - - - {/* ─── NOTIFICATIONS ─── */} -
-

- - {t("notifications.title")} -

-

{t("notifications.description")}

- -
-
-
-
- {notifPrefs.enabled ? ( - - ) : ( - - )} -
- { - if (v) { - if ("Notification" in window && Notification.permission !== "granted") { - requestNotifPermission(); - } else { - updateNotifPrefs({ enabled: true }); - await subscribeToPush(); - } - } else { - updateNotifPrefs({ enabled: false }); - await unsubscribeFromPush(); - } - }} - label={t("notifications.enable")} - /> -
- {"Notification" in window && ( - - {Notification.permission === "granted" ? ( - - ) : Notification.permission === "denied" ? ( - - ) : ( - - )} - {Notification.permission === "granted" - ? t("notifications.granted") - : Notification.permission === "denied" - ? t("notifications.blocked") - : t("notifications.required")} - - )} -
- - {notifPrefs.enabled && ( -
-

- {t("notifications.notifyWhen")} -

-
-
- - updateNotifPrefs({ onNewSession: v })} - label={t("notifications.newSession")} - /> -
-
- - updateNotifPrefs({ onSessionComplete: v })} - label={t("notifications.sessionComplete")} - /> -
-
- - updateNotifPrefs({ onSessionError: v })} - label={t("notifications.sessionError")} - /> -
-
- - updateNotifPrefs({ onSubagentSpawn: v })} - label={t("notifications.subagentSpawned")} - /> -
-
- -
- -
-
- )} - - {!notifPrefs.enabled && ( -
- - {t("notifications.disabledInfo")} -
- )} -
-
- - {/* ─── DATA MANAGEMENT ─── */} -
-

- - {t("data.title")} -

-

{t("data.description")}

- -
-
-
-

- {t("data.dbOverview")} -

- {sysInfo && ( -
- - {sysInfo.db.path} -
- )} -
- - {sysInfo ? ( -
- {(() => { - const tableIcons: Record = { - sessions: , - agents: , - events: , - token_usage: , - model_pricing: , - }; - const tableLabels: Record = { - sessions: t("tables.sessions"), - agents: t("tables.agents"), - events: t("tables.events"), - token_usage: t("tables.sessionsWithCost"), - model_pricing: t("tables.pricingRules"), - }; - const tableColors: Record = { - sessions: "border-blue-500/20", - agents: "border-emerald-500/20", - events: "border-violet-500/20", - token_usage: "border-amber-500/20", - model_pricing: "border-cyan-500/20", - }; - return Object.entries(sysInfo.db.counts).map(([table, count]) => ( -
-
- {tableIcons[table] || } -

- {tableLabels[table] || table.replace(/_/g, " ")} -

-
-

- {fmt(count)} -

-
- )); - })()} -
-
- -

- {t("data.dbSize")} -

-
-

- {formatBytes(sysInfo.db.size)} -

-
-
- ) : ( -

{t("data.loadingDb")}

- )} -
- - {/* Session Cleanup */} -
-
-
- -
-
-

{t("data.sessionCleanup")}

-

{t("data.cleanupDesc")}

-
-
- -
-
- -
- setAbandonHours(e.target.value)} - className="input w-20 text-sm text-right font-mono" - /> - {t("common:hours")} -
-
-
- -
- setPurgeDays(e.target.value)} - className="input w-20 text-sm text-right font-mono" - /> - {t("common:days")} -
-
-
- - - - {actionBanner(["cleanup"])} -
- - {/* Danger zone */} -
-
-
- -
-
-

{t("danger.title")}

-

{t("danger.description")}

-
-
- - {confirmAction === "clear" ? ( -
- {t("danger.warning")} -
- - -
-
- ) : ( - - )} - - {actionBanner(["clear"])} -
-
-
- - {/* ─── ABOUT ─── */} -
-

- - {t("about.title")} -

-

{t("about.description")}

- - {sysInfo ? ( -
-
-
-
- -

- {t("about.uptime")} -

-
-

- {formatUptime(sysInfo.server.uptime)} -

-
-
-
- -

- {t("about.nodejs")} -

-
-

- {sysInfo.server.node_version} -

-
-
-
- -

- {t("about.platform")} -

-
-

{sysInfo.server.platform}

-
-
-
- -

- {t("about.wsClients")} -

-
-

- {sysInfo.server.ws_connections} -

-
-
-
- ) : ( -

{t("about.loadingInfo")}

- )} -
-
- ); -} diff --git a/apps/desktop/scripts/agent-monitor-client/StatusBadge.tsx b/apps/desktop/scripts/agent-monitor-client/StatusBadge.tsx deleted file mode 100644 index e2f1236c..00000000 --- a/apps/desktop/scripts/agent-monitor-client/StatusBadge.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @file StatusBadge.tsx - * @description Defines reusable React components for displaying the status of - * agents and sessions in a visually distinct way using badges. - */ -import { useTranslation } from "react-i18next"; -import { STATUS_CONFIG, SESSION_STATUS_CONFIG } from "../lib/types"; -import type { EffectiveAgentStatus, EffectiveSessionStatus } from "../lib/types"; -import { - isSubscriptionMode, - subscriptionBadgeLabel, -} from "../lib/closedloop-ledger"; - -interface AgentStatusBadgeProps { - status: EffectiveAgentStatus; - pulse?: boolean; -} - -export function AgentStatusBadge({ status, pulse }: AgentStatusBadgeProps) { - const { t } = useTranslation(); - const config = STATUS_CONFIG[status]; - const shouldPulse = pulse ?? (status === "working" || status === "waiting"); - - return ( - - - {t(config.labelKey)} - - ); -} - -interface SessionStatusBadgeProps { - status: EffectiveSessionStatus; - pulse?: boolean; -} - -export function SessionStatusBadge({ status, pulse }: SessionStatusBadgeProps) { - const { t } = useTranslation(); - const config = SESSION_STATUS_CONFIG[status]; - const shouldPulse = pulse ?? status === "waiting"; - return ( - - {shouldPulse && ( - - ); -} - -export function HarnessBadge({ harness }: { harness?: string | null }) { - const h = (harness || "claude").toLowerCase(); - const config: Record = { - codex: { - label: "Codex", - cls: "bg-sky-500/10 text-sky-300 border border-sky-500/20", - }, - cursor: { - label: "Cursor", - cls: "bg-amber-500/10 text-amber-300 border border-amber-500/20", - }, - copilot: { - label: "Copilot", - cls: "bg-green-500/10 text-green-300 border border-green-500/20", - }, - opencode: { - label: "OpenCode", - cls: "bg-rose-500/10 text-rose-300 border border-rose-500/20", - }, - }; - const { label, cls } = config[h] || { - label: "Claude", - cls: "bg-violet-500/10 text-violet-300 border border-violet-500/20", - }; - return {label}; -} - -// CLOSEDLOOP FEA-1434: per-session billing signal. Renders ONLY for -// subscription-covered sessions (Claude Pro/Max, Codex, Cursor Pro, Copilot -// seat) — the honest quota signal asked for by PRD-414. There is no fabricated -// quota percentage: existence-only detection cannot resolve a $100-vs-$200 tier -// or remaining quota, so we surface the billing mode itself plus a tooltip -// explaining the spend is subscription-covered (not billed per token). Metered -// and unknown sessions get no badge — their real cost already shows in the cost -// cell. Classification is presentation-only (see lib/closedloop-ledger.ts). -export function BillingBadge({ billing_mode }: { billing_mode?: string | null }) { - if (!isSubscriptionMode(billing_mode)) return null; - return ( - - {subscriptionBadgeLabel(billing_mode)} - - ); -} diff --git a/apps/desktop/scripts/agent-monitor-client/lib/closedloop-ledger.ts b/apps/desktop/scripts/agent-monitor-client/lib/closedloop-ledger.ts deleted file mode 100644 index a6369ce1..00000000 --- a/apps/desktop/scripts/agent-monitor-client/lib/closedloop-ledger.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @file closedloop-ledger.ts - * @description ClosedLoop-authored client helper (FEA-1434) for the two-ledger - * cost UI. Copied to `src/lib/closedloop-ledger.ts` at build time by - * scripts/build-agent-monitor.mjs via CLIENT_FULL_FILE_OVERRIDES, then bundled - * by Vite. It centralises, in ONE place shared by every overlay: - * 1. the localStorage-backed "show hypothetical API cost for subscription - * sessions" preference (Settings writes it, the Dashboard reads it); - * 2. the `CostByLedger` shape the /api/pricing/cost and /api/analytics - * endpoints attach as `cost_by_ledger`; and - * 3. presentation-only billing-mode classification + labels for the - * per-session "subscription-covered" badge. - * - * IMPORTANT — no cost math happens here. The canonical token-cost engine - * (genai-prices) and the server's ledger split - * (apps/desktop/scripts/agent-monitor-billing/billing-mode.js → billingLedger) - * own every dollar figure. SUBSCRIPTION_BILLING_MODES below is a presentation - * mirror of the SUBSCRIPTION_MODES set in that server module, used only to - * decide whether to draw a badge and what to label it. A drift here can only - * mislabel a cosmetic badge — it can never change a headline or ledger total, - * which are computed server-side and never recomputed on the client. - */ - -// ─── "Show hypothetical API cost" preference (localStorage) ─── - -export const LEDGER_PREFS_KEY = "agent-monitor-ledger"; - -export interface LedgerPrefs { - /** - * When true the Dashboard surfaces the hypothetical API cost of - * subscription-covered usage (the "would have cost"). Off by default so the - * headline shows only really-billed spend (metered + unknown); the - * subscription bucket is a hypothetical and is never summed into the - * headline regardless of this flag. - */ - showHypotheticalCost: boolean; -} - -export const defaultLedgerPrefs: LedgerPrefs = { - showHypotheticalCost: false, -}; - -export function loadLedgerPrefs(): LedgerPrefs { - try { - const raw = localStorage.getItem(LEDGER_PREFS_KEY); - if (!raw) return { ...defaultLedgerPrefs }; - return { ...defaultLedgerPrefs, ...JSON.parse(raw) }; - } catch { - return { ...defaultLedgerPrefs }; - } -} - -export function saveLedgerPrefs(prefs: LedgerPrefs): void { - localStorage.setItem(LEDGER_PREFS_KEY, JSON.stringify(prefs)); -} - -// ─── Two-ledger totals shape ─── - -/** - * The three-bucket totals the cost + analytics endpoints attach as - * `cost_by_ledger`. Headline cost = metered + unknown; `subscription` is the - * hypothetical "would have cost" and is never summed into the headline. The - * upstream CostResult/Analytics types predate this field, so consumers read it - * through this locally-declared shape. - */ -export interface CostByLedger { - metered: number; - subscription: number; - unknown: number; -} - -// ─── Billing-mode presentation (mirror of server SSOT, badge-only) ─── - -/** - * Presentation mirror of SUBSCRIPTION_MODES in the server billing-mode engine - * (apps/desktop/scripts/agent-monitor-billing/billing-mode.js). Used ONLY to - * decide whether a session is subscription-covered for the badge — never for - * cost math. Keep in sync with the server set; a mismatch only affects a badge. - */ -export const SUBSCRIPTION_BILLING_MODES: ReadonlySet = new Set([ - "subscription_unknown", - "pro", - "max_5x", - "max_20x", - "codex_subscription", - "cursor_pro", - "copilot_seat", -]); - -/** True when a stored billing_mode represents subscription-covered usage. */ -export function isSubscriptionMode(mode: string | null | undefined): boolean { - return mode != null && SUBSCRIPTION_BILLING_MODES.has(mode); -} - -/** - * Human-friendly label for a subscription billing_mode shown on the badge. - * Existence-only detection can't resolve Anthropic tiers yet (subscription_unknown - * → "Subscription"); finer tiers (Pro / Max 5x / Max 20x) arrive once `/status` - * parsing lands (out of scope for this slice, PRD-414). Non-subscription modes - * never reach this function (the badge is drawn only for subscription sessions). - */ -export function subscriptionBadgeLabel(mode: string | null | undefined): string { - switch (mode) { - case "pro": - return "Pro"; - case "max_5x": - return "Max 5x"; - case "max_20x": - return "Max 20x"; - case "codex_subscription": - return "Codex"; - case "cursor_pro": - return "Cursor Pro"; - case "copilot_seat": - return "Copilot"; - case "subscription_unknown": - default: - return "Subscription"; - } -} diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessioncard.badge.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessioncard.badge.replace.txt deleted file mode 100644 index 666c4162..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessioncard.badge.replace.txt +++ /dev/null @@ -1,4 +0,0 @@ -
- - -
\ No newline at end of file diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.find.txt deleted file mode 100644 index cc2c1624..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.find.txt +++ /dev/null @@ -1,2 +0,0 @@ - {/* Status Filters */} -
diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.replace.txt deleted file mode 100644 index eb65c816..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.replace.txt +++ /dev/null @@ -1,19 +0,0 @@ - {/* Harness Filter (Addition #6) */} -
- {HARNESS_OPTIONS.map((opt) => ( - - ))} -
- - {/* Status Filters */} -
diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.find.txt deleted file mode 100644 index 7d934144..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.find.txt +++ /dev/null @@ -1,3 +0,0 @@ - const waiting = res.sessions.filter(isSessionAwaitingInput); - setTotal(waiting.length); - setSessions(waiting.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)); diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.legacy.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.legacy.find.txt deleted file mode 100644 index a584899c..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.legacy.find.txt +++ /dev/null @@ -1,6 +0,0 @@ - let rows = res.sessions; - if (filter === "waiting") rows = rows.filter(isSessionAwaitingInput); - if (harness) - rows = rows.filter((s) => (s.harness || "claude").toLowerCase() === harness); - setTotal(rows.length); - setSessions(rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)); diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.replace.txt deleted file mode 100644 index ce1ea103..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.replace.txt +++ /dev/null @@ -1,4 +0,0 @@ - let rows = res.sessions; - rows = rows.filter(isSessionAwaitingInput); - setTotal(rows.length); - setSessions(rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)); diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.find.txt deleted file mode 100644 index 1a9f8f60..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.find.txt +++ /dev/null @@ -1,7 +0,0 @@ - // The "waiting" filter is a UI-only overlay derived from the - // awaiting_input_since column — the underlying SessionStatus is - // still "active". Map it to a client-side filter on top of the - // active set so paging/totals stay consistent with the visible rows. - if (filter === "waiting") { - const res = await api.sessions.list({ - status: "active", diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.legacy.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.legacy.find.txt deleted file mode 100644 index aa8b6c82..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.legacy.find.txt +++ /dev/null @@ -1,9 +0,0 @@ - // Two UI-only overlays need client-side filtering on a broad fetch so - // paging/totals stay consistent with the visible rows: - // - "waiting" derived from awaiting_input_since (status is "active"). - // - harness derived from the harness column (Addition #6); the - // vendored /api/sessions route is unpatched so we filter here. - // Legacy/empty harness counts as "claude" (matches the DB default). - if (filter === "waiting" || harness) { - const res = await api.sessions.list({ - status: filter === "waiting" ? "active" : filter || undefined, diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.replace.txt deleted file mode 100644 index d5a3a36b..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.replace.txt +++ /dev/null @@ -1,15 +0,0 @@ - // The "waiting" filter is a UI-only overlay derived from the - // awaiting_input_since column — the underlying SessionStatus is - // still "active". Map it to a client-side filter on top of the - // server-side status + harness filters so paging/totals stay - // consistent with the visible rows. - if (filter === "waiting") { - const res = await api.sessions.list({ - status: "active", - q: search || undefined, - cwd: cwd || undefined, - harness: harness || undefined, - sort_by: sortBy, - sort_desc: sortDesc, - limit: 10000, - offset: 0, diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.find.txt deleted file mode 100644 index ebbf1a8e..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.find.txt +++ /dev/null @@ -1,2 +0,0 @@ -

- {dashboardRunIds.has(session.id) && ( diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.replace.txt deleted file mode 100644 index c55bcc37..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.replace.txt +++ /dev/null @@ -1,3 +0,0 @@ -

- - {dashboardRunIds.has(session.id) && ( diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.state.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.state.replace.txt deleted file mode 100644 index a2c813a4..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.state.replace.txt +++ /dev/null @@ -1,14 +0,0 @@ - const [dashboardRunIds, setDashboardRunIds] = useState>(new Set()); - // CLOSEDLOOP multi-harness support: filter by agent harness. "" = all. - // Applied client-side (the vendored /api/sessions route is unpatched); - // legacy/empty harness values count as "claude" (DB column default). - const [harness, setHarness] = useState(""); - - const HARNESS_OPTIONS: Array<{ label: string; value: string }> = [ - { label: "All Harnesses", value: "" }, - { label: "Claude", value: "claude" }, - { label: "Codex", value: "codex" }, - { label: "Cursor", value: "cursor" }, - { label: "Copilot", value: "copilot" }, - { label: "OpenCode", value: "opencode" }, - ]; \ No newline at end of file diff --git a/apps/desktop/scripts/agent-monitor-codex/client/statusbadge.append.tsx b/apps/desktop/scripts/agent-monitor-codex/client/statusbadge.append.tsx deleted file mode 100644 index 821acb0f..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/statusbadge.append.tsx +++ /dev/null @@ -1,17 +0,0 @@ - - -/** - * CLOSEDLOOP multi-harness support: which agent harness produced a session. - * Legacy/empty values render as "Claude" to match the DB default. - */ -export function HarnessBadge({ harness }: { harness?: string | null }) { - const h = (harness || "claude").toLowerCase(); - const config: Record = { - codex: { label: "Codex", cls: "bg-sky-500/10 text-sky-300 border border-sky-500/20" }, - cursor: { label: "Cursor", cls: "bg-amber-500/10 text-amber-300 border border-amber-500/20" }, - copilot: { label: "Copilot", cls: "bg-green-500/10 text-green-300 border border-green-500/20" }, - opencode: { label: "OpenCode", cls: "bg-rose-500/10 text-rose-300 border border-rose-500/20" }, - }; - const { label, cls } = config[h] || { label: "Claude", cls: "bg-violet-500/10 text-violet-300 border border-violet-500/20" }; - return {label}; -} diff --git a/apps/desktop/scripts/agent-monitor-codex/codex-home.js b/apps/desktop/scripts/agent-monitor-codex/codex-home.js deleted file mode 100644 index 8dda6249..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/codex-home.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @file codex-home.js - * @description Centralized OpenAI Codex CLI home directory path management — - * the Codex analogue of claude-home.js. Resolves the sessions root, the - * rollout JSONL files (Codex writes one append-only `rollout-*.jsonl` per - * session under `sessions/YYYY/MM/DD/`), the aggregated history file, and the - * archived-sessions directory. Supports a custom root via the CODEX_HOME - * environment variable so non-default Codex installs are still discovered. - * - * Part of CLOSEDLOOP VENDOR Addition #6 (see vendor/agent-monitor/VENDOR.md). - */ -const path = require("path"); -const os = require("os"); -const fs = require("fs"); - -function getCodexHome() { - // Codex accepts a comma-separated CODEX_HOME in some setups; the first entry - // is the active root. Fall back to ~/.codex. - const raw = process.env.CODEX_HOME; - if (raw && raw.trim()) { - const first = raw.split(",")[0].trim(); - if (first) return first.replace(/^~(?=\/)/, os.homedir()); - } - return path.join(os.homedir(), ".codex"); -} - -function getCodexSessionsDir() { - return path.join(getCodexHome(), "sessions"); -} - -function getCodexArchivedDir() { - return path.join(getCodexHome(), "archived_sessions"); -} - -function getCodexHistoryPath() { - return path.join(getCodexHome(), "history.jsonl"); -} - -/** - * Derive a stable session id from a rollout file path. Codex names rollout - * files `rollout--.jsonl`; we want the uuid. If the name - * doesn't match, fall back to the basename sans extension so every file still - * maps to a deterministic id. - */ -function sessionIdFromRolloutPath(filePath) { - const base = path.basename(filePath, ".jsonl"); - const uuid = base.match( - /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i - ); - if (uuid) return uuid[0]; - return base.replace(/^rollout-/, ""); -} - -/** - * Recursively collect every `*.jsonl` rollout file under a root directory. - * Codex nests by date (`sessions/YYYY/MM/DD/`), but we walk generically so a - * flat layout or `archived_sessions/` also works. Depth-bounded and - * error-tolerant — a Codex dir is the user's own local data and a permission - * or IO error on one branch must not abort discovery. - */ -function collectRolloutFiles(root, { maxDepth = 8 } = {}) { - const out = []; - if (!root || !fs.existsSync(root)) return out; - const walk = (dir, depth) => { - if (depth > maxDepth) return; - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) { - walk(full, depth + 1); - } else if (e.isFile() && e.name.endsWith(".jsonl")) { - out.push(full); - } - } - }; - walk(root, 0); - return out; -} - -/** - * All Codex rollout files (active sessions + archived). - */ -function listAllRolloutFiles() { - return [ - ...collectRolloutFiles(getCodexSessionsDir()), - ...collectRolloutFiles(getCodexArchivedDir()), - ]; -} - -module.exports = { - getCodexHome, - getCodexSessionsDir, - getCodexArchivedDir, - getCodexHistoryPath, - sessionIdFromRolloutPath, - collectRolloutFiles, - listAllRolloutFiles, -}; diff --git a/apps/desktop/scripts/agent-monitor-codex/codex-import.js b/apps/desktop/scripts/agent-monitor-codex/codex-import.js deleted file mode 100644 index a5390421..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/codex-import.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * @file codex-import.js - * @description Bootstrap importer for OpenAI Codex CLI sessions — the Codex - * analogue of scripts/import-history.js `importAllSessions`. It parses each - * Codex rollout JSONL into the shared normalized session shape and then reuses - * the existing, battle-tested `importSession()` so Codex sessions land in the - * same sessions/agents/events/token_usage rows and render through the - * unchanged dashboard UI. The only Codex-specific step is stamping - * `harness='codex'` on the row afterwards (the shared insert defaults to - * 'claude'); `setSessionHarness` is idempotent. - * - * Part of CLOSEDLOOP VENDOR Addition #6 (see vendor/agent-monitor/VENDOR.md). - */ -const { parseRolloutFile } = require("./codex-parser"); -const { listAllRolloutFiles } = require("./codex-home"); -const { importSession } = require("../../scripts/import-history"); -const { reactivateImportedSession } = require("../agent-monitor-shared/import-session-utils"); -const { createCatchupCache } = require("../agent-monitor-shared/catchup-cache"); -const { ingestCachePath } = require("../agent-monitor-shared/ingest-paths"); -const { stampSessionBillingMode } = require("../agent-monitor-shared/billing-stamp"); - -// Cache of (path, mtime, size) for rollout files already parsed and imported. -// The catchup poll runs every 5 s and would otherwise re-parse every file on -// every tick (FEA-1316); the persisted backing file additionally lets a fresh -// process skip unchanged files on the cold-start boot import (FEA-1334). -const catchupCache = createCatchupCache({ persistPath: ingestCachePath("codex") }); - -/** - * Import (or idempotently backfill) a single Codex rollout file. - * Returns { sessionId, result } where result is importSession's return value, - * or { skipped: true } when the file has no usable content. - */ -function importCodexSession(dbModule, session) { - const result = importSession(dbModule, session); - // Stamp the harness regardless of skipped/backfilled — cheap, idempotent, - // and self-heals rows imported before the `harness` column existed. - try { - dbModule.stmts.setSessionHarness.run("codex", session.sessionId, "codex"); - } catch { - /* non-fatal — column/stmt guaranteed by db.js Patch #4 */ - } - // FEA-1434: stamp the billing mode (idempotent + best-effort internally). - stampSessionBillingMode(dbModule.stmts, "codex", session.sessionId); - const reactivated = reactivateImportedSession(dbModule, session); - return { sessionId: session.sessionId, result, reactivated }; -} - -/** - * Parse + import every discovered Codex rollout file. Designed to be cheap on - * repeat runs: importSession skips already-imported sessions (or backfills - * only genuinely-new events via its per-event-type high-water-mark). - * - * @param {any} dbModule - * @param {{ signal?: AbortSignal, onBegin?: (total: number) => void, - * onProgress?: () => void }} [opts] - ingest-orchestrator progress - * hooks (FEA-1334). The watcher catchup tick calls this with no opts. - * Returns { imported, skipped, errors }. - */ -async function importAllCodexSessions(dbModule, opts = {}) { - const onBegin = typeof opts.onBegin === "function" ? opts.onBegin : null; - const onProgress = typeof opts.onProgress === "function" ? opts.onProgress : null; - const signal = opts.signal || null; - const files = listAllRolloutFiles(); - if (onBegin) onBegin(files.length); - let imported = 0; - let skipped = 0; - let errors = 0; - - const importBatch = dbModule.db.transaction((sessions) => { - for (const session of sessions) { - const { result, reactivated } = importCodexSession(dbModule, session); - if (result && result.skipped && !reactivated) skipped++; - else imported++; - } - }); - - // Parse outside the transaction (async IO); apply inside one (sync, fast). - // Skip files whose (mtime, size) is unchanged since the last successful - // parse — that is the common case for the 5 s catchup poll. Without this - // gate every tick re-parses every historical rollout file (FEA-1316). - const batch = []; - const parsedEntries = []; - for (const filePath of files) { - if (signal && signal.aborted) break; - if (onProgress) onProgress(); - const { unchanged, stat } = catchupCache.isUnchanged(filePath); - if (unchanged) { - skipped++; - continue; - } - try { - const session = await parseRolloutFile(filePath); - if (!session) { - // Cache even null-parsed files so we don't re-read them every tick. - catchupCache.markSeenWith(filePath, stat); - skipped++; - continue; - } - batch.push(session); - parsedEntries.push({ path: filePath, stat }); - } catch { - errors++; - } - } - if (batch.length > 0) importBatch(batch); - for (const { path, stat } of parsedEntries) catchupCache.markSeenWith(path, stat); - catchupCache.pruneTo(files); - catchupCache.flush(); - - return { imported, skipped, errors }; -} - -module.exports = { importAllCodexSessions, importCodexSession }; diff --git a/apps/desktop/scripts/agent-monitor-codex/codex-parser.js b/apps/desktop/scripts/agent-monitor-codex/codex-parser.js deleted file mode 100644 index 30c01737..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/codex-parser.js +++ /dev/null @@ -1,368 +0,0 @@ -/** - * @file codex-parser.js - * @description Parse an OpenAI Codex CLI rollout JSONL file into the SAME - * normalized session object that scripts/import-history.js `parseSessionFile` - * produces for Claude Code. Emitting an identical shape lets the Codex import - * path reuse the existing, battle-tested `importSession()` so Codex sessions - * render through the unchanged dashboard UI exactly like Claude sessions. - * - * Codex's rollout format has drifted across releases, so parsing is - * intentionally tolerant: it accepts the modern RolloutLine envelope - * (`{type:"session_meta"|"event_msg"|"response_item", payload, timestamp}`), - * older bare records (the item itself on the line), and auto-detects a typed - * `payload` under an unknown wrapper. Token usage in Codex `token_count` - * events is CUMULATIVE per session, so the final value is the session total - * (no delta math needed). Model attribution follows CodexBar's documented - * rule: `turn_context.model` is authoritative. - * - * Reference for the Codex format & token/model semantics: steipete/CodexBar - * `docs/codex.md` (MIT) — see THIRD_PARTY_NOTICES.md. - * - * Part of CLOSEDLOOP VENDOR Addition #6 (see vendor/agent-monitor/VENDOR.md). - */ -const fs = require("fs"); -const path = require("path"); -const readline = require("readline"); -const { sessionIdFromRolloutPath } = require("./codex-home"); -const { pushTurnDuration, toIso, safeJson } = require("../agent-monitor-shared/parser-utils"); - -const RESPONSE_ITEM_TYPES = new Set([ - "message", - "reasoning", - "function_call", - "function_call_output", - "local_shell_call", - "local_shell_call_output", - "custom_tool_call", - "custom_tool_call_output", -]); - -// CLOSEDLOOP plan-extraction (FEA-1189): Codex emits implementation plans as a -// structured `item_completed` event whose item.type === "Plan", and (fallback) -// as a block inside an assistant message. We surface both into -// session.plans[]; plan-extractor/plan-store handle normalization + versioning. -const PROPOSED_PLAN_RE = /([\s\S]*?)<\/proposed_plan>/i; -/** - * Classify a parsed JSONL record into a coarse kind plus its inner payload. - */ -function classify(rec) { - if (!rec || typeof rec !== "object") return null; - const ts = rec.timestamp || rec.ts || (rec.payload && rec.payload.timestamp) || null; - const t = rec.type; - - if (t === "session_meta" || t === "session.created") - return { kind: "session_meta", p: rec.payload || rec, ts }; - if (t === "turn_context" || t === "turn.context") - return { kind: "turn_context", p: rec.payload || rec, ts }; - if (t === "event_msg" || t === "event") - return { kind: "event", p: rec.payload || rec, ts }; - if (t === "response_item" || t === "response.item") - return { kind: "response_item", p: rec.payload || rec, ts }; - - // Unknown wrapper but a typed payload — auto-detect from payload.type. - if (rec.payload && typeof rec.payload === "object" && rec.payload.type) { - return { kind: "auto", p: rec.payload, ts }; - } - // Bare Responses-API item on the line. - if (t && RESPONSE_ITEM_TYPES.has(t)) return { kind: "response_item", p: rec, ts }; - // Bare session meta (no `type`, but session-ish fields). - if (!t && (rec.cwd || rec.instructions || rec.git || rec.session_id || rec.id)) { - return { kind: "session_meta", p: rec, ts }; - } - // Bare event-like record. - if (t) return { kind: "event", p: rec, ts }; - return { kind: "other", p: rec.payload || rec, ts }; -} - -function extractText(content) { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - const parts = []; - for (const b of content) { - if (!b) continue; - if (typeof b === "string") parts.push(b); - else if (typeof b.text === "string") parts.push(b.text); - else if (b.type === "input_text" || b.type === "output_text" || b.type === "text") { - if (typeof b.text === "string") parts.push(b.text); - } - } - return parts.join(""); -} - -/** - * Parse a single Codex rollout JSONL file into the normalized session object. - * Returns null when the file carries no usable timestamp (mirrors - * parseSessionFile's contract so importSession can treat both identically). - */ -async function parseRolloutFile(filePath) { - const sessionId = sessionIdFromRolloutPath(filePath); - - const rl = readline.createInterface({ - input: fs.createReadStream(filePath, { encoding: "utf8" }), - crlfDelay: Infinity, - }); - - let cwd = null; - let model = null; - let version = null; - let gitBranch = null; - let firstTimestamp = null; - let lastTimestamp = null; - let userMessageCount = 0; - let assistantMessageCount = 0; - const messageTimestamps = []; - const toolUses = []; - const turnDurations = []; - const plans = []; // CLOSEDLOOP plan-extraction (FEA-1189) - const apiErrors = []; - let thinkingBlockCount = 0; - const toolResultErrors = []; - let latestTotals = null; // cumulative token_count totals (last wins) - let sawResponseItems = false; - let lastTs = null; - let pendingTurnStartedAt = null; - - const noteTs = (raw) => { - const iso = toIso(raw); - if (!iso) return null; - if (!firstTimestamp || iso < firstTimestamp) firstTimestamp = iso; - if (!lastTimestamp || iso > lastTimestamp) lastTimestamp = iso; - lastTs = iso; - return iso; - }; - - const handleResponseItem = (p, iso, explicitIso) => { - sawResponseItems = true; - const itype = p.type; - if (itype === "message") { - const role = p.role || p.author || "assistant"; - const text = extractText(p.content); - if (role === "user") { - userMessageCount++; - if (explicitIso) pendingTurnStartedAt = explicitIso; - } else { - assistantMessageCount++; - if (iso) messageTimestamps.push(iso); - pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); - pendingTurnStartedAt = null; - // Fallback plan signal: block in an assistant message - // (medium confidence — flagged for user confirmation downstream). - const pm = PROPOSED_PLAN_RE.exec(text); - if (pm && pm[1] && pm[1].trim()) { - plans.push({ - source: "codex-proposed-plan", - content: pm[1].trim(), - timestamp: iso || firstTimestamp, - }); - } - } - void text; - } else if (itype === "reasoning") { - thinkingBlockCount++; - } else if (itype === "function_call" || itype === "custom_tool_call") { - toolUses.push({ - name: p.name || p.tool_name || "function", - timestamp: iso || firstTimestamp, - input: safeJson(p.arguments != null ? p.arguments : p.input), - }); - } else if (itype === "local_shell_call") { - const action = p.action || {}; - toolUses.push({ - name: "shell", - timestamp: iso || firstTimestamp, - input: action.command || action || p.input || null, - }); - } else if ( - itype === "function_call_output" || - itype === "custom_tool_call_output" || - itype === "local_shell_call_output" - ) { - const out = p.output || p.result || {}; - const isErr = - out && typeof out === "object" - ? out.success === false || out.is_error === true || !!out.error - : false; - if (isErr) { - const content = - typeof out === "string" - ? out.slice(0, 500) - : JSON.stringify(out).slice(0, 500); - toolResultErrors.push({ content, timestamp: iso }); - } - } - }; - - const handleEvent = (p, iso, explicitIso) => { - const et = p.type; - if (!et) return; - // CLOSEDLOOP plan-extraction (FEA-1189): the strongest Codex plan signal — - // a structured item_completed event carrying item.type === "Plan". - if ( - et === "item_completed" && - p.item && - p.item.type === "Plan" && - typeof p.item.text === "string" && - p.item.text.trim() - ) { - plans.push({ - source: "codex-plan-item", - content: p.item.text, - timestamp: iso || firstTimestamp, - }); - return; - } - if (et === "user_message") { - userMessageCount++; - if (explicitIso) pendingTurnStartedAt = explicitIso; - } else if (et === "agent_message" || et === "agent_message_delta") { - if (et === "agent_message") { - assistantMessageCount++; - if (iso) messageTimestamps.push(iso); - pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); - pendingTurnStartedAt = null; - } - } else if (et === "agent_reasoning" || et === "agent_reasoning_section_break") { - if (et === "agent_reasoning") thinkingBlockCount++; - } else if (et === "token_count") { - const info = p.info || p.token_count_info || p; - const totals = - info.total_token_usage || info.totalTokenUsage || info.total || null; - if (totals && typeof totals === "object") latestTotals = totals; - const m = - (p.turn_context && p.turn_context.model) || info.model || p.model; - if (m) model = m; - } else if (et === "error" || et === "stream_error") { - apiErrors.push({ - type: et, - message: - (typeof p.message === "string" && p.message) || - p.error || - "Codex error", - timestamp: iso, - }); - } else if ( - !sawResponseItems && - (et === "exec_command_begin" || - et === "patch_apply_begin" || - et === "mcp_tool_call_begin") - ) { - // Fallback only for older event-only logs with no response_item items. - const name = - et === "exec_command_begin" - ? "shell" - : et === "patch_apply_begin" - ? "apply_patch" - : p.tool || p.server || "mcp_tool"; - toolUses.push({ - name, - timestamp: iso || firstTimestamp, - input: p.command || p.changes || p.arguments || null, - }); - } - }; - - for await (const line of rl) { - if (!line.trim()) continue; - let rec; - try { - rec = JSON.parse(line); - } catch { - continue; - } - const c = classify(rec); - if (!c) continue; - const explicitIso = noteTs(c.ts); - const iso = explicitIso || lastTs; - - if (c.kind === "session_meta") { - const p = c.p || {}; - if (!cwd && (p.cwd || p.workdir)) cwd = p.cwd || p.workdir; - if (!version && (p.cli_version || p.version)) version = p.cli_version || p.version; - if (!gitBranch) { - if (typeof p.git === "object" && p.git) gitBranch = p.git.branch || p.git.ref || null; - else if (typeof p.git_branch === "string") gitBranch = p.git_branch; - } - if (!model && p.model) model = p.model; - } else if (c.kind === "turn_context") { - const p = c.p || {}; - if (p.model) model = p.model; // authoritative - if (!cwd && p.cwd) cwd = p.cwd; - } else if (c.kind === "response_item") { - handleResponseItem(c.p || {}, iso, explicitIso); - } else if (c.kind === "event") { - handleEvent(c.p || {}, iso, explicitIso); - } else if (c.kind === "auto") { - const p = c.p || {}; - if (RESPONSE_ITEM_TYPES.has(p.type)) handleResponseItem(p, iso, explicitIso); - else handleEvent(p, iso, explicitIso); - } - } - - if (!firstTimestamp) return null; - - const tokensByModel = {}; - if (latestTotals) { - const key = model || "gpt-codex"; - const input = latestTotals.input_tokens || latestTotals.inputTokens || 0; - const cached = - latestTotals.cached_input_tokens || latestTotals.cachedInputTokens || 0; - const output = latestTotals.output_tokens || latestTotals.outputTokens || 0; - const reasoning = - latestTotals.reasoning_output_tokens || - latestTotals.reasoningOutputTokens || - 0; - const cacheWrite = - latestTotals.cache_write_tokens || - latestTotals.cacheWriteTokens || - latestTotals.cache_creation_input_tokens || - latestTotals.cacheCreationInputTokens || - 0; - if (input || output || cached || reasoning || cacheWrite) { - tokensByModel[key] = { - input, - output: output + reasoning, - cacheRead: cached, - cacheWrite, - }; - } - } - - let fileModifiedAt = null; - try { - fileModifiedAt = fs.statSync(filePath).mtimeMs; - } catch { - /* non-fatal */ - } - - const projectName = cwd ? path.basename(cwd) : `Codex Session ${sessionId.slice(0, 8)}`; - - return { - sessionId, - name: projectName, - cwd, - model, - version, - slug: null, - gitBranch, - startedAt: firstTimestamp, - endedAt: lastTimestamp, - teams: [], - userMessages: userMessageCount, - assistantMessages: assistantMessageCount, - tokensByModel, - messageTimestamps, - toolUses, - plans, - compactions: [], - apiErrors, - fileModifiedAt, - turnDurations, - entrypoint: "codex", - permissionMode: null, - thinkingBlockCount, - toolResultErrors, - usageExtras: { service_tiers: [], speeds: [], inference_geos: [] }, - }; -} - -module.exports = { parseRolloutFile, classify }; diff --git a/apps/desktop/scripts/agent-monitor-codex/codex-watcher.js b/apps/desktop/scripts/agent-monitor-codex/codex-watcher.js deleted file mode 100644 index 6e5e104a..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/codex-watcher.js +++ /dev/null @@ -1,192 +0,0 @@ -/** - * @file codex-watcher.js - * @description Live file watcher for OpenAI Codex CLI sessions. Codex has NO - * hook system (unlike Claude Code, whose live data arrives via hooks), so the - * ONLY way to keep the dashboard current for Codex is to watch the rollout - * JSONL files Codex appends to under `~/.codex/sessions/YYYY/MM/DD/`. - * - * On a debounced change it re-parses the affected rollout file and runs the - * shared idempotent importer (importSession backfills only genuinely-new - * events via its per-event-type high-water-mark), then broadcasts the updated - * session/agent rows over the existing dashboard WebSocket so the unchanged - * client live-updates exactly like it does for Claude hook events. - * - * Best-effort and non-fatal, mirroring cc-watcher.js: `fs.watch` is - * platform-quirky; a failure here must never crash the sidecar. A full - * startup catch-up still happens via codex-import.js. - * - * Part of CLOSEDLOOP VENDOR Addition #6 (see vendor/agent-monitor/VENDOR.md). - */ -const fs = require("fs"); -const path = require("path"); -const { getCodexSessionsDir } = require("./codex-home"); -const { parseRolloutFile } = require("./codex-parser"); -const { broadcastHarnessRows } = require("../agent-monitor-shared/harness-watcher-utils"); - -const DEBOUNCE_MS = 600; -const RETRY_MS = 4000; -const MAX_RETRY_ATTEMPTS = 75; // ~5 minutes at 4s intervals, then give up -const CATCHUP_POLL_MS = 5000; - -let started = false; -let timer = null; -let retryTimer = null; -let catchupTimer = null; -let pending = new Set(); -const watchers = []; - -function processPending(broadcast) { - const files = Array.from(pending); - pending = new Set(); - if (files.length === 0) return; - - // Lazy-require to avoid load-order coupling with db/import-history. - let dbModule; - let importCodexSession; - try { - dbModule = require("../db"); - ({ importCodexSession } = require("./codex-import")); - } catch { - return; - } - - (async () => { - for (const filePath of files) { - let session; - try { - session = await parseRolloutFile(filePath); - } catch { - continue; - } - if (!session) continue; - try { - const before = dbModule.stmts.getSession.get(session.sessionId); - const apply = dbModule.db.transaction(() => { - importCodexSession(dbModule, session); - }); - apply(); - const row = dbModule.stmts.getSession.get(session.sessionId); - if (row) broadcast(before ? "session_updated" : "session_created", row); - const agent = dbModule.stmts.getAgent.get(`${session.sessionId}-main`); - if (agent) broadcast("agent_updated", agent); - } catch { - /* non-fatal — a partially-written rollout line is normal mid-turn */ - } - } - })(); -} - -function scheduleProcess(broadcast, filePath) { - if (filePath) pending.add(filePath); - if (timer) return; - timer = setTimeout(() => { - timer = null; - try { - processPending(broadcast); - } catch { - /* ignore */ - } - }, DEBOUNCE_MS); -} - -function safeWatchSessions({ root, broadcast }) { - try { - if (!fs.existsSync(root)) return false; - const w = fs.watch(root, { recursive: true }, (_event, filename) => { - if (!filename) return; - if (!String(filename).endsWith(".jsonl")) return; - const full = path.join(root, filename); - scheduleProcess(broadcast, full); - }); - w.on("error", () => {}); - watchers.push(w); - return true; - } catch { - /* platform limitation — startup import still covers historical sessions */ - return false; - } -} - -// One-time catch-up after the sessions dir appears for the first time AFTER -// the sidecar booted (e.g. the user ran their first-ever Codex session while -// the dashboard was already open). server/index.js's startup import ran when -// the dir didn't exist yet and fs.watch only fires for events AFTER it -// attaches, so without this the session stays invisible until an app -// restart. Re-imports are idempotent; broadcasts make an open UI refresh. -function runCatchupImport(broadcast) { - let dbModule; - let importAllCodexSessions; - try { - dbModule = require("../db"); - ({ importAllCodexSessions } = require("./codex-import")); - } catch { - return; - } - Promise.resolve() - .then(() => importAllCodexSessions(dbModule)) - .then(({ imported }) => { - if (imported > 0) { - broadcastHarnessRows(dbModule, broadcast, "codex"); - } - }) - .catch(() => {}); -} - -/** - * Start watching Codex rollout files. Idempotent: subsequent calls are no-ops. - * Resilient to a not-yet-existent ~/.codex/sessions: if the dir is missing at - * boot we poll (cheap fs.existsSync) until it appears, then run a one-time - * catch-up import and attach the recursive watcher. - */ -function startCodexWatcher({ broadcast }) { - if (started) return; - started = true; - catchupTimer = setInterval(() => runCatchupImport(broadcast), CATCHUP_POLL_MS); - catchupTimer.unref?.(); - runCatchupImport(broadcast); - const root = getCodexSessionsDir(); - if (safeWatchSessions({ root, broadcast })) return; // dir existed → attached - if (retryTimer) { clearInterval(retryTimer); retryTimer = null; } - let retryCount = 0; - retryTimer = setInterval(() => { - if (++retryCount > MAX_RETRY_ATTEMPTS) { - clearInterval(retryTimer); - retryTimer = null; - return; - } - if (!fs.existsSync(root)) return; - if (safeWatchSessions({ root, broadcast })) { - clearInterval(retryTimer); - retryTimer = null; - runCatchupImport(broadcast); - } - }, RETRY_MS); - retryTimer.unref?.(); -} - -function stopCodexWatcher() { - if (timer) { - clearTimeout(timer); - timer = null; - } - if (retryTimer) { - clearInterval(retryTimer); - retryTimer = null; - } - if (catchupTimer) { - clearInterval(catchupTimer); - catchupTimer = null; - } - for (const w of watchers) { - try { - w.close(); - } catch { - /* ignore */ - } - } - watchers.length = 0; - pending = new Set(); - started = false; -} - -module.exports = { startCodexWatcher, stopCodexWatcher }; diff --git a/apps/desktop/scripts/agent-monitor-copilot/copilot-home.js b/apps/desktop/scripts/agent-monitor-copilot/copilot-home.js deleted file mode 100644 index af18aded..00000000 --- a/apps/desktop/scripts/agent-monitor-copilot/copilot-home.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * @file copilot-home.js - * @description Centralized GitHub Copilot session path management. Resolves - * paths for: - * - * 1. Copilot Chat (VS Code extension): JSON session files under - * ~/Library/Application Support/Code/User/workspaceStorage//chatSessions/ - * - * 2. Copilot CLI (`gh copilot`): JSONL event logs under - * ~/.copilot/session-state//events.jsonl - * - * Both locations are scanned opportunistically — if neither exists the tool - * is simply not installed or hasn't been used. - */ -const path = require("path"); -const os = require("os"); -const fs = require("fs"); -const { fileURLToPath } = require("url"); - -function getCopilotCliHome() { - const raw = process.env.COPILOT_HOME; - if (raw && raw.trim()) { - return raw.trim().replace(/^~(?=\/)/, os.homedir()); - } - return path.join(os.homedir(), ".copilot"); -} - -function getCopilotCliSessionStateDir() { - return path.join(getCopilotCliHome(), "session-state"); -} - -/** - * VS Code workspace storage root. Platform-dependent. - */ -function getVscodeWorkspaceStorageDir() { - const home = os.homedir(); - switch (process.platform) { - case "darwin": - return path.join(home, "Library", "Application Support", "Code", "User", "workspaceStorage"); - case "win32": - return path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), - "Code", "User", "workspaceStorage"); - default: // linux - return path.join(home, ".config", "Code", "User", "workspaceStorage"); - } -} - -function workspacePathFromUri(folder) { - if (typeof folder !== "string" || folder.length === 0) return null; - if (!folder.startsWith("file:")) return folder; - try { - return fileURLToPath(folder); - } catch { - try { - return decodeURIComponent(folder.replace(/^file:\/\//, "")); - } catch { - return folder.replace(/^file:\/\//, ""); - } - } -} - -function readWorkspacePathFromHashDir(hashPath) { - try { - const wsJson = JSON.parse(fs.readFileSync(path.join(hashPath, "workspace.json"), "utf8")); - return workspacePathFromUri(wsJson.folder || wsJson.workspace || ""); - } catch { - return null; - } -} - -/** - * Discover all chatSession JSON files across all VS Code workspaces. - * Returns array of { filePath, workspacePath } where workspacePath is resolved - * from the workspace.json in the hash directory. - */ -function listChatSessionFiles() { - const wsRoot = getVscodeWorkspaceStorageDir(); - if (!fs.existsSync(wsRoot)) return []; - const results = []; - - let hashDirs; - try { hashDirs = fs.readdirSync(wsRoot, { withFileTypes: true }); } catch { return []; } - - for (const hashDir of hashDirs) { - if (!hashDir.isDirectory()) continue; - const hashPath = path.join(wsRoot, hashDir.name); - const chatDir = path.join(hashPath, "chatSessions"); - if (!fs.existsSync(chatDir)) continue; - - const workspacePath = readWorkspacePathFromHashDir(hashPath); - - let files; - try { files = fs.readdirSync(chatDir, { withFileTypes: true }); } catch { continue; } - for (const f of files) { - if (f.isFile() && f.name.endsWith(".json")) { - results.push({ - filePath: path.join(chatDir, f.name), - workspacePath, - }); - } - } - } - return results; -} - -/** - * Collect all Copilot CLI event JSONL files under ~/.copilot/session-state/. - */ -function listCliEventFiles() { - const root = getCopilotCliSessionStateDir(); - if (!fs.existsSync(root)) return []; - const results = []; - - let sessionDirs; - try { sessionDirs = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; } - - for (const dir of sessionDirs) { - if (!dir.isDirectory()) continue; - const eventsFile = path.join(root, dir.name, "events.jsonl"); - if (fs.existsSync(eventsFile)) { - results.push({ filePath: eventsFile, sessionId: dir.name }); - } - } - return results; -} - -module.exports = { - getCopilotCliHome, - getCopilotCliSessionStateDir, - getVscodeWorkspaceStorageDir, - workspacePathFromUri, - readWorkspacePathFromHashDir, - listChatSessionFiles, - listCliEventFiles, -}; diff --git a/apps/desktop/scripts/agent-monitor-copilot/copilot-import.js b/apps/desktop/scripts/agent-monitor-copilot/copilot-import.js deleted file mode 100644 index 14671df0..00000000 --- a/apps/desktop/scripts/agent-monitor-copilot/copilot-import.js +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @file copilot-import.js - * @description Bootstrap importer for GitHub Copilot sessions. Parses both - * Copilot Chat (VS Code extension JSON) and Copilot CLI (JSONL event logs) - * into the shared normalized session shape, reusing importSession(). - */ -const { parseChatSessionFile, parseCliEventFile } = require("./copilot-parser"); -const { listChatSessionFiles, listCliEventFiles } = require("./copilot-home"); -const { importSession } = require("../../scripts/import-history"); -const { reactivateImportedSession } = require("../agent-monitor-shared/import-session-utils"); -const { createCatchupCache } = require("../agent-monitor-shared/catchup-cache"); -const { ingestCachePath } = require("../agent-monitor-shared/ingest-paths"); -const { stampSessionBillingMode } = require("../agent-monitor-shared/billing-stamp"); - -// Skip chat/CLI files unchanged since the last tick (FEA-1316); the persisted -// backing files additionally let a fresh process skip unchanged files on the -// cold-start boot import (FEA-1334). -const chatCache = createCatchupCache({ persistPath: ingestCachePath("copilot-chat") }); -const cliCache = createCatchupCache({ persistPath: ingestCachePath("copilot-cli") }); - -function importCopilotSession(dbModule, session) { - const result = importSession(dbModule, session); - try { - dbModule.stmts.setSessionHarness.run("copilot", session.sessionId, "copilot"); - } catch { /* non-fatal */ } - // FEA-1434: stamp the billing mode (idempotent + best-effort internally). - stampSessionBillingMode(dbModule.stmts, "copilot", session.sessionId); - const reactivated = reactivateImportedSession(dbModule, session); - return { sessionId: session.sessionId, result, reactivated }; -} - -/** - * Parse + import every discovered Copilot Chat + CLI session. Idempotent. - * - * @param {any} dbModule - * @param {{ signal?: AbortSignal, onBegin?: (total: number) => void, - * onProgress?: () => void }} [opts] - ingest-orchestrator progress - * hooks (FEA-1334). The watcher catchup tick calls this with no opts. - */ -async function importAllCopilotSessions(dbModule, opts = {}) { - const onBegin = typeof opts.onBegin === "function" ? opts.onBegin : null; - const onProgress = typeof opts.onProgress === "function" ? opts.onProgress : null; - const signal = opts.signal || null; - let imported = 0; - let skipped = 0; - let errors = 0; - - const importBatch = dbModule.db.transaction((sessions) => { - for (const session of sessions) { - const { result, reactivated } = importCopilotSession(dbModule, session); - if (result && result.skipped && !reactivated) skipped++; - else imported++; - } - }); - - const batch = []; - const chatParsed = []; - const cliParsed = []; - - // Discover both source sets up front so the orchestrator gets one honest - // total covering Chat (JSON) + CLI (JSONL) before parsing begins. - const chatFiles = listChatSessionFiles(); - const cliFiles = listCliEventFiles(); - if (onBegin) onBegin(chatFiles.length + cliFiles.length); - - // Copilot Chat (VS Code extension) — JSON files - for (const { filePath, workspacePath } of chatFiles) { - if (signal && signal.aborted) break; - if (onProgress) onProgress(); - const { unchanged, stat } = chatCache.isUnchanged(filePath); - if (unchanged) { - skipped++; - continue; - } - try { - const session = parseChatSessionFile(filePath, workspacePath); - if (!session) { chatCache.markSeenWith(filePath, stat); skipped++; continue; } - batch.push(session); - chatParsed.push({ path: filePath, stat }); - } catch { errors++; } - } - - // Copilot CLI — JSONL event files - for (const { filePath, sessionId } of cliFiles) { - if (signal && signal.aborted) break; - if (onProgress) onProgress(); - const { unchanged, stat } = cliCache.isUnchanged(filePath); - if (unchanged) { - skipped++; - continue; - } - try { - const session = await parseCliEventFile(filePath, sessionId); - if (!session) { cliCache.markSeenWith(filePath, stat); skipped++; continue; } - batch.push(session); - cliParsed.push({ path: filePath, stat }); - } catch { errors++; } - } - - if (batch.length > 0) importBatch(batch); - for (const { path, stat } of chatParsed) chatCache.markSeenWith(path, stat); - for (const { path, stat } of cliParsed) cliCache.markSeenWith(path, stat); - chatCache.pruneTo(chatFiles.map((f) => f.filePath)); - cliCache.pruneTo(cliFiles.map((f) => f.filePath)); - chatCache.flush(); - cliCache.flush(); - - return { imported, skipped, errors }; -} - -module.exports = { importAllCopilotSessions, importCopilotSession }; diff --git a/apps/desktop/scripts/agent-monitor-copilot/copilot-parser.js b/apps/desktop/scripts/agent-monitor-copilot/copilot-parser.js deleted file mode 100644 index af65cefa..00000000 --- a/apps/desktop/scripts/agent-monitor-copilot/copilot-parser.js +++ /dev/null @@ -1,493 +0,0 @@ -/** - * @file copilot-parser.js - * @description Parse GitHub Copilot session data into the normalized session - * object consumed by importSession(). Handles two formats: - * - * 1. Copilot Chat (VS Code extension): JSON files with conversation turns - * 2. Copilot CLI (`gh copilot`): JSONL event log files - * - * Both produce the same normalized shape so Copilot sessions render through - * the unchanged dashboard UI. - */ -const fs = require("fs"); -const path = require("path"); -const readline = require("readline"); -const { - extractErrorMessage, - toIso, - safeJson, - pushTurnDuration, -} = require("../agent-monitor-shared/parser-utils"); - -function hasRenderableContent(value, depth = 0) { - if (value == null || depth > 4) return false; - if (typeof value === "string") return value.trim().length > 0; - if (typeof value === "number" || typeof value === "boolean") return true; - if (Array.isArray(value)) return value.some((entry) => hasRenderableContent(entry, depth + 1)); - if (typeof value === "object") { - return Object.values(value).some((entry) => hasRenderableContent(entry, depth + 1)); - } - return false; -} - -function collectToolCalls(value, depth = 0, out = []) { - if (value == null || depth > 4) return out; - if (Array.isArray(value)) { - for (const entry of value) collectToolCalls(entry, depth + 1, out); - return out; - } - if (typeof value !== "object") return out; - - for (const key of ["toolCalls", "tool_calls", "functionCalls"]) { - const calls = value[key]; - if (Array.isArray(calls)) { - for (const call of calls) out.push(call); - } - } - - for (const key of ["message", "request", "prompt", "input", "response", "result", "reply", "output"]) { - collectToolCalls(value[key], depth + 1, out); - } - return out; -} - -function normalizeChatRequest(request, sessionData) { - if (!request || typeof request !== "object") return []; - - const requestTimestamp = - request.timestamp || - request.created_at || - request.createdAt || - request.requestDate || - request.message?.timestamp || - request.message?.createdAt || - sessionData.creationDate || - null; - const responseTimestamp = - request.responseTimestamp || - request.responseDate || - request.updatedAt || - request.response?.timestamp || - request.result?.timestamp || - sessionData.lastMessageDate || - requestTimestamp; - const userPayload = - request.message ?? - request.request ?? - request.prompt ?? - request.input; - const assistantPayload = - request.response ?? - request.result ?? - request.reply ?? - request.output; - const toolCalls = collectToolCalls(request); - const assistantError = extractErrorMessage( - request.responseError ?? - request.error ?? - request.result?.error ?? - request.response?.error, - ); - - const entries = []; - if ( - hasRenderableContent(userPayload) || - request.id != null || - request.requestId != null - ) { - entries.push({ - role: "user", - timestamp: requestTimestamp, - }); - } - - if ( - hasRenderableContent(assistantPayload) || - assistantError != null || - toolCalls.length > 0 || - request.response != null || - request.result != null || - request.reply != null || - request.output != null - ) { - entries.push({ - role: "assistant", - timestamp: responseTimestamp, - toolCalls, - thinking: Boolean( - request.thinking || - request.reasoning || - request.response?.thinking || - request.response?.reasoning || - request.result?.thinking || - request.result?.reasoning, - ), - error: assistantError, - }); - } - - return entries; -} - -function normalizeChatMessages(data) { - for (const key of ["messages", "turns", "history"]) { - const value = data[key]; - if (Array.isArray(value) && value.length > 0) return value; - } - - const requests = Array.isArray(data.requests) ? data.requests : []; - return requests.flatMap((request) => normalizeChatRequest(request, data)); -} - -/** - * Parse a Copilot Chat JSON session file (VS Code extension). - * Recent VS Code builds persist these as top-level metadata plus `requests[]`, - * while older shapes may store direct `messages[]` / `turns[]` arrays. - */ -function parseChatSessionFile(filePath, workspacePath) { - let data; - try { - data = JSON.parse(fs.readFileSync(filePath, "utf8")); - } catch { return null; } - - if (!data || typeof data !== "object") return null; - - const sessionId = data.sessionId || data.id || path.basename(filePath, ".json"); - - // P1 Fix: extract token usage from raw requests BEFORE normalization, - // since normalizeChatMessages reduces each request to {role, timestamp} - // and drops the original usage/response payloads. - const rawRequests = Array.isArray(data.requests) ? data.requests : []; - const requestTokenFields = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - for (const req of rawRequests) { - if (!req || typeof req !== "object") continue; - const usageInfo = - req.usage || req.tokenUsage || req.token_count || - req.response?.usage || req.result?.usage || null; - if (usageInfo && typeof usageInfo === "object") { - if (usageInfo.input_tokens != null) requestTokenFields.input += usageInfo.input_tokens; - if (usageInfo.output_tokens != null) requestTokenFields.output += usageInfo.output_tokens; - if (usageInfo.prompt_tokens != null) requestTokenFields.input += usageInfo.prompt_tokens; - if (usageInfo.completion_tokens != null) requestTokenFields.output += usageInfo.completion_tokens; - if (usageInfo.cache_read_tokens != null) requestTokenFields.cacheRead += usageInfo.cache_read_tokens; - if (usageInfo.cached_input_tokens != null) requestTokenFields.cacheRead += usageInfo.cached_input_tokens; - if (usageInfo.cache_write_tokens != null) requestTokenFields.cacheWrite += usageInfo.cache_write_tokens; - if (usageInfo.cache_creation_input_tokens != null) requestTokenFields.cacheWrite += usageInfo.cache_creation_input_tokens; - } - } - - const messages = normalizeChatMessages(data); - if (!Array.isArray(messages) || messages.length === 0) return null; - - let firstTimestamp = null; - let lastTimestamp = null; - let userMessageCount = 0; - let assistantMessageCount = 0; - const messageTimestamps = []; - const toolUses = []; - const turnDurations = []; - const apiErrors = []; - let thinkingBlockCount = 0; - const toolResultErrors = []; - const tokenFields = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - let pendingTurnStartedAt = null; - - const noteTs = (raw) => { - const iso = toIso(raw); - if (!iso) return null; - if (!firstTimestamp || iso < firstTimestamp) firstTimestamp = iso; - if (!lastTimestamp || iso > lastTimestamp) lastTimestamp = iso; - return iso; - }; - - for (const msg of messages) { - if (!msg || typeof msg !== "object") continue; - const ts = msg.timestamp || msg.created_at || msg.createdAt || msg.date || null; - const iso = noteTs(ts); - const role = msg.role || msg.author || msg.type || ""; - - if (role === "user" || role === "human") { - userMessageCount++; - if (iso) pendingTurnStartedAt = iso; - } else if (role === "assistant" || role === "copilot" || role === "bot") { - assistantMessageCount++; - if (iso) messageTimestamps.push(iso); - pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); - pendingTurnStartedAt = null; - } - - // Tool uses embedded in messages - const calls = - msg.toolCalls || - msg.tool_calls || - msg.functionCalls || - collectToolCalls(msg); - if (Array.isArray(calls)) { - for (const call of calls) { - if (!call) continue; - toolUses.push({ - name: call.name || call.function?.name || "copilot_tool", - timestamp: iso || firstTimestamp, - input: safeJson(call.arguments || call.input || call.parameters), - }); - } - } - - // Thinking blocks - if (msg.thinking || msg.reasoning) thinkingBlockCount++; - - const errorMessage = extractErrorMessage(msg.error); - if (errorMessage) { - apiErrors.push({ - type: "error", - message: errorMessage, - timestamp: iso, - }); - } - - // Token usage embedded in messages/requests - const usageInfo = - msg.usage || msg.tokenUsage || msg.token_count || msg.response?.usage || msg.result?.usage || null; - if (usageInfo && typeof usageInfo === "object") { - if (usageInfo.input_tokens != null) tokenFields.input += usageInfo.input_tokens; - if (usageInfo.output_tokens != null) tokenFields.output += usageInfo.output_tokens; - if (usageInfo.prompt_tokens != null) tokenFields.input += usageInfo.prompt_tokens; - if (usageInfo.completion_tokens != null) tokenFields.output += usageInfo.completion_tokens; - if (usageInfo.cache_read_tokens != null) tokenFields.cacheRead += usageInfo.cache_read_tokens; - if (usageInfo.cached_input_tokens != null) tokenFields.cacheRead += usageInfo.cached_input_tokens; - if (usageInfo.cache_write_tokens != null) tokenFields.cacheWrite += usageInfo.cache_write_tokens; - if (usageInfo.cache_creation_input_tokens != null) tokenFields.cacheWrite += usageInfo.cache_creation_input_tokens; - } - } - - // Merge request-level tokens (from raw requests before normalization) - // with message-level tokens. Use summation since each request is unique. - tokenFields.input += requestTokenFields.input; - tokenFields.output += requestTokenFields.output; - tokenFields.cacheRead += requestTokenFields.cacheRead; - tokenFields.cacheWrite += requestTokenFields.cacheWrite; - - // Token usage from top-level session data - const topUsage = data.usage || data.tokenUsage || data.token_count || null; - if (topUsage && typeof topUsage === "object") { - if (topUsage.input_tokens != null) tokenFields.input = Math.max(tokenFields.input, topUsage.input_tokens); - if (topUsage.output_tokens != null) tokenFields.output = Math.max(tokenFields.output, topUsage.output_tokens); - if (topUsage.prompt_tokens != null) tokenFields.input = Math.max(tokenFields.input, topUsage.prompt_tokens); - if (topUsage.completion_tokens != null) tokenFields.output = Math.max(tokenFields.output, topUsage.completion_tokens); - if (topUsage.cache_read_tokens != null) tokenFields.cacheRead = Math.max(tokenFields.cacheRead, topUsage.cache_read_tokens); - if (topUsage.cached_input_tokens != null) tokenFields.cacheRead = Math.max(tokenFields.cacheRead, topUsage.cached_input_tokens); - if (topUsage.cache_write_tokens != null) tokenFields.cacheWrite = Math.max(tokenFields.cacheWrite, topUsage.cache_write_tokens); - // P2 Fix: also map cache_creation_input_tokens to cacheWrite (alias) - if (topUsage.cache_creation_input_tokens != null) tokenFields.cacheWrite = Math.max(tokenFields.cacheWrite, topUsage.cache_creation_input_tokens); - } - - if (!firstTimestamp) { - // Fall back to file mtime - try { - const stat = fs.statSync(filePath); - firstTimestamp = stat.birthtime?.toISOString() || stat.mtime.toISOString(); - lastTimestamp = stat.mtime.toISOString(); - } catch { return null; } - } - - const model = data.model || data.modelId || null; - const cwd = workspacePath || data.cwd || data.workspaceFolder || null; - - let fileModifiedAt = null; - try { fileModifiedAt = fs.statSync(filePath).mtimeMs; } catch { /* non-fatal */ } - - const projectName = cwd ? path.basename(cwd) : `Copilot Chat ${sessionId.slice(0, 8)}`; - - const tokensByModel = {}; - if (tokenFields.input || tokenFields.output || tokenFields.cacheRead || tokenFields.cacheWrite) { - const key = model || "copilot-default"; - tokensByModel[key] = { - input: tokenFields.input, - output: tokenFields.output, - cacheRead: tokenFields.cacheRead, - cacheWrite: tokenFields.cacheWrite, - }; - } - - return { - sessionId: `copilot-chat-${sessionId}`, - name: projectName, - cwd, - model, - version: null, - slug: null, - gitBranch: null, - startedAt: firstTimestamp, - endedAt: lastTimestamp, - teams: [], - userMessages: userMessageCount, - assistantMessages: assistantMessageCount, - tokensByModel, - messageTimestamps, - toolUses, - compactions: [], - apiErrors, - fileModifiedAt, - turnDurations, - entrypoint: "copilot", - permissionMode: null, - thinkingBlockCount, - toolResultErrors, - usageExtras: { service_tiers: [], speeds: [], inference_geos: [] }, - }; -} - -/** - * Parse a Copilot CLI events.jsonl file. - */ -async function parseCliEventFile(filePath, sessionId) { - const rl = readline.createInterface({ - input: fs.createReadStream(filePath, { encoding: "utf8" }), - crlfDelay: Infinity, - }); - - let cwd = null; - let model = null; - let version = null; - let firstTimestamp = null; - let lastTimestamp = null; - let userMessageCount = 0; - let assistantMessageCount = 0; - const messageTimestamps = []; - const toolUses = []; - const turnDurations = []; - const apiErrors = []; - let thinkingBlockCount = 0; - const toolResultErrors = []; - let tokenInput = 0; - let tokenOutput = 0; - let tokenCacheRead = 0; - let tokenCacheWrite = 0; - let tokenReasoning = 0; - let pendingTurnStartedAt = null; - - const noteTs = (raw) => { - const iso = toIso(raw); - if (!iso) return null; - if (!firstTimestamp || iso < firstTimestamp) firstTimestamp = iso; - if (!lastTimestamp || iso > lastTimestamp) lastTimestamp = iso; - return iso; - }; - - for await (const line of rl) { - if (!line.trim()) continue; - let rec; - try { rec = JSON.parse(line); } catch { continue; } - if (!rec || typeof rec !== "object") continue; - - const ts = rec.timestamp || rec.ts || rec.created_at || null; - const iso = noteTs(ts); - const type = rec.type || rec.event || ""; - const payload = rec.payload || rec.data || rec; - - // Session metadata - if (type === "session_start" || type === "session_created" || type === "init") { - if (!cwd) cwd = payload.cwd || payload.workdir || null; - if (!version) version = payload.version || payload.cli_version || null; - if (!model) model = payload.model || null; - } - - // Messages - if (type === "user_message" || type === "user_input" || type === "prompt") { - userMessageCount++; - if (iso) pendingTurnStartedAt = iso; - } - if (type === "assistant_message" || type === "response" || type === "completion") { - assistantMessageCount++; - if (iso) messageTimestamps.push(iso); - pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); - pendingTurnStartedAt = null; - } - - // Tool calls - if (type === "tool_call" || type === "function_call" || type === "command") { - toolUses.push({ - name: payload.name || payload.tool || payload.command || "copilot_tool", - timestamp: iso || firstTimestamp, - input: safeJson(payload.arguments || payload.input), - }); - } - - // Token usage - if (type === "usage" || type === "token_count" || type === "metrics") { - const info = payload.usage || payload; - if (info.input_tokens != null) tokenInput = info.input_tokens; - if (info.output_tokens != null) tokenOutput = info.output_tokens; - if (info.prompt_tokens != null) tokenInput = info.prompt_tokens; - if (info.completion_tokens != null) tokenOutput = info.completion_tokens; - if (info.cache_read_tokens != null) tokenCacheRead = info.cache_read_tokens; - if (info.cached_input_tokens != null) tokenCacheRead = info.cached_input_tokens; - if (info.cache_write_tokens != null) tokenCacheWrite = info.cache_write_tokens; - if (info.cache_creation_input_tokens != null) tokenCacheWrite = info.cache_creation_input_tokens; - if (info.reasoning_tokens != null) tokenReasoning = info.reasoning_tokens; - if (info.reasoning_output_tokens != null) tokenReasoning = info.reasoning_output_tokens; - if (payload.model) model = payload.model; - } - - // Errors - if (type === "error" || type === "api_error") { - apiErrors.push({ - type, - message: payload.message || payload.error || "Copilot CLI error", - timestamp: iso, - }); - } - - // Thinking - if (type === "reasoning" || type === "thinking") { - thinkingBlockCount++; - } - } - - if (!firstTimestamp) return null; - - const tokensByModel = {}; - if (tokenInput || tokenOutput || tokenCacheRead || tokenCacheWrite || tokenReasoning) { - const key = model || "copilot-default"; - tokensByModel[key] = { - input: tokenInput, - output: tokenOutput + tokenReasoning, - cacheRead: tokenCacheRead, - cacheWrite: tokenCacheWrite, - }; - } - - let fileModifiedAt = null; - try { fileModifiedAt = fs.statSync(filePath).mtimeMs; } catch { /* non-fatal */ } - - const projectName = cwd ? path.basename(cwd) : `Copilot CLI ${sessionId.slice(0, 8)}`; - - return { - sessionId: `copilot-cli-${sessionId}`, - name: projectName, - cwd, - model, - version, - slug: null, - gitBranch: null, - startedAt: firstTimestamp, - endedAt: lastTimestamp, - teams: [], - userMessages: userMessageCount, - assistantMessages: assistantMessageCount, - tokensByModel, - messageTimestamps, - toolUses, - compactions: [], - apiErrors, - fileModifiedAt, - turnDurations, - entrypoint: "copilot", - permissionMode: null, - thinkingBlockCount, - toolResultErrors, - usageExtras: { service_tiers: [], speeds: [], inference_geos: [] }, - }; -} - -module.exports = { parseChatSessionFile, parseCliEventFile }; diff --git a/apps/desktop/scripts/agent-monitor-copilot/copilot-watcher.js b/apps/desktop/scripts/agent-monitor-copilot/copilot-watcher.js deleted file mode 100644 index 1957b944..00000000 --- a/apps/desktop/scripts/agent-monitor-copilot/copilot-watcher.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * @file copilot-watcher.js - * @description Live file watcher for GitHub Copilot sessions. Watches both: - * 1. VS Code workspace storage chatSessions/ directories for new/changed JSON - * 2. ~/.copilot/session-state/ for new/changed JSONL event logs - * - * Best-effort and non-fatal. - */ -const fs = require("fs"); -const path = require("path"); -const { - getCopilotCliSessionStateDir, - getVscodeWorkspaceStorageDir, - readWorkspacePathFromHashDir, -} = require("./copilot-home"); -const { parseChatSessionFile, parseCliEventFile } = require("./copilot-parser"); -const { broadcastHarnessRows } = require("../agent-monitor-shared/harness-watcher-utils"); - -const DEBOUNCE_MS = 600; -const RETRY_MS = 4000; -const MAX_RETRY_ATTEMPTS = 75; // ~5 minutes at 4s intervals, then give up -const CATCHUP_POLL_MS = 5000; -const CHAT_SESSION_FILE_RE = /(^|[/\\])chatSessions[/\\][^/\\]+\.json$/i; - -let started = false; -let timer = null; -let retryTimers = []; -let catchupTimer = null; -let pending = new Map(); // filePath → { type: "chat"|"cli", meta } -const watchers = []; - -function processPending(broadcast) { - const entries = Array.from(pending.entries()); - pending = new Map(); - if (entries.length === 0) return; - - let dbModule; - let importCopilotSession; - try { - dbModule = require("../db"); - ({ importCopilotSession } = require("./copilot-import")); - } catch { return; } - - (async () => { - for (const [filePath, info] of entries) { - let session; - try { - if (info.type === "chat") { - session = parseChatSessionFile(filePath, info.workspacePath); - } else { - session = await parseCliEventFile(filePath, info.sessionId); - } - } catch { continue; } - if (!session) continue; - try { - const before = dbModule.stmts.getSession.get(session.sessionId); - const apply = dbModule.db.transaction(() => { - importCopilotSession(dbModule, session); - }); - apply(); - const row = dbModule.stmts.getSession.get(session.sessionId); - if (row) broadcast(before ? "session_updated" : "session_created", row); - const agent = dbModule.stmts.getAgent.get(`${session.sessionId}-main`); - if (agent) broadcast("agent_updated", agent); - } catch { /* non-fatal */ } - } - })(); -} - -function scheduleProcess(broadcast, filePath, info) { - if (filePath) pending.set(filePath, info); - if (timer) return; - timer = setTimeout(() => { - timer = null; - try { processPending(broadcast); } catch { /* ignore */ } - }, DEBOUNCE_MS); -} - -function watchDir(root, broadcast, matchFn, infoFn) { - try { - if (!fs.existsSync(root)) return false; - const w = fs.watch(root, { recursive: true }, (_event, filename) => { - if (!filename) return; - if (!matchFn(filename)) return; - const full = path.join(root, filename); - scheduleProcess(broadcast, full, infoFn(full, filename)); - }); - w.on("error", () => {}); - watchers.push(w); - return true; - } catch { return false; } -} - -function retryWatch(root, broadcast, matchFn, infoFn) { - if (watchDir(root, broadcast, matchFn, infoFn)) return; - let retryCount = 0; - const t = setInterval(() => { - if (++retryCount > MAX_RETRY_ATTEMPTS) { - clearInterval(t); - retryTimers = retryTimers.filter((r) => r !== t); - return; - } - if (!fs.existsSync(root)) return; - if (watchDir(root, broadcast, matchFn, infoFn)) { - clearInterval(t); - retryTimers = retryTimers.filter((r) => r !== t); - runCatchupImport(broadcast); - } - }, RETRY_MS); - t.unref?.(); - retryTimers.push(t); -} - -function runCatchupImport(broadcast) { - let dbModule; - let importAllCopilotSessions; - try { - dbModule = require("../db"); - ({ importAllCopilotSessions } = require("./copilot-import")); - } catch { return; } - Promise.resolve() - .then(() => importAllCopilotSessions(dbModule)) - .then(({ imported }) => { - if (imported > 0) { - broadcastHarnessRows(dbModule, broadcast, "copilot"); - } - }) - .catch(() => {}); -} - -function startCopilotWatcher({ broadcast }) { - if (started) return; - started = true; - // Clear any stale retry timers from a previous lifecycle - for (const t of retryTimers) { clearInterval(t); } - retryTimers = []; - catchupTimer = setInterval(() => runCatchupImport(broadcast), CATCHUP_POLL_MS); - catchupTimer.unref?.(); - runCatchupImport(broadcast); - - // Watch VS Code workspace storage for chat session JSON files - const wsRoot = getVscodeWorkspaceStorageDir(); - retryWatch( - wsRoot, - broadcast, - (filename) => CHAT_SESSION_FILE_RE.test(String(filename)), - (full) => { - const hashDir = path.dirname(path.dirname(full)); - return { type: "chat", workspacePath: readWorkspacePathFromHashDir(hashDir) }; - }, - ); - - // Watch Copilot CLI session-state for JSONL event files - const cliRoot = getCopilotCliSessionStateDir(); - retryWatch( - cliRoot, - broadcast, - (filename) => String(filename).endsWith(".jsonl"), - (full, filename) => ({ - type: "cli", - sessionId: path.basename(path.dirname(full)), - }), - ); -} - -function stopCopilotWatcher() { - if (timer) { clearTimeout(timer); timer = null; } - for (const t of retryTimers) { clearInterval(t); } - retryTimers = []; - if (catchupTimer) { clearInterval(catchupTimer); catchupTimer = null; } - for (const w of watchers) { try { w.close(); } catch { /* ignore */ } } - watchers.length = 0; - pending = new Map(); - started = false; -} - -module.exports = { startCopilotWatcher, stopCopilotWatcher }; diff --git a/apps/desktop/scripts/agent-monitor-cost/cost-pricing.js b/apps/desktop/scripts/agent-monitor-cost/cost-pricing.js deleted file mode 100644 index d09d8d12..00000000 --- a/apps/desktop/scripts/agent-monitor-cost/cost-pricing.js +++ /dev/null @@ -1,176 +0,0 @@ -/** - * @file cost-pricing.js - * @description Canonical token-cost engine for the agent-monitor sidecar - * (CommonJS). Wraps `@pydantic/genai-prices` — the single source of truth for - * model rates — and converts the dashboard DB's per-harness token counts into - * the canonical `Usage` shape the library expects, then returns the library's - * computed price UNCHANGED. - * - * CLOSEDLOOP FEA-1431. Replaces the deleted hand-maintained pricing table + - * override pipeline (HOST_DEFAULT_PRICING / model_pricing-based calculateCost), - * which double-charged cached OpenAI tokens (the v1 overcharge bug — see the - * "input convention" note below). - * - * ── Core principle ────────────────────────────────────────────────────────── - * TRUST THE LIBRARY. This module never overrides, clamps, asserts, or rewrites - * any rate or price genai-prices returns. Its ONLY job is to feed correct - * INPUTS. If a rate is wrong, it is fixed upstream (or by bumping the pinned - * library version) — never patched locally. - * - * ── The input-token convention (the crux of correctness) ───────────────────── - * genai-prices treats `Usage.input_tokens` as the GRAND TOTAL prompt size - * (uncached + cache_read + cache_write); internally it derives - * uncached = input_tokens - cache_read_tokens - cache_write_tokens - * and throws if that goes negative. - * - * But the two providers report raw input differently, and the dashboard DB - * faithfully preserves whichever convention each harness ingested: - * • Anthropic (Claude Code harness): the API's `input_tokens` is FRESH / - * uncached; cache_read / cache_write are SEPARATE, additive fields. The DB - * stores `input` = fresh. So the grand total = input + cacheRead + cacheWrite. - * • OpenAI / others (Codex etc.): the API's `input_tokens` is the TOTAL prompt - * and cached tokens are a SUBSET of it. The DB stores `input` = total. So - * the grand total = input (cache must NOT be added — doing so double-charges - * the cached portion, which was the v1 overcharge bug). - * - * This is exactly what genai-prices' own `extractUsage` does (verified against - * the library: Anthropic raw {input:1000,cache_read:500,cache_write:300} → - * canonical input_tokens 1800; OpenAI raw {input:1000, cached:500} → canonical - * input_tokens 1000). We mirror that behavior here, keyed on the model's - * provider, so the conversion stays consistent with the library's source of - * truth rather than a local guess. A parity test asserts this Set matches the - * library's actual extractUsage summing behavior so drift is caught loudly. - */ -"use strict"; - -const { calcPrice, findProvider } = require("@pydantic/genai-prices"); - -/** - * Provider ids whose API reports `input_tokens` as FRESH (uncached) with cache - * counts as SEPARATE additive fields — so the genai-prices grand total is - * `input + cacheRead + cacheWrite`. Every other provider reports `input_tokens` - * as the TOTAL (cache is a subset), so `input` passes through unchanged. - * - * Anthropic is currently the only provider in genai-prices' data that uses the - * additive (separate cache_creation/cache_read) convention. This Set is - * verified against the library's own extractUsage in the parity test; if a - * future genai-prices version adds another additive-cache provider, that test - * fails so we update this Set deliberately. - */ -const CACHE_ADDITIVE_PROVIDERS = new Set(["anthropic"]); - -/** Coerce a possibly-null/undefined/string DB token count to a finite number. */ -function toCount(value) { - const n = Number(value); - return Number.isFinite(n) && n > 0 ? n : 0; -} - -function notPriced(reason, provider = null) { - return { - priced: false, - provider, - costUsd: null, - inputCostUsd: null, - outputCostUsd: null, - reason, - }; -} - -/** - * Resolve the provider id for a model id, defensively (findProvider can throw - * on malformed input). Returns null when the model is unknown. - */ -function resolveProviderId(model) { - try { - const provider = findProvider({ modelId: model }); - return provider ? provider.id : null; - } catch { - return null; - } -} - -/** - * Build the canonical genai-prices `Usage` from the DB's per-harness counts, - * applying the provider-aware input convention described in the file header. - */ -function buildUsage(providerId, counts) { - const additive = providerId != null && CACHE_ADDITIVE_PROVIDERS.has(providerId); - return { - input_tokens: additive - ? counts.input + counts.cacheRead + counts.cacheWrite - : counts.input, - output_tokens: counts.output, - cache_read_tokens: counts.cacheRead, - cache_write_tokens: counts.cacheWrite, - }; -} - -/** - * Compute the USD cost for one (model, token-counts) row. - * - * @param {object} input - * @param {string} input.model Model id as stored in the DB. - * @param {number} input.inputTokens Provider-native input count (see header). - * @param {number} input.outputTokens - * @param {number} input.cacheReadTokens - * @param {number} input.cacheWriteTokens - * @param {Date} [input.timestamp] Optional historical pricing date. - * @returns {{ - * priced: boolean, - * provider: string|null, - * costUsd: number|null, - * inputCostUsd: number|null, - * outputCostUsd: number|null, - * reason: string|null, - * }} Library values are returned UNCHANGED (no rounding/clamping). `reason` is - * one of "unknown_model" | "no_match" | "compute_error" when not priced. - */ -function computeTokenCost(input) { - const model = input && typeof input.model === "string" ? input.model : ""; - if (model.length === 0) { - return notPriced("unknown_model"); - } - - const counts = { - input: toCount(input.inputTokens), - output: toCount(input.outputTokens), - cacheRead: toCount(input.cacheReadTokens), - cacheWrite: toCount(input.cacheWriteTokens), - }; - - const providerId = resolveProviderId(model); - const usage = buildUsage(providerId, counts); - const options = - input.timestamp instanceof Date ? { timestamp: input.timestamp } : undefined; - - let result; - try { - result = calcPrice(usage, model, options); - } catch { - // calcPrice throws on genuinely inconsistent input (e.g. negative uncached). - // Never crash the cost path — surface as not-priced so the caller can show - // "—" rather than a wrong number or an exception. - return notPriced("compute_error", providerId); - } - - if (!result) { - // Library found no matching model/provider → not priced. - return notPriced("no_match", providerId); - } - - return { - priced: true, - provider: (result.provider && result.provider.id) || providerId, - costUsd: result.total_price, - inputCostUsd: result.input_price, - outputCostUsd: result.output_price, - reason: null, - }; -} - -module.exports = { - computeTokenCost, - CACHE_ADDITIVE_PROVIDERS, - // Exported for the parity test only. - buildUsage, -}; diff --git a/apps/desktop/scripts/agent-monitor-cost/package.json b/apps/desktop/scripts/agent-monitor-cost/package.json deleted file mode 100644 index aaa0afce..00000000 --- a/apps/desktop/scripts/agent-monitor-cost/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "//": "Scopes this dir to CommonJS (parent apps/desktop is type:module). cost-pricing.js is build-time-copied into the generated agent-monitor server/lib (a CommonJS tree), mirroring scripts/agent-monitor-plans. Not part of the desktop ESM build.", - "type": "commonjs", - "private": true -} diff --git a/apps/desktop/scripts/agent-monitor-cursor/cursor-home.js b/apps/desktop/scripts/agent-monitor-cursor/cursor-home.js deleted file mode 100644 index 9d7e71b6..00000000 --- a/apps/desktop/scripts/agent-monitor-cursor/cursor-home.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * @file cursor-home.js - * @description Centralized Cursor session path management. Resolves paths for - * Cursor's background agent JSONL transcripts stored under - * `~/.cursor/projects//agent-transcripts//`. - * - * Cursor also stores standard chat sessions in a SQLite database - * (`state.vscdb`) under VS Code workspace storage, but those are opaque - * key-value blobs — this module focuses on the structured agent transcripts - * that yield the same telemetry the dashboard expects. - */ -const path = require("path"); -const os = require("os"); -const fs = require("fs"); - -function getCursorHome() { - const raw = process.env.CURSOR_HOME; - if (raw && raw.trim()) { - return raw.trim().replace(/^~(?=\/)/, os.homedir()); - } - return path.join(os.homedir(), ".cursor"); -} - -function getCursorProjectsDir() { - return path.join(getCursorHome(), "projects"); -} - -/** - * Derive a stable session id from an agent transcript path. - * Cursor stores transcripts at: - * ~/.cursor/projects//agent-transcripts//.jsonl - * The session-id directory name is the canonical id. - */ -function sessionIdFromTranscriptPath(filePath) { - // The parent directory name is the session id - return path.basename(path.dirname(filePath)); -} - -/** - * Recursively collect every `*.jsonl` transcript file under the projects root. - * Cursor nests by project → agent-transcripts → session-id, but we walk - * generically. Depth-bounded and error-tolerant. - */ -function collectTranscriptFiles(root, { maxDepth = 8 } = {}) { - const out = []; - if (!root || !fs.existsSync(root)) return out; - const walk = (dir, depth) => { - if (depth > maxDepth) return; - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) { - walk(full, depth + 1); - } else if (e.isFile() && e.name.endsWith(".jsonl")) { - out.push(full); - } - } - }; - walk(root, 0); - return out; -} - -/** - * All Cursor agent transcript files. - */ -function listAllTranscriptFiles() { - return collectTranscriptFiles(getCursorProjectsDir()); -} - -module.exports = { - getCursorHome, - getCursorProjectsDir, - sessionIdFromTranscriptPath, - collectTranscriptFiles, - listAllTranscriptFiles, -}; diff --git a/apps/desktop/scripts/agent-monitor-cursor/cursor-import.js b/apps/desktop/scripts/agent-monitor-cursor/cursor-import.js deleted file mode 100644 index 4cb3b2f0..00000000 --- a/apps/desktop/scripts/agent-monitor-cursor/cursor-import.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @file cursor-import.js - * @description Bootstrap importer for Cursor agent sessions. Parses each - * Cursor agent transcript JSONL into the shared normalized session shape and - * reuses the existing importSession() so Cursor sessions land in the same - * sessions/agents/events/token_usage rows. The only Cursor-specific step is - * stamping `harness='cursor'` on the row afterwards. - */ -const { parseTranscriptFile } = require("./cursor-parser"); -const { listAllTranscriptFiles } = require("./cursor-home"); -const { importSession } = require("../../scripts/import-history"); -const { reactivateImportedSession } = require("../agent-monitor-shared/import-session-utils"); -const { createCatchupCache } = require("../agent-monitor-shared/catchup-cache"); -const { ingestCachePath } = require("../agent-monitor-shared/ingest-paths"); -const { stampSessionBillingMode } = require("../agent-monitor-shared/billing-stamp"); - -// Skip transcript files unchanged since the last tick to keep the 5 s catchup -// poll cheap (FEA-1316); the persisted backing file additionally lets a fresh -// process skip unchanged files on the cold-start boot import (FEA-1334). -const catchupCache = createCatchupCache({ persistPath: ingestCachePath("cursor") }); - -/** - * Import a single Cursor agent transcript file. - */ -function importCursorSession(dbModule, session) { - const result = importSession(dbModule, session); - try { - dbModule.stmts.setSessionHarness.run("cursor", session.sessionId, "cursor"); - } catch { /* non-fatal */ } - // FEA-1434: stamp the billing mode (idempotent + best-effort internally). - stampSessionBillingMode(dbModule.stmts, "cursor", session.sessionId); - const reactivated = reactivateImportedSession(dbModule, session); - return { sessionId: session.sessionId, result, reactivated }; -} - -/** - * Parse + import every discovered Cursor transcript file. Idempotent on repeat runs. - * - * @param {any} dbModule - * @param {{ signal?: AbortSignal, onBegin?: (total: number) => void, - * onProgress?: () => void }} [opts] - ingest-orchestrator progress - * hooks (FEA-1334). The watcher catchup tick calls this with no opts. - */ -async function importAllCursorSessions(dbModule, opts = {}) { - const onBegin = typeof opts.onBegin === "function" ? opts.onBegin : null; - const onProgress = typeof opts.onProgress === "function" ? opts.onProgress : null; - const signal = opts.signal || null; - const files = listAllTranscriptFiles(); - if (onBegin) onBegin(files.length); - let imported = 0; - let skipped = 0; - let errors = 0; - - const importBatch = dbModule.db.transaction((sessions) => { - for (const session of sessions) { - const { result, reactivated } = importCursorSession(dbModule, session); - if (result && result.skipped && !reactivated) skipped++; - else imported++; - } - }); - - const batch = []; - const parsedEntries = []; - for (const filePath of files) { - if (signal && signal.aborted) break; - if (onProgress) onProgress(); - const { unchanged, stat } = catchupCache.isUnchanged(filePath); - if (unchanged) { - skipped++; - continue; - } - try { - const session = await parseTranscriptFile(filePath); - if (!session) { - catchupCache.markSeenWith(filePath, stat); - skipped++; - continue; - } - batch.push(session); - parsedEntries.push({ path: filePath, stat }); - } catch { errors++; } - } - if (batch.length > 0) importBatch(batch); - for (const { path, stat } of parsedEntries) catchupCache.markSeenWith(path, stat); - catchupCache.pruneTo(files); - catchupCache.flush(); - - return { imported, skipped, errors }; -} - -module.exports = { importAllCursorSessions, importCursorSession }; diff --git a/apps/desktop/scripts/agent-monitor-cursor/cursor-parser.js b/apps/desktop/scripts/agent-monitor-cursor/cursor-parser.js deleted file mode 100644 index c40c8adf..00000000 --- a/apps/desktop/scripts/agent-monitor-cursor/cursor-parser.js +++ /dev/null @@ -1,208 +0,0 @@ -/** - * @file cursor-parser.js - * @description Parse a Cursor agent transcript JSONL file into the normalized - * session object consumed by importSession(). Cursor's background agent - * transcripts use a format similar to Codex rollouts — each line is a JSON - * record with a type, payload, and timestamp. The parser is intentionally - * tolerant of format drift across Cursor versions. - */ -const fs = require("fs"); -const path = require("path"); -const readline = require("readline"); -const { sessionIdFromTranscriptPath } = require("./cursor-home"); -const { toIso, safeJson, pushTurnDuration } = require("../agent-monitor-shared/parser-utils"); - -/** - * Parse a single Cursor agent transcript JSONL file. - * Returns null when the file carries no usable timestamp. - */ -async function parseTranscriptFile(filePath) { - const sessionId = sessionIdFromTranscriptPath(filePath); - - const rl = readline.createInterface({ - input: fs.createReadStream(filePath, { encoding: "utf8" }), - crlfDelay: Infinity, - }); - - let cwd = null; - let model = null; - let version = null; - let gitBranch = null; - let firstTimestamp = null; - let lastTimestamp = null; - let userMessageCount = 0; - let assistantMessageCount = 0; - const messageTimestamps = []; - const toolUses = []; - const turnDurations = []; - const apiErrors = []; - let thinkingBlockCount = 0; - const toolResultErrors = []; - let tokenInput = 0; - let tokenOutput = 0; - let tokenCacheRead = 0; - let tokenCacheWrite = 0; - let pendingTurnStartedAt = null; - - const noteTs = (raw) => { - const iso = toIso(raw); - if (!iso) return null; - if (!firstTimestamp || iso < firstTimestamp) firstTimestamp = iso; - if (!lastTimestamp || iso > lastTimestamp) lastTimestamp = iso; - return iso; - }; - - for await (const line of rl) { - if (!line.trim()) continue; - let rec; - try { rec = JSON.parse(line); } catch { continue; } - if (!rec || typeof rec !== "object") continue; - - const ts = rec.timestamp || rec.ts || rec.created_at || null; - const iso = noteTs(ts); - const type = rec.type || ""; - const payload = rec.payload || rec.data || rec; - - // Session metadata - if (type === "session_meta" || type === "session.created" || type === "session_start" || - (!type && (payload.cwd || payload.workdir || payload.workspace))) { - if (!cwd) cwd = payload.cwd || payload.workdir || payload.workspace || null; - if (!version) version = payload.version || payload.cli_version || payload.cursor_version || null; - if (!model) model = payload.model || null; - if (!gitBranch) { - if (typeof payload.git === "object" && payload.git) { - gitBranch = payload.git.branch || payload.git.ref || null; - } else if (payload.git_branch) { - gitBranch = payload.git_branch; - } - } - } - - // Model override (turn-level is authoritative) - if (type === "turn_context" || type === "turn.context" || type === "model_context") { - if (payload.model) model = payload.model; - if (!cwd && payload.cwd) cwd = payload.cwd; - } - - // User messages - if (type === "user_message" || type === "human_message" || - (type === "message" && (payload.role === "user" || payload.author === "user"))) { - userMessageCount++; - if (iso) pendingTurnStartedAt = iso; - } - - // Assistant messages - if (type === "assistant_message" || type === "agent_message" || - (type === "message" && (payload.role === "assistant" || payload.author === "assistant"))) { - assistantMessageCount++; - if (iso) messageTimestamps.push(iso); - pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); - pendingTurnStartedAt = null; - } - - // Thinking/reasoning - if (type === "reasoning" || type === "thinking" || type === "agent_reasoning") { - thinkingBlockCount++; - } - - // Tool calls - if (type === "tool_call" || type === "function_call" || type === "tool_use" || - type === "command_execution" || type === "terminal_command") { - toolUses.push({ - name: payload.name || payload.tool_name || payload.command_name || "tool", - timestamp: iso || firstTimestamp, - input: safeJson(payload.arguments != null ? payload.arguments : payload.input), - }); - } - - // File edits (Cursor-specific) - if (type === "file_edit" || type === "apply_edit" || type === "code_edit") { - toolUses.push({ - name: "file_edit", - timestamp: iso || firstTimestamp, - input: payload.file || payload.path || null, - }); - } - - // Tool results with errors - if (type === "tool_result" || type === "tool_output" || type === "command_output") { - const isErr = payload.is_error === true || payload.success === false || - payload.exit_code > 0 || !!payload.error; - if (isErr) { - const content = typeof payload.output === "string" - ? payload.output.slice(0, 500) - : JSON.stringify(payload.error || payload.output || payload).slice(0, 500); - toolResultErrors.push({ content, timestamp: iso }); - } - } - - // Token usage - if (type === "token_count" || type === "usage" || type === "token_usage") { - const info = payload.usage || payload.token_count || payload; - if (info.input_tokens != null) tokenInput = info.input_tokens; - if (info.output_tokens != null) tokenOutput = info.output_tokens; - if (info.cache_read_tokens != null) tokenCacheRead = info.cache_read_tokens; - if (info.cached_input_tokens != null) tokenCacheRead = info.cached_input_tokens; - if (info.cache_write_tokens != null) tokenCacheWrite = info.cache_write_tokens; - if (info.cache_creation_input_tokens != null) tokenCacheWrite = info.cache_creation_input_tokens; - if (payload.model) model = payload.model; - } - - // Errors - if (type === "error" || type === "api_error" || type === "stream_error") { - apiErrors.push({ - type, - message: (typeof payload.message === "string" && payload.message) || - payload.error || "Cursor error", - timestamp: iso, - }); - } - } - - if (!firstTimestamp) return null; - - const tokensByModel = {}; - if (tokenInput || tokenOutput || tokenCacheRead || tokenCacheWrite) { - const key = model || "cursor-default"; - tokensByModel[key] = { - input: tokenInput, - output: tokenOutput, - cacheRead: tokenCacheRead, - cacheWrite: tokenCacheWrite, - }; - } - - let fileModifiedAt = null; - try { fileModifiedAt = fs.statSync(filePath).mtimeMs; } catch { /* non-fatal */ } - - const projectName = cwd ? path.basename(cwd) : `Cursor Session ${sessionId.slice(0, 8)}`; - - return { - sessionId, - name: projectName, - cwd, - model, - version, - slug: null, - gitBranch, - startedAt: firstTimestamp, - endedAt: lastTimestamp, - teams: [], - userMessages: userMessageCount, - assistantMessages: assistantMessageCount, - tokensByModel, - messageTimestamps, - toolUses, - compactions: [], - apiErrors, - fileModifiedAt, - turnDurations, - entrypoint: "cursor", - permissionMode: null, - thinkingBlockCount, - toolResultErrors, - usageExtras: { service_tiers: [], speeds: [], inference_geos: [] }, - }; -} - -module.exports = { parseTranscriptFile }; diff --git a/apps/desktop/scripts/agent-monitor-cursor/cursor-watcher.js b/apps/desktop/scripts/agent-monitor-cursor/cursor-watcher.js deleted file mode 100644 index 5e7010c5..00000000 --- a/apps/desktop/scripts/agent-monitor-cursor/cursor-watcher.js +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @file cursor-watcher.js - * @description Live file watcher for Cursor agent transcripts. Watches - * `~/.cursor/projects/` for new/changed JSONL transcript files and - * re-imports them into the dashboard on change. Best-effort and non-fatal. - */ -const fs = require("fs"); -const path = require("path"); -const { getCursorProjectsDir } = require("./cursor-home"); -const { parseTranscriptFile } = require("./cursor-parser"); -const { broadcastHarnessRows } = require("../agent-monitor-shared/harness-watcher-utils"); - -const DEBOUNCE_MS = 600; -const RETRY_MS = 4000; -const MAX_RETRY_ATTEMPTS = 75; // ~5 minutes at 4s intervals, then give up -const CATCHUP_POLL_MS = 5000; - -let started = false; -let timer = null; -let retryTimer = null; -let catchupTimer = null; -let pending = new Set(); -const watchers = []; - -function processPending(broadcast) { - const files = Array.from(pending); - pending = new Set(); - if (files.length === 0) return; - - let dbModule; - let importCursorSession; - try { - dbModule = require("../db"); - ({ importCursorSession } = require("./cursor-import")); - } catch { return; } - - (async () => { - for (const filePath of files) { - let session; - try { session = await parseTranscriptFile(filePath); } catch { continue; } - if (!session) continue; - try { - const before = dbModule.stmts.getSession.get(session.sessionId); - const apply = dbModule.db.transaction(() => { - importCursorSession(dbModule, session); - }); - apply(); - const row = dbModule.stmts.getSession.get(session.sessionId); - if (row) broadcast(before ? "session_updated" : "session_created", row); - const agent = dbModule.stmts.getAgent.get(`${session.sessionId}-main`); - if (agent) broadcast("agent_updated", agent); - } catch { /* non-fatal */ } - } - })(); -} - -function scheduleProcess(broadcast, filePath) { - if (filePath) pending.add(filePath); - if (timer) return; - timer = setTimeout(() => { - timer = null; - try { processPending(broadcast); } catch { /* ignore */ } - }, DEBOUNCE_MS); -} - -function safeWatch({ root, broadcast }) { - try { - if (!fs.existsSync(root)) return false; - const w = fs.watch(root, { recursive: true }, (_event, filename) => { - if (!filename) return; - if (!String(filename).endsWith(".jsonl")) return; - const full = path.join(root, filename); - scheduleProcess(broadcast, full); - }); - w.on("error", () => {}); - watchers.push(w); - return true; - } catch { return false; } -} - -function runCatchupImport(broadcast) { - let dbModule; - let importAllCursorSessions; - try { - dbModule = require("../db"); - ({ importAllCursorSessions } = require("./cursor-import")); - } catch { return; } - Promise.resolve() - .then(() => importAllCursorSessions(dbModule)) - .then(({ imported }) => { - if (imported > 0) { - broadcastHarnessRows(dbModule, broadcast, "cursor"); - } - }) - .catch(() => {}); -} - -function startCursorWatcher({ broadcast }) { - if (started) return; - started = true; - catchupTimer = setInterval(() => runCatchupImport(broadcast), CATCHUP_POLL_MS); - catchupTimer.unref?.(); - runCatchupImport(broadcast); - const root = getCursorProjectsDir(); - if (safeWatch({ root, broadcast })) return; - if (retryTimer) { clearInterval(retryTimer); retryTimer = null; } - let retryCount = 0; - retryTimer = setInterval(() => { - if (++retryCount > MAX_RETRY_ATTEMPTS) { - clearInterval(retryTimer); - retryTimer = null; - return; - } - if (!fs.existsSync(root)) return; - if (safeWatch({ root, broadcast })) { - clearInterval(retryTimer); - retryTimer = null; - runCatchupImport(broadcast); - } - }, RETRY_MS); - retryTimer.unref?.(); -} - -function stopCursorWatcher() { - if (timer) { clearTimeout(timer); timer = null; } - if (retryTimer) { clearInterval(retryTimer); retryTimer = null; } - if (catchupTimer) { clearInterval(catchupTimer); catchupTimer = null; } - for (const w of watchers) { try { w.close(); } catch { /* ignore */ } } - watchers.length = 0; - pending = new Set(); - started = false; -} - -module.exports = { startCursorWatcher, stopCursorWatcher }; diff --git a/apps/desktop/scripts/agent-monitor-embed/App.tsx b/apps/desktop/scripts/agent-monitor-embed/App.tsx deleted file mode 100644 index 1d241ec1..00000000 --- a/apps/desktop/scripts/agent-monitor-embed/App.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @file App.tsx - * @description ClosedLoop-authored replacement for the upstream agent-monitor - * App router. Copied verbatim over `src/App.tsx` at build time by - * scripts/build-agent-monitor.mjs. - * - * This keeps our route contract explicit in-repo: we layer host-owned - * additions such as the gated Plans page on top of the pinned upstream base - * instead of editing the dependency in place. - */ - -import { BrowserRouter, Routes, Route } from "react-router-dom"; -import { useCallback } from "react"; -import { Layout } from "./components/Layout"; -import { Dashboard } from "./pages/Dashboard"; -import { KanbanBoard } from "./pages/KanbanBoard"; -import { Sessions } from "./pages/Sessions"; -import { SessionDetail } from "./pages/SessionDetail"; -import { ActivityFeed } from "./pages/ActivityFeed"; -import { Analytics } from "./pages/Analytics"; -import { Workflows } from "./pages/Workflows"; -import { Settings } from "./pages/Settings"; -import { CcConfig } from "./pages/CcConfig"; -import { Run } from "./pages/Run"; -import { Plans } from "./pages/Plans"; -import { Packs } from "./pages/Packs"; -import { PackDetail } from "./pages/PackDetail"; -import { PullRequests } from "./pages/PullRequests"; -import { NotFound } from "./pages/NotFound"; -import { isPlanExtractionEnabled } from "./lib/closedloop-host-flags"; -import { useWebSocket } from "./hooks/useWebSocket"; -import { useNotifications } from "./hooks/useNotifications"; -import { eventBus } from "./lib/eventBus"; -import type { WSMessage } from "./lib/types"; - -export default function App() { - const onMessage = useCallback((msg: WSMessage) => { - eventBus.publish(msg); - }, []); - - const { connected } = useWebSocket(onMessage); - useNotifications(); - - return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - : } /> - } /> - } /> - } /> - } /> - } /> - - - - ); -} diff --git a/apps/desktop/scripts/agent-monitor-embed/Layout.tsx b/apps/desktop/scripts/agent-monitor-embed/Layout.tsx deleted file mode 100644 index 93b48896..00000000 --- a/apps/desktop/scripts/agent-monitor-embed/Layout.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @file Layout.tsx - * @description ClosedLoop-authored replacement for the upstream agent-monitor - * Layout. Copied verbatim over `src/components/Layout.tsx` at build time by - * scripts/build-agent-monitor.mjs. - * - * The agent monitor ships embedded as an - -
- - -
- - - - - - -
- - -
-
-

Connection Status

-

Live gateway port, cloud connection, and endpoint configuration.

-
-
-
Gateway Port
-
Loading...
-
-
-
Cloud Connection
-
Loading...
-
Loading...
-
-
-
Remote Commands
-
Loading...
-
-
-
Connection Security
-
Loading...
-
Loading...
-
-
- -
-
-
Target ID
-
Loading...
-
-
-
Relay Origin
-
Loading...
-
-
-
API Origin
-
Loading...
-
-
-
-
- -
-
-
-
-

Cloud Relay

-

WebSocket relay for cloud command mode.

-
- - -
-
- - -
- - -
-

-
- -
- -
-

Local Gateway

-

REST API and browser origin for local mode.

-
- - -
-
- - -
-
- -
- -

Clears the dashboard database and re-imports every agent session from scratch. The progress bar at the top tracks the re-import.

-

-
-
-
-
- -
-

Saved Configs

-

Save the current origins and API key as a named profile, then switch between profiles instantly.

-
- - -
-
-
-
- - -
- - -
- -
-
-

Sandbox

-

Not set

-

Choose a base directory below.

-
-
-

Signing Keys

-

--

-

Loading authorization state...

-
-
-

Always Denied

-

7 paths

-

Built-in protections that override your sandbox.

-
-
- - -
-
-
-

Perimeter

-

Sandbox Directory

-

Gateway operations can only read or write inside this directory. Anything outside — or inside the always-denied list — is blocked.

-
-
- -
-
-

Allowed Root

-

- No directory selected -

- -
-
- -
-

- -
-

Always Denied

-
- ~/.ssh - ~/.gnupg - ~/.aws - ~/Library/Keychains - /etc - /bin - /sbin -
-
-
-
- - - - - -
- - -
-
-

Command-Line Tools

-

ClosedLoop runs these tools on your behalf. They're located automatically on your system. If a tool is missing or the wrong version is being used, set a custom path below.

-
-
-
-
-
- Claude Code -
- Checking -
-
- -
- - -
-
-

-
-
-
-
- GitHub CLI -
- Checking -
-
- -
- - -
-
-

-
-
-
-
- Codex CLI -
- Checking -
-
- -
- - -
-
-

-
-
-
-
- Python 3 -
- Checking -
-
- -
- - -
-
-

-
-
-
-
- Git -
- Checking -
-
- -
- - -
-
-

-
-
-
- - -
-
-

Approval Policy

-

Controls when gateway operations require manual approval.

-
- - -
-
- -

Override the default tier for specific operations.

-
-
- - - -
- -
-
- -
-

Always-Allow Rules

-

Temporary bypass rules created when you click "Always Allow" on a pending request. Each rule matches the exact operation, method, path, and scope, and expires after 7 days.

-

None -- click "Always Allow" on a pending request to add rules here.

-
- -
- - -
-
-

Billing Admin Keys

-

- Organization Admin keys let ClosedLoop fetch what each vendor actually billed and - reconcile it against the local estimate. Keys are stored in your OS keychain and never - leave this device's main process — the dashboard only ever sees whether a key - exists, never the key itself. Saving verifies the key with a single billing-API call. -

- -
- - -
- - -
-

-
- -
- - -
- - -
-

-
-
- -
-

Drift Diagnostics

-

- Each row compares the local token-based estimate against the vendor's billed amount for - a day and model. Drift is informational — ClosedLoop never re-prices past sessions. - A positive drift means the local estimate ran higher than the vendor bill; negative - means lower. Use “Explain” on a flagged row for the most likely cause. -

- -
- - - - -
-

- -
- - - - - - - - - - - - - - - -
DayVendorModelLocalVendorDrift
No reconciliation data yet.
-
-
- -
-

Claude Code Usage (Anthropic estimate)

-

- Per-user Claude Code spend over the last 7 days, as reported by Anthropic's own - usage report. This is Anthropic's estimate, shown for reference - — it never replaces the local token-based ledger. Requires an Anthropic Admin - key above and a Team or Enterprise organization. -

- -
- - -
-

- -
- - - - - - - - - - - - - - -
UserTypeModelsInputOutputEst. cost
No Claude Code usage loaded yet.
-
-
- - -
- - -
-
-

Labs

-

Early access to experimental features and advanced controls. Flip a switch, see what happens.

-
-
- -
-
-
- -
- - - diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index d89165c6..d9b44590 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -1,8 +1,8 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { DesignSystemProvider } from "@closedloop-ai/design-system"; -import "./globals.css"; import "@closedloop-ai/design-system/styles/globals.css"; +import "./globals.css"; import App from "./App"; window.addEventListener("error", (event) => { diff --git a/apps/desktop/src/renderer/types/desktop-api.d.ts b/apps/desktop/src/renderer/types/desktop-api.d.ts index 92e157af..a3a83da0 100644 --- a/apps/desktop/src/renderer/types/desktop-api.d.ts +++ b/apps/desktop/src/renderer/types/desktop-api.d.ts @@ -9,10 +9,30 @@ import type { SessionPageRequest, SessionWithAgents, DashboardSummary, + DashboardCoreFeatures, + DashboardPackSummary, + DashboardPlanSummary, + DashboardPullRequestSummary, + DashboardSkillSummary, + DashboardSubAgentSummary, + DashboardToolSummary, TokenAnalytics, AnalyticsData, WorkflowQueryData, AgentHierarchyNode, + CatalogEntry, + CatalogMutationResult, + InstallOutputChunk, + InstallRunRecord, + InstalledPack, + InstalledPackDetail, + SkillWithInvocations, + SkillInvocation, + PlanRecord, + PlanVersionRecord, + PrStats, + PrSessionGroup, + PrRecord, } from "../../shared/agent-db-contract"; export interface AgentMonitorUrl { @@ -101,7 +121,7 @@ export interface DesktopApi { setAgentMonitorHooksEnabled: (enabled: boolean) => Promise; getAgentMonitorCodexHooksOptIn: () => Promise; setAgentMonitorCodexHooksOptIn: (optIn: boolean) => Promise; - /** @deprecated Replaced by in-process SQLite database */ + /** @deprecated Replaced by in-process dashboard database */ getAgentMonitorData?: (query: string) => Promise; /** Database IPC channels (typed against the in-process repository shapes). */ db: { @@ -121,9 +141,51 @@ export interface DesktopApi { getAgentHierarchy: (sessionId: string) => Promise; getAnalytics: () => Promise; getWorkflowData: () => Promise; + getCoreFeatures: () => Promise; + getPacks: () => Promise; + getSkills: () => Promise; + getTools: () => Promise; + getSubAgents: () => Promise; + getPlans: () => Promise; + getPullRequests: () => Promise; + + // Catalog (FEA-1314) + getCatalog: () => Promise; + getCatalogEntry: (packId: string) => Promise; + getCatalogReadme: (packId: string) => Promise; + getCatalogContents: (packId: string) => Promise; + getCatalogHistory: (packId: string) => Promise>; + catalogInstall: (packId: string, harness: string, cwd?: string) => Promise; + catalogUninstall: (packId: string, harness: string, cwd?: string) => Promise; + catalogRefresh: () => Promise; + getInstallRuns: (packId?: string) => Promise; + + // Installed packs (FEA-1224) + getInstalledPacks: () => Promise; + getPackDetail: (packId: string) => Promise; + getPackSessions: (packId: string) => Promise; + getAllSkills: () => Promise; + getSkillInvocations: (name: string) => Promise; + getRecentProjects: () => Promise; + + // Plans (FEA-1189) + getPlansList: (opts?: { sessionId?: string; needsConfirmation?: boolean; limit?: number; offset?: number }) => Promise; + getPlan: (id: string) => Promise; + getPlanVersions: (planId: string) => Promise; + confirmPlan: (id: string) => Promise; + rejectPlan: (id: string) => Promise; + openPlan: (id: string, target?: string) => Promise; + + // Pull Requests (FEA-1226) + getPrStats: () => Promise; + getPrSessions: (opts?: { limit?: number; offset?: number }) => Promise; + getPrList: (opts?: { sessionId?: string; repo?: string; limit?: number; offset?: number }) => Promise; + openPr: (id: string) => Promise; }; /** Live DB-change push subscription; returns an unsubscribe fn. */ onDbChanged: (callback: (payload: { sessionId?: string }) => void) => () => void; + /** Subscribe to streamed pack install/uninstall output (FEA-1314). */ + onInstallOutput?: (callback: (payload: InstallOutputChunk) => void) => () => void; } declare global { diff --git a/apps/desktop/src/server/operations/symphony-loop.ts b/apps/desktop/src/server/operations/symphony-loop.ts index 6db838a0..cff4315c 100644 --- a/apps/desktop/src/server/operations/symphony-loop.ts +++ b/apps/desktop/src/server/operations/symphony-loop.ts @@ -213,7 +213,12 @@ export function getOverrideBinaryPaths(): BinaryPathOverrides | null { } export function getResolvedGitPath(): string { - return resolveBinaryFromLoginShellSync("git", getOverrideBinaryPaths()?.git).path; + const override = getOverrideBinaryPaths()?.git; + const resolved = resolveBinaryFromLoginShellSync("git", override); + if (resolved.source !== "override_invalid") { + return resolved.path; + } + return resolveBinaryFromLoginShellSync("git").path; } export function getResolvedGhPath(): string { @@ -1990,7 +1995,12 @@ async function removeWorktreeImpl( `git worktree remove failed for GENERATE_PRD, falling back to fs.rm`, ); } - await fs.rm(worktreeDir, { recursive: true, force: true }); + await fs.rm(worktreeDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); try { execSync(`${shellEscape(gitBin)} worktree prune`, { cwd: expandedRepoPath, diff --git a/apps/desktop/src/server/shell-path.ts b/apps/desktop/src/server/shell-path.ts index 24f1029f..73d50a81 100644 --- a/apps/desktop/src/server/shell-path.ts +++ b/apps/desktop/src/server/shell-path.ts @@ -350,7 +350,15 @@ function resolveExecutablesOnPathSync( return hits; } -export type BinaryName = "claude" | "gh" | "codex" | "python3" | "git"; +export type BinaryName = + | "claude" + | "gh" + | "codex" + | "python3" + | "git" + | "rtk" + | "npm" + | "ccr"; export type BinaryResolveResult = { path: string; diff --git a/apps/desktop/src/shared/agent-db-contract.ts b/apps/desktop/src/shared/agent-db-contract.ts index bc7f7d59..f9b2099a 100644 --- a/apps/desktop/src/shared/agent-db-contract.ts +++ b/apps/desktop/src/shared/agent-db-contract.ts @@ -5,12 +5,10 @@ // shared contract between main and renderer — the renderer MUST import its DB // response shapes from here, NOT from `src/main/database/types.ts`. // -// Raw persistence rows (the untyped `Record` produced by -// `node:sqlite`) are private to `src/main/database/`. Each repository store maps -// those raw rows into the DTOs below via its `toRow()` helper, so a SQLite -// schema/column change is absorbed at that boundary and does not break the -// renderer's compile-time contract. Purely-internal row types that never cross -// IPC (e.g. `TokenUsageRow`) stay in `src/main/database/types.ts`. +// Raw persistence rows are private to `src/main/database/`. Each repository +// store maps those raw rows into the DTOs below, so a schema/column change is +// absorbed at that boundary and does not break the renderer's compile-time +// contract. Purely-internal row types that never cross IPC stay in main. export interface SessionRow { id: string; @@ -212,3 +210,267 @@ export interface AgentHierarchyNode { createdAt: string | null; }>; } + +export interface DashboardPackSummary { + id: string; + name: string; + harness: string; + installPath: string | null; + sourceUrl: string | null; + version: string | null; + skillCount: number; + toolCallCount: number; + lastUsedAt: string | null; +} + +export interface DashboardSkillSummary { + id: string; + packId: string | null; + name: string; + harness: string; + description: string | null; + installPath: string | null; + invocationCount: number; + lastUsedAt: string | null; +} + +export interface DashboardToolSummary { + toolName: string; + invocationCount: number; + sessionCount: number; + lastUsedAt: string | null; +} + +export interface DashboardSubAgentSummary { + subagentType: string; + total: number; + completed: number; + errors: number; + sessions: number; + lastUsedAt: string | null; +} + +export interface DashboardPlanSummary { + id: string; + sessionId: string | null; + title: string; + source: string | null; + content: string; + timestamp: string | null; + harness: string | null; + cwd: string | null; +} + +export interface DashboardPullRequestSummary { + id: string; + sessionId: string | null; + sessionName: string | null; + prUrl: string; + prNumber: number; + repoFullName: string; + branchName: string | null; + headSha: string | null; + title: string | null; + harness: string | null; + observedAt: string | null; +} + +export interface DashboardCoreFeatures { + packs: DashboardPackSummary[]; + skills: DashboardSkillSummary[]; + tools: DashboardToolSummary[]; + subagents: DashboardSubAgentSummary[]; + plans: DashboardPlanSummary[]; + pullRequests: DashboardPullRequestSummary[]; +} + +// --- Catalog (FEA-1314) --- + +export interface CatalogEntry { + packId: string; + displayName: string; + category: string | null; + githubUrl: string; + marketplaceUrl: string | null; + description: string | null; + descriptionLive: string | null; + harnesses: string[]; + installCommands: Record | null; + uninstallCommands: Record | null; + installNotes: string | null; + placeholderReason: string | null; + verified: boolean; + readmeExcerpt: string | null; + stars: number | null; + forks: number | null; + lastRelease: string | null; + seedVersion: number; + pinOrder: number | null; + contents: CatalogContentsConfig | null; + contentsCache: CatalogContentItem[] | null; + detectionPatterns: string[] | null; + harnessAgnostic: boolean; + projectScoped: boolean; + singleInstall: boolean; + postInstall: Record | null; + // Joined from agent_packs + installedHarnesses: string[]; + skillCount: number; + usageCount: number; + // Sparkline data + history: Array<{ fetchedAt: string; stars: number; forks: number }>; +} + +export interface CatalogContentsConfig { + type: string; + [key: string]: unknown; +} + +export interface CatalogContentItem { + name: string; + type: string; + description?: string; + path?: string; +} + +export interface InstallRunRecord { + id: number; + packId: string; + harness: string | null; + action: string; + command: string | null; + exitCode: number | null; + startedAt: string; + endedAt: string | null; + stdoutTail: string | null; + stderrTail: string | null; +} + +export interface CatalogMutationResult { + started: boolean; + runId?: number; + error?: { + code: string; + message: string; + }; +} + +export interface InstallOutputChunk { + runId: number; + type: "start" | "stdout" | "stderr" | "error" | "post_install" | "copy_command" | "complete"; + data: unknown; +} + +// --- Installed Packs (FEA-1224) --- + +export interface InstalledPack { + packId: string; + harnesses: string[]; + installs: Array<{ + harness: string; + installPath: string; + installKind: string | null; + sourceUrl: string | null; + version: string | null; + detectedAt: string | null; + lastSeenAt: string | null; + }>; + skillCount: number; + lastSeenAt: string | null; +} + +export interface InstalledPackDetail extends InstalledPack { + skills: Array<{ + skillId: string; + name: string | null; + version: string | null; + description: string | null; + harness: string | null; + }>; + associations: Array<{ + projectPath: string; + detectedAt: string | null; + lastSeenAt: string | null; + }>; +} + +export interface SkillWithInvocations { + skillId: string; + packId: string | null; + name: string; + harness: string | null; + description: string | null; + invocationCount: number; + lastUsedAt: string | null; +} + +export interface SkillInvocation { + eventId: string; + sessionId: string; + sessionName: string | null; + harness: string | null; + model: string | null; + createdAt: string | null; +} + +// --- Plans (FEA-1189) --- + +export interface PlanRecord { + id: string; + title: string | null; + status: string; + source: string | null; + captureMethod: string | null; + harness: string | null; + sessionId: string | null; + filePath: string | null; + sourceLogPath: string | null; + needsConfirmation: boolean; + confidence: number; + createdAt: string | null; + updatedAt: string | null; + latestContent: string | null; + versionCount: number; +} + +export interface PlanVersionRecord { + id: string; + planId: string; + versionNumber: number; + contentMarkdown: string | null; + contentSha256: string | null; + authorType: string | null; + captureMethod: string | null; + createdAt: string | null; +} + +// --- Pull Requests (FEA-1226) --- + +export interface PrRecord { + id: string; + sessionId: string | null; + prUrl: string; + prNumber: number | null; + repoFullName: string | null; + branchName: string | null; + headSha: string | null; + title: string | null; + harness: string | null; + observedAt: string | null; + createdAt: string | null; +} + +export interface PrStats { + totalPrs: number; + sessionsWithPrs: number; + repos: number; +} + +export interface PrSessionGroup { + sessionId: string; + sessionName: string | null; + cwd: string | null; + harness: string | null; + startedAt: string | null; + prs: PrRecord[]; +} diff --git a/apps/desktop/src/shared/contracts.ts b/apps/desktop/src/shared/contracts.ts index 56140014..edfd3efd 100644 --- a/apps/desktop/src/shared/contracts.ts +++ b/apps/desktop/src/shared/contracts.ts @@ -3,14 +3,7 @@ export const FALLBACK_GATEWAY_PORTS = [19433, 19434, 19435] as const; export const PORT_PROBE_ORDER = [DEFAULT_GATEWAY_PORT, ...FALLBACK_GATEWAY_PORTS] as const; export const GATEWAY_PROTOCOL_VERSION = "0.1.0"; -/** - * Fixed loopback port for the generated Agent Monitor sidecar. It MUST be fixed - * (not an ephemeral free port like the gateway) because Claude Code hooks bake - * a port at install time and the hook handler POSTs to - * `127.0.0.1:${CLAUDE_DASHBOARD_PORT || 4820}` — 4820 is upstream's own default, - * so hooks work with zero per-hook env. Outside PORT_PROBE_ORDER, so it never - * collides with the gateway's port selection. - */ +/** Fixed loopback port for the local Agent Dashboard hook listener. */ export const AGENT_MONITOR_PORT = 4820; export const COMMAND_SIGNING_REJECTION_REASONS = { @@ -161,10 +154,8 @@ export interface DesktopSettings { dashboardWelcomeSeen: boolean; cloudCommandsPaused: boolean; cloudConnectionEnabled: boolean; - /** Enables the legacy sidecar-backed Agent Dashboard experience. On by default. */ + /** Enables the first-party PGlite-backed Agent Dashboard experience. On by default. */ agentMonitorEnabled: boolean; - /** Opts into the in-process design-system Agent Dashboard. Labs-only and off by default. */ - agentDashboardDesignSystemEnabled: boolean; /** Host-owned opt-in for Plans / plan extraction UI in the embedded Agent Dashboard. */ planExtractionEnabled: boolean; /** Desktop-local opt-in that requires trusted browser command signatures. */ @@ -184,8 +175,6 @@ export interface DesktopSettings { savedConfigs: SavedConfig[]; activeConfigId: string | null; updateAndRestartEnabled: boolean; - /** Splits oversized agent sessions into chunked batches for sync. Requires relay support. */ - agentSessionChunkedSyncEnabled: boolean; /** * ISO timestamp when the user last dismissed the managed-key revival hint * (D5 / AC-010). Null means never dismissed. @@ -210,7 +199,6 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { cloudCommandsPaused: false, cloudConnectionEnabled: true, agentMonitorEnabled: true, - agentDashboardDesignSystemEnabled: false, planExtractionEnabled: false, commandSigningEnforcementEnabled: false, defaultApprovalTier: "high", @@ -222,7 +210,6 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { savedConfigs: [], activeConfigId: null, updateAndRestartEnabled: false, - agentSessionChunkedSyncEnabled: false, managedKeyHintDismissedAt: null, managedKeyHintLastSeenProvenance: null, }; diff --git a/apps/desktop/src/shared/feature-flags.ts b/apps/desktop/src/shared/feature-flags.ts index 4f217ef1..9973e17e 100644 --- a/apps/desktop/src/shared/feature-flags.ts +++ b/apps/desktop/src/shared/feature-flags.ts @@ -25,19 +25,10 @@ const FEATURE_FLAGS_INTERNAL = [ default: true, label: "Agent Dashboard", description: - "Runs the local Agent Dashboard sidecar that powers the Dashboard and agent views in the sidebar.", + "Runs the local PGlite-backed Agent Dashboard that powers the Dashboard and agent views in the sidebar.", category: "Diagnostics" as const, requiresRestart: true, }, - { - key: "agentDashboardDesignSystemEnabled" as const, - default: false, - label: "Agent Dashboard Design System", - description: - "Use the in-process design-system Agent Dashboard instead of the legacy sidecar dashboard.", - category: "Labs" as const, - requiresRestart: true, - }, { key: "planExtractionEnabled" as const, default: false, @@ -78,14 +69,6 @@ const FEATURE_FLAGS_INTERNAL = [ "Automatically download and install updates, then restart the app.", category: "Experimental" as const, }, - { - key: "agentSessionChunkedSyncEnabled" as const, - default: false, - label: "Chunked Session Sync", - description: - "Splits oversized agent sessions into multiple smaller batches for sync. Enable after the relay supports chunked ingestion.", - category: "Experimental" as const, - }, ] as const; export type FlagKey = (typeof FEATURE_FLAGS_INTERNAL)[number]["key"]; diff --git a/apps/desktop/test-e2e/agent-monitor/fixtures/agent_packs.json b/apps/desktop/test-e2e/agent-monitor/fixtures/agent_packs.json deleted file mode 100644 index 103e465e..00000000 --- a/apps/desktop/test-e2e/agent-monitor/fixtures/agent_packs.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "pack_id": "fixture-pack-alpha", - "harness": "claude", - "install_path": "/tmp/fixture-claude/plugins/alpha", - "install_kind": "directory", - "source_url": "https://github.com/example/fixture-alpha", - "version": "1.0.0", - "detected_at": "2026-05-10T00:00:00.000Z", - "last_seen_at": "2026-05-22T00:00:00.000Z", - "uninstalled_at": null - }, - { - "pack_id": "fixture-pack-beta", - "harness": "claude", - "install_path": "/tmp/fixture-claude/plugins/beta", - "install_kind": "symlink", - "source_url": "https://github.com/example/fixture-beta", - "version": "2.3.1", - "detected_at": "2026-05-11T00:00:00.000Z", - "last_seen_at": "2026-05-22T00:00:00.000Z", - "uninstalled_at": null - }, - { - "pack_id": "fixture-pack-beta", - "harness": "codex", - "install_path": "/tmp/fixture-codex/plugins/beta", - "install_kind": "directory", - "source_url": "https://github.com/example/fixture-beta", - "version": "2.3.1", - "detected_at": "2026-05-12T00:00:00.000Z", - "last_seen_at": "2026-05-22T00:00:00.000Z", - "uninstalled_at": null - }, - { - "pack_id": "fixture-pack-gamma", - "harness": "claude", - "install_path": "/tmp/fixture-claude/plugins/gamma", - "install_kind": "directory", - "source_url": null, - "version": null, - "detected_at": "2026-05-13T00:00:00.000Z", - "last_seen_at": "2026-05-22T00:00:00.000Z", - "uninstalled_at": null - } -] diff --git a/apps/desktop/test-e2e/agent-monitor/fixtures/agents.json b/apps/desktop/test-e2e/agent-monitor/fixtures/agents.json deleted file mode 100644 index 7295bfac..00000000 --- a/apps/desktop/test-e2e/agent-monitor/fixtures/agents.json +++ /dev/null @@ -1,98 +0,0 @@ -[ - { - "id": "fixture-sess-active-1-main", - "session_id": "fixture-sess-active-1", - "name": "Main Agent — Fixture Active 1", - "type": "main", - "subagent_type": null, - "status": "working", - "task": null, - "current_tool": "Bash", - "started_at": "2026-05-20T10:00:00.000Z", - "ended_at": null, - "parent_agent_id": null, - "metadata": null, - "updated_at": "2026-05-20T10:30:00.000Z", - "awaiting_input_since": null - }, - { - "id": "fixture-sess-active-2-main", - "session_id": "fixture-sess-active-2", - "name": "Main Agent — Fixture Active 2", - "type": "main", - "subagent_type": null, - "status": "working", - "task": null, - "current_tool": "Read", - "started_at": "2026-05-21T11:00:00.000Z", - "ended_at": null, - "parent_agent_id": null, - "metadata": null, - "updated_at": "2026-05-21T11:45:00.000Z", - "awaiting_input_since": null - }, - { - "id": "fixture-sess-completed-1-main", - "session_id": "fixture-sess-completed-1", - "name": "Main Agent — Fixture Completed 1", - "type": "main", - "subagent_type": null, - "status": "completed", - "task": null, - "current_tool": null, - "started_at": "2026-05-15T08:00:00.000Z", - "ended_at": "2026-05-15T09:30:00.000Z", - "parent_agent_id": null, - "metadata": null, - "updated_at": "2026-05-15T09:30:00.000Z", - "awaiting_input_since": null - }, - { - "id": "fixture-sess-completed-1-sub-1", - "session_id": "fixture-sess-completed-1", - "name": "Explorer Subagent", - "type": "subagent", - "subagent_type": "Explore", - "status": "completed", - "task": "Find auth code", - "current_tool": null, - "started_at": "2026-05-15T08:05:00.000Z", - "ended_at": "2026-05-15T08:10:00.000Z", - "parent_agent_id": "fixture-sess-completed-1-main", - "metadata": null, - "updated_at": "2026-05-15T08:10:00.000Z", - "awaiting_input_since": null - }, - { - "id": "fixture-sess-completed-2-main", - "session_id": "fixture-sess-completed-2", - "name": "Main Agent — Fixture Completed 2", - "type": "main", - "subagent_type": null, - "status": "completed", - "task": null, - "current_tool": null, - "started_at": "2026-05-16T08:00:00.000Z", - "ended_at": "2026-05-16T08:15:00.000Z", - "parent_agent_id": null, - "metadata": null, - "updated_at": "2026-05-16T08:15:00.000Z", - "awaiting_input_since": null - }, - { - "id": "fixture-sess-error-1-main", - "session_id": "fixture-sess-error-1", - "name": "Main Agent — Fixture Error", - "type": "main", - "subagent_type": null, - "status": "error", - "task": null, - "current_tool": null, - "started_at": "2026-05-17T08:00:00.000Z", - "ended_at": "2026-05-17T08:05:00.000Z", - "parent_agent_id": null, - "metadata": null, - "updated_at": "2026-05-17T08:05:00.000Z", - "awaiting_input_since": null - } -] diff --git a/apps/desktop/test-e2e/agent-monitor/fixtures/events.json b/apps/desktop/test-e2e/agent-monitor/fixtures/events.json deleted file mode 100644 index 5e35cb5f..00000000 --- a/apps/desktop/test-e2e/agent-monitor/fixtures/events.json +++ /dev/null @@ -1,74 +0,0 @@ -[ - { - "session_id": "fixture-sess-active-1", - "agent_id": "fixture-sess-active-1-main", - "event_type": "PreToolUse", - "tool_name": "Bash", - "summary": null, - "data": "{\"command\":\"ls\"}", - "created_at": "2026-05-20T10:01:00.000Z" - }, - { - "session_id": "fixture-sess-active-1", - "agent_id": "fixture-sess-active-1-main", - "event_type": "PostToolUse", - "tool_name": "Bash", - "summary": null, - "data": "{\"command\":\"ls\",\"exit_code\":0}", - "created_at": "2026-05-20T10:01:01.000Z" - }, - { - "session_id": "fixture-sess-active-1", - "agent_id": "fixture-sess-active-1-main", - "event_type": "UserPromptSubmit", - "tool_name": null, - "summary": null, - "data": "{\"prompt\":\"/review the diff please\"}", - "created_at": "2026-05-20T10:02:00.000Z" - }, - { - "session_id": "fixture-sess-active-2", - "agent_id": "fixture-sess-active-2-main", - "event_type": "PreToolUse", - "tool_name": "Read", - "summary": null, - "data": "{\"file_path\":\"/tmp/x\"}", - "created_at": "2026-05-21T11:01:00.000Z" - }, - { - "session_id": "fixture-sess-completed-1", - "agent_id": "fixture-sess-completed-1-main", - "event_type": "PreToolUse", - "tool_name": "Edit", - "summary": null, - "data": "{\"file_path\":\"/tmp/y\"}", - "created_at": "2026-05-15T08:30:00.000Z" - }, - { - "session_id": "fixture-sess-completed-1", - "agent_id": "fixture-sess-completed-1-main", - "event_type": "UserPromptSubmit", - "tool_name": null, - "summary": null, - "data": "{\"prompt\":\"/ship to main\"}", - "created_at": "2026-05-15T09:00:00.000Z" - }, - { - "session_id": "fixture-sess-completed-2", - "agent_id": "fixture-sess-completed-2-main", - "event_type": "PreToolUse", - "tool_name": "Bash", - "summary": null, - "data": "{\"command\":\"git status\"}", - "created_at": "2026-05-16T08:05:00.000Z" - }, - { - "session_id": "fixture-sess-error-1", - "agent_id": "fixture-sess-error-1-main", - "event_type": "Error", - "tool_name": null, - "summary": "session crashed", - "data": "{\"error\":\"sample\"}", - "created_at": "2026-05-17T08:04:00.000Z" - } -] diff --git a/apps/desktop/test-e2e/agent-monitor/fixtures/model_pricing.json b/apps/desktop/test-e2e/agent-monitor/fixtures/model_pricing.json deleted file mode 100644 index 0427751c..00000000 --- a/apps/desktop/test-e2e/agent-monitor/fixtures/model_pricing.json +++ /dev/null @@ -1,29 +0,0 @@ -[ - { - "model_pattern": "claude-opus-4-7", - "display_name": "Claude Opus 4.7", - "input_per_mtok": 15, - "output_per_mtok": 75, - "cache_read_per_mtok": 1.5, - "cache_write_per_mtok": 18.75, - "updated_at": "2026-05-01T00:00:00.000Z" - }, - { - "model_pattern": "claude-sonnet-4-6", - "display_name": "Claude Sonnet 4.6", - "input_per_mtok": 3, - "output_per_mtok": 15, - "cache_read_per_mtok": 0.3, - "cache_write_per_mtok": 3.75, - "updated_at": "2026-05-01T00:00:00.000Z" - }, - { - "model_pattern": "claude-haiku-4-5", - "display_name": "Claude Haiku 4.5", - "input_per_mtok": 1, - "output_per_mtok": 5, - "cache_read_per_mtok": 0.1, - "cache_write_per_mtok": 1.25, - "updated_at": "2026-05-01T00:00:00.000Z" - } -] diff --git a/apps/desktop/test-e2e/agent-monitor/fixtures/pack_catalog.json b/apps/desktop/test-e2e/agent-monitor/fixtures/pack_catalog.json deleted file mode 100644 index 33e3ffa4..00000000 --- a/apps/desktop/test-e2e/agent-monitor/fixtures/pack_catalog.json +++ /dev/null @@ -1,130 +0,0 @@ -[ - { - "pack_id": "fixture-pack-alpha", - "display_name": "Fixture Pack Alpha", - "category": "framework", - "github_url": "https://github.com/example/fixture-alpha", - "description": "Deterministic alpha pack used by e2e tests.", - "description_live": null, - "harnesses": "claude", - "install_commands": "{\"claude\":\"echo install alpha\"}", - "uninstall_commands": "{\"claude\":\"echo uninstall alpha\"}", - "install_notes": null, - "placeholder_reason": null, - "verified": 1, - "readme_excerpt": null, - "readme_fetched_at": null, - "stars": 100, - "forks": 10, - "last_release": null, - "last_fetched_at": "2026-05-22T00:00:00.000Z", - "seed_version": 1, - "pin_order": 1, - "contents": null, - "contents_cache": null, - "contents_fetched_at": null, - "upstream_github_url": null, - "marketplace_url": null, - "detection_patterns": null, - "harness_agnostic": 0, - "project_scoped": 0, - "single_install": 0, - "post_install": null - }, - { - "pack_id": "fixture-pack-beta", - "display_name": "Fixture Pack Beta", - "category": "tooling", - "github_url": "https://github.com/example/fixture-beta", - "description": "Deterministic beta pack with multi-harness install.", - "description_live": null, - "harnesses": "claude,codex", - "install_commands": "{\"claude\":\"echo install beta\",\"codex\":\"echo install beta codex\"}", - "uninstall_commands": "{\"claude\":\"echo uninstall beta\"}", - "install_notes": null, - "placeholder_reason": null, - "verified": 1, - "readme_excerpt": null, - "readme_fetched_at": null, - "stars": 2500, - "forks": 200, - "last_release": "v2.3.1", - "last_fetched_at": "2026-05-22T00:00:00.000Z", - "seed_version": 1, - "pin_order": 2, - "contents": null, - "contents_cache": null, - "contents_fetched_at": null, - "upstream_github_url": null, - "marketplace_url": null, - "detection_patterns": null, - "harness_agnostic": 0, - "project_scoped": 0, - "single_install": 0, - "post_install": null - }, - { - "pack_id": "fixture-pack-gamma", - "display_name": "Fixture Pack Gamma", - "category": "framework", - "github_url": "https://github.com/example/fixture-gamma", - "description": "Pack with no skills, used to assert zero-skill rendering.", - "description_live": null, - "harnesses": "claude", - "install_commands": "{\"claude\":\"echo install gamma\"}", - "uninstall_commands": null, - "install_notes": null, - "placeholder_reason": null, - "verified": 1, - "readme_excerpt": null, - "readme_fetched_at": null, - "stars": 50, - "forks": 0, - "last_release": null, - "last_fetched_at": "2026-05-22T00:00:00.000Z", - "seed_version": 1, - "pin_order": 3, - "contents": null, - "contents_cache": null, - "contents_fetched_at": null, - "upstream_github_url": null, - "marketplace_url": null, - "detection_patterns": null, - "harness_agnostic": 0, - "project_scoped": 0, - "single_install": 0, - "post_install": null - }, - { - "pack_id": "fixture-pack-delta-uninstalled", - "display_name": "Fixture Pack Delta", - "category": "tooling", - "github_url": "https://github.com/example/fixture-delta", - "description": "Catalog-only pack, not installed locally.", - "description_live": null, - "harnesses": "claude", - "install_commands": "{\"claude\":\"echo install delta\"}", - "uninstall_commands": null, - "install_notes": null, - "placeholder_reason": null, - "verified": 1, - "readme_excerpt": null, - "readme_fetched_at": null, - "stars": 7, - "forks": 0, - "last_release": null, - "last_fetched_at": "2026-05-22T00:00:00.000Z", - "seed_version": 1, - "pin_order": 4, - "contents": null, - "contents_cache": null, - "contents_fetched_at": null, - "upstream_github_url": null, - "marketplace_url": null, - "detection_patterns": null, - "harness_agnostic": 0, - "project_scoped": 0, - "single_install": 0, - "post_install": null - } -] diff --git a/apps/desktop/test-e2e/agent-monitor/fixtures/schema.sql b/apps/desktop/test-e2e/agent-monitor/fixtures/schema.sql deleted file mode 100644 index b9fd9355..00000000 --- a/apps/desktop/test-e2e/agent-monitor/fixtures/schema.sql +++ /dev/null @@ -1,238 +0,0 @@ -CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - name TEXT, - status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','completed','error','abandoned')), - cwd TEXT, - model TEXT, - started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - ended_at TEXT, - metadata TEXT - , updated_at TEXT NOT NULL DEFAULT '', awaiting_input_since TEXT, harness TEXT NOT NULL DEFAULT 'claude'); -CREATE TABLE agents ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'main' CHECK(type IN ('main','subagent')), - subagent_type TEXT, - status TEXT NOT NULL DEFAULT 'waiting' CHECK(status IN ('working','waiting','completed','error')), - task TEXT, - current_tool TEXT, - started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - ended_at TEXT, - parent_agent_id TEXT, - metadata TEXT, updated_at TEXT NOT NULL DEFAULT '', awaiting_input_since TEXT, - FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, - FOREIGN KEY (parent_agent_id) REFERENCES agents(id) ON DELETE SET NULL - ); -CREATE TABLE events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - agent_id TEXT, - event_type TEXT NOT NULL, - tool_name TEXT, - summary TEXT, - data TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, - FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL - ); -CREATE TABLE token_usage ( - session_id TEXT NOT NULL, - model TEXT NOT NULL DEFAULT 'unknown', - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cache_write_tokens INTEGER NOT NULL DEFAULT 0, baseline_input INTEGER NOT NULL DEFAULT 0, baseline_output INTEGER NOT NULL DEFAULT 0, baseline_cache_read INTEGER NOT NULL DEFAULT 0, baseline_cache_write INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (session_id, model), - FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE - ); -CREATE TABLE model_pricing ( - model_pattern TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - input_per_mtok REAL NOT NULL DEFAULT 0, - output_per_mtok REAL NOT NULL DEFAULT 0, - cache_read_per_mtok REAL NOT NULL DEFAULT 0, - cache_write_per_mtok REAL NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ); -CREATE TABLE push_subscriptions ( - endpoint TEXT PRIMARY KEY, - p256dh TEXT NOT NULL, - auth TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ); -CREATE TABLE dashboard_runs ( - id TEXT PRIMARY KEY, - session_id TEXT, - mode TEXT NOT NULL, - cwd TEXT NOT NULL, - model TEXT, - permission_mode TEXT, - effort TEXT, - resume_session_id TEXT, - prompt_preview TEXT, - status TEXT NOT NULL, - exit_code INTEGER, - started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - ended_at TEXT - ); -CREATE INDEX idx_agents_session ON agents(session_id); -CREATE INDEX idx_agents_status ON agents(status); -CREATE INDEX idx_events_session ON events(session_id); -CREATE INDEX idx_events_type ON events(event_type); -CREATE INDEX idx_events_created ON events(created_at DESC); -CREATE INDEX idx_sessions_status ON sessions(status); -CREATE INDEX idx_sessions_started ON sessions(started_at DESC); -CREATE INDEX idx_events_session_type ON events(session_id, event_type); -CREATE INDEX idx_agents_session_type ON agents(session_id, type); -CREATE INDEX idx_dashboard_runs_started ON dashboard_runs(started_at DESC); -CREATE INDEX idx_dashboard_runs_session ON dashboard_runs(session_id); -CREATE INDEX idx_sessions_status_updated ON sessions(status, updated_at DESC); -CREATE INDEX idx_sessions_harness ON sessions(harness); -CREATE TABLE plans ( - id TEXT PRIMARY KEY, - organization_id TEXT, - title TEXT, - current_version_id TEXT, - status TEXT NOT NULL DEFAULT 'draft' - CHECK(status IN ('draft','proposed','approved','rejected','superseded','archived')), - source TEXT NOT NULL DEFAULT 'captured' - CHECK(source IN ('captured','imported','human','generated')), - capture_method TEXT - CHECK(capture_method IN ('log','hook','api','file','import','manual')), - harness TEXT, - created_from_session_id TEXT, - created_from_event_id TEXT, - plan_key TEXT, - file_path TEXT, - source_log_path TEXT, - needs_confirmation INTEGER NOT NULL DEFAULT 0, - confidence REAL, - sync_state TEXT NOT NULL DEFAULT 'local_only' - CHECK(sync_state IN ('local_only','metadata_synced','full_synced','excluded')), - metadata TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) - ); -CREATE TABLE plan_versions ( - id TEXT PRIMARY KEY, - plan_id TEXT NOT NULL, - version_number INTEGER NOT NULL, - content_markdown TEXT NOT NULL, - content_json TEXT, - content_sha256 TEXT NOT NULL, - author_type TEXT NOT NULL DEFAULT 'agent' - CHECK(author_type IN ('human','agent','imported')), - author_user_id TEXT, - source_session_id TEXT, - source_event_ref TEXT, - capture_method TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - UNIQUE(plan_id, version_number), - FOREIGN KEY (plan_id) REFERENCES plans(id) ON DELETE CASCADE - ); -CREATE INDEX idx_plans_session ON plans(created_from_session_id); -CREATE INDEX idx_plans_needs_confirmation ON plans(needs_confirmation); -CREATE INDEX idx_plans_updated ON plans(updated_at DESC); -CREATE INDEX idx_plan_versions_plan ON plan_versions(plan_id); -CREATE UNIQUE INDEX idx_plans_session_key - ON plans(created_from_session_id, plan_key); -CREATE TABLE agent_packs ( - pack_id TEXT NOT NULL, - harness TEXT NOT NULL, - install_path TEXT NOT NULL, - install_kind TEXT NOT NULL CHECK(install_kind IN ('symlink','directory')), - source_url TEXT, - version TEXT, - detected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), uninstalled_at TEXT, - PRIMARY KEY (pack_id, harness, install_path) - ); -CREATE TABLE skills ( - skill_id TEXT PRIMARY KEY, - pack_id TEXT, - harness TEXT NOT NULL, - install_path TEXT NOT NULL, - name TEXT NOT NULL, - version TEXT, - description TEXT, - source_url TEXT, - detected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) - , uninstalled_at TEXT); -CREATE TABLE project_pack_associations ( - project_path TEXT NOT NULL, - pack_id TEXT NOT NULL, - detected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - PRIMARY KEY (project_path, pack_id) - ); -CREATE INDEX idx_skills_pack ON skills(pack_id); -CREATE INDEX idx_skills_name ON skills(name); -CREATE INDEX idx_agent_packs_pack ON agent_packs(pack_id); -CREATE INDEX idx_events_type_tool ON events(event_type, tool_name); -CREATE INDEX idx_events_skill_prompt_lookup ON events(event_type, json_extract(data,'$.prompt')); -CREATE TABLE pack_catalog ( - pack_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - category TEXT, - github_url TEXT NOT NULL, - description TEXT, - description_live TEXT, - harnesses TEXT, - install_commands TEXT, - uninstall_commands TEXT, - install_notes TEXT, - placeholder_reason TEXT, - verified INTEGER NOT NULL DEFAULT 0, - readme_excerpt TEXT, - readme_fetched_at TEXT, - stars INTEGER, - forks INTEGER, - last_release TEXT, - last_fetched_at TEXT, - seed_version INTEGER NOT NULL DEFAULT 1 - , pin_order INTEGER, contents TEXT, contents_cache TEXT, contents_fetched_at TEXT, upstream_github_url TEXT, marketplace_url TEXT, detection_patterns TEXT, harness_agnostic INTEGER, project_scoped INTEGER, single_install INTEGER, post_install TEXT); -CREATE TABLE pack_catalog_history ( - pack_id TEXT NOT NULL, - fetched_at TEXT NOT NULL, - stars INTEGER, - forks INTEGER, - PRIMARY KEY (pack_id, fetched_at) - ); -CREATE TABLE pack_install_runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - pack_id TEXT NOT NULL, - harness TEXT NOT NULL, - command TEXT NOT NULL, - exit_code INTEGER, - started_at TEXT NOT NULL, - ended_at TEXT, - stdout_tail TEXT, - stderr_tail TEXT - ); -CREATE INDEX idx_pack_catalog_history_pack - ON pack_catalog_history(pack_id, fetched_at DESC); -CREATE INDEX idx_pack_install_runs_pack - ON pack_install_runs(pack_id, started_at DESC); -CREATE INDEX idx_pack_install_runs_inflight - ON pack_install_runs(pack_id, ended_at); -CREATE TABLE pull_requests ( - id TEXT PRIMARY KEY, - session_id TEXT, - pr_url TEXT NOT NULL, - pr_number INTEGER NOT NULL, - repo_full_name TEXT NOT NULL, - branch_name TEXT, - head_sha TEXT, - title TEXT, - harness TEXT NOT NULL, - observed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) - ); -CREATE INDEX idx_pull_requests_session - ON pull_requests(session_id); -CREATE INDEX idx_pull_requests_repo - ON pull_requests(repo_full_name, pr_number); -CREATE INDEX idx_pull_requests_observed - ON pull_requests(observed_at DESC); diff --git a/apps/desktop/test-e2e/agent-monitor/fixtures/sessions.json b/apps/desktop/test-e2e/agent-monitor/fixtures/sessions.json deleted file mode 100644 index 9d885d52..00000000 --- a/apps/desktop/test-e2e/agent-monitor/fixtures/sessions.json +++ /dev/null @@ -1,67 +0,0 @@ -[ - { - "id": "fixture-sess-active-1", - "name": "Fixture Active Session 1", - "status": "active", - "cwd": "/tmp/fixture-repo-a", - "model": "claude-opus-4-7", - "started_at": "2026-05-20T10:00:00.000Z", - "ended_at": null, - "metadata": null, - "updated_at": "2026-05-20T10:30:00.000Z", - "awaiting_input_since": null, - "harness": "claude" - }, - { - "id": "fixture-sess-active-2", - "name": "Fixture Active Session 2", - "status": "active", - "cwd": "/tmp/fixture-repo-b", - "model": "claude-sonnet-4-6", - "started_at": "2026-05-21T11:00:00.000Z", - "ended_at": null, - "metadata": null, - "updated_at": "2026-05-21T11:45:00.000Z", - "awaiting_input_since": null, - "harness": "claude" - }, - { - "id": "fixture-sess-completed-1", - "name": "Fixture Completed Session 1", - "status": "completed", - "cwd": "/tmp/fixture-repo-a", - "model": "claude-opus-4-7", - "started_at": "2026-05-15T08:00:00.000Z", - "ended_at": "2026-05-15T09:30:00.000Z", - "metadata": null, - "updated_at": "2026-05-15T09:30:00.000Z", - "awaiting_input_since": null, - "harness": "claude" - }, - { - "id": "fixture-sess-completed-2", - "name": "Fixture Completed Session 2", - "status": "completed", - "cwd": "/tmp/fixture-repo-c", - "model": "claude-haiku-4-5", - "started_at": "2026-05-16T08:00:00.000Z", - "ended_at": "2026-05-16T08:15:00.000Z", - "metadata": null, - "updated_at": "2026-05-16T08:15:00.000Z", - "awaiting_input_since": null, - "harness": "codex" - }, - { - "id": "fixture-sess-error-1", - "name": "Fixture Error Session", - "status": "error", - "cwd": "/tmp/fixture-repo-b", - "model": "claude-opus-4-7", - "started_at": "2026-05-17T08:00:00.000Z", - "ended_at": "2026-05-17T08:05:00.000Z", - "metadata": null, - "updated_at": "2026-05-17T08:05:00.000Z", - "awaiting_input_since": null, - "harness": "claude" - } -] diff --git a/apps/desktop/test-e2e/agent-monitor/fixtures/skills.json b/apps/desktop/test-e2e/agent-monitor/fixtures/skills.json deleted file mode 100644 index d4f688ce..00000000 --- a/apps/desktop/test-e2e/agent-monitor/fixtures/skills.json +++ /dev/null @@ -1,41 +0,0 @@ -[ - { - "skill_id": "fixture-skill-alpha-1", - "pack_id": "fixture-pack-alpha", - "harness": "claude", - "install_path": "/tmp/fixture-claude/plugins/alpha", - "name": "alpha-skill-one", - "version": "1.0.0", - "description": "First skill from fixture pack alpha", - "source_url": null, - "detected_at": "2026-05-10T00:00:00.000Z", - "last_seen_at": "2026-05-22T00:00:00.000Z", - "uninstalled_at": null - }, - { - "skill_id": "fixture-skill-alpha-2", - "pack_id": "fixture-pack-alpha", - "harness": "claude", - "install_path": "/tmp/fixture-claude/plugins/alpha", - "name": "alpha-skill-two", - "version": "1.0.0", - "description": "Second skill from fixture pack alpha", - "source_url": null, - "detected_at": "2026-05-10T00:00:00.000Z", - "last_seen_at": "2026-05-22T00:00:00.000Z", - "uninstalled_at": null - }, - { - "skill_id": "fixture-skill-beta-1", - "pack_id": "fixture-pack-beta", - "harness": "claude", - "install_path": "/tmp/fixture-claude/plugins/beta", - "name": "beta-skill-one", - "version": "2.3.1", - "description": "Only skill from fixture pack beta", - "source_url": null, - "detected_at": "2026-05-11T00:00:00.000Z", - "last_seen_at": "2026-05-22T00:00:00.000Z", - "uninstalled_at": null - } -] diff --git a/apps/desktop/test-e2e/agent-monitor/fixtures/token_usage.json b/apps/desktop/test-e2e/agent-monitor/fixtures/token_usage.json deleted file mode 100644 index 13bf55b5..00000000 --- a/apps/desktop/test-e2e/agent-monitor/fixtures/token_usage.json +++ /dev/null @@ -1,50 +0,0 @@ -[ - { - "session_id": "fixture-sess-active-1", - "model": "claude-opus-4-7", - "input_tokens": 1000, - "output_tokens": 200, - "cache_read_tokens": 5000, - "cache_write_tokens": 800, - "baseline_input": 0, - "baseline_output": 0, - "baseline_cache_read": 0, - "baseline_cache_write": 0 - }, - { - "session_id": "fixture-sess-active-2", - "model": "claude-sonnet-4-6", - "input_tokens": 500, - "output_tokens": 100, - "cache_read_tokens": 2000, - "cache_write_tokens": 400, - "baseline_input": 0, - "baseline_output": 0, - "baseline_cache_read": 0, - "baseline_cache_write": 0 - }, - { - "session_id": "fixture-sess-completed-1", - "model": "claude-opus-4-7", - "input_tokens": 2000, - "output_tokens": 500, - "cache_read_tokens": 10000, - "cache_write_tokens": 1500, - "baseline_input": 0, - "baseline_output": 0, - "baseline_cache_read": 0, - "baseline_cache_write": 0 - }, - { - "session_id": "fixture-sess-completed-2", - "model": "claude-haiku-4-5", - "input_tokens": 300, - "output_tokens": 50, - "cache_read_tokens": 1000, - "cache_write_tokens": 200, - "baseline_input": 0, - "baseline_output": 0, - "baseline_cache_read": 0, - "baseline_cache_write": 0 - } -] diff --git a/apps/desktop/test-e2e/agent-monitor/helpers/audit-tile.ts b/apps/desktop/test-e2e/agent-monitor/helpers/audit-tile.ts deleted file mode 100644 index d645200b..00000000 --- a/apps/desktop/test-e2e/agent-monitor/helpers/audit-tile.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Shared per-tile UI-audit assertion for the manifest-driven Playwright specs -// (PLN-760, Phase 3). One screen spec = a table loop over its manifest tiles, -// each delegating to assertTileMatchesOracle here. Keeps the oracle+slice+assert -// logic in one place so every screen stays consistent and adding a screen is a -// ~10-line spec (per the repo convention to extract shared test helpers rather -// than copy spec bodies). - -import { expect, type Page } from "@playwright/test"; -import { readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; - -// @ts-expect-error — .mjs loaded by Playwright's ts loader -import { computeOracle, openDb } from "../inventory/audit-runner.mjs"; -// @ts-expect-error — .ts helper imported by Playwright's ts loader -import { sliceForTile } from "./playwright-region"; - -/** - * The fixture DB path is written to a state file by the Playwright global setup - * (helpers/playwright-global-setup.ts). Read it so the DB we query for oracles - * is the same one the sidecar is serving. - */ -export function resolveFixtureDbPath(): string { - const statePath = join( - process.env.RUNNER_TEMP || "/tmp", - "closedloop-e2e-sidecar-state.json", - ); - if (existsSync(statePath)) { - const state = JSON.parse(readFileSync(statePath, "utf8")); - if (state.dbPath) return state.dbPath; - } - throw new Error( - "Cannot resolve fixture DB path — globalSetup did not write " + - `${statePath}, or the state file is missing dbPath. Run via ` + - "`pnpm --filter desktop test:audit:ui` rather than ad-hoc Playwright.", - ); -} - -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -/** - * Uniform skip decision for every screen spec. A tile is audited only when it - * is bound to a real selector (selector.present_in_code). Tiles with a filed - * bug skip until the fix lands; tiles without a bound selector skip as - * "selector pending" rather than silently falling back to a fragile label - * slice that can match a number coincidentally (PR #252 review, thadeusb). Use - * this in ALL specs so the five behave identically. - */ -export function tileSkip(row: { - bug_ref?: string | null; - selector?: { present_in_code?: boolean }; -}): { skip: boolean; suffix: string } { - if (row.bug_ref) return { skip: true, suffix: ` (skip — bug ${row.bug_ref})` }; - if (!row.selector?.present_in_code) - return { skip: true, suffix: " (skip — selector pending)" }; - return { skip: false, suffix: "" }; -} - -/** - * Compute the tile's oracle value from the fixture DB, slice its rendered region - * (data-testid selector when present, else label slice), and assert the rendered - * text matches the formatted oracle. Branches on tile_kind: trend / money / count. - * Zero magic numbers — every expectation flows from the manifest + oracle. - */ -export async function assertTileMatchesOracle( - page: Page, - // row is a manifest tile loaded via manifest-loader.mjs - row: { - id: string; - label: string; - oracle: string; - tile_kind?: string; - trend_label?: string; - selector?: { - value?: string; - present_in_code?: boolean; - render_kind?: string; - }; - }, -): Promise { - const db = openDb(resolveFixtureDbPath()); - let expectedFormatted: string; - let expectedRaw: number | string; - try { - const r = computeOracle(row, db, { tzOffsetMinutes: 0 }); - expectedFormatted = r.expectedFormatted ?? String(r.expected); - expectedRaw = r.expected as number; - } finally { - db.close(); - } - - // dom_count tiles (list_length / section_card_count): there is no single - // numeric element — the tile's value IS the number of rendered item nodes. - // Assert the count of the item selector equals the oracle. selector.value is - // the per-item selector for these tiles (e.g. [data-testid='audit-plan-row']). - if (row.selector?.render_kind === "dom_count" && row.selector.present_in_code) { - const expectedCount = Number(expectedRaw); - await expect( - page.locator(row.selector.value as string), - `\n manifest id: ${row.id}\n` + - ` oracle: ${row.oracle} -> ${expectedRaw}\n` + - ` selector: ${row.selector.value} (counted)\n` + - ` note: dom_count tile — rendered item count must equal oracle.\n`, - ).toHaveCount(expectedCount); - return; - } - - const region = await sliceForTile(page, row); - - if (row.tile_kind === "trend") { - const trendLabel = row.trend_label ?? ""; - const pattern = new RegExp( - `${escapeRegex(expectedFormatted)}\\s*${escapeRegex(trendLabel)}`, - "i", - ); - expect( - region, - `\n manifest id: ${row.id}\n` + - ` label: ${row.label} (${trendLabel})\n` + - ` oracle: ${row.oracle} -> ${expectedRaw}\n` + - ` expected: "${expectedFormatted} ${trendLabel}"\n` + - ` region: ${JSON.stringify(region)}\n` + - ` triage: Compare against the API audit for this row's parent\n` + - ` tile. API agrees but UI doesn't => client formatter /\n` + - ` rendering bug. Both disagree => DB or oracle is wrong.\n`, - ).toMatch(pattern); - } else if (row.tile_kind === "money") { - expect( - region, - `\n ${row.id} expected "${expectedFormatted}" in region: ${JSON.stringify(region)}`, - ).toContain(expectedFormatted); - } else { - // Counts: the formatted number should appear as a standalone token. - const pattern = new RegExp(`\\b${escapeRegex(expectedFormatted)}\\b`); - expect( - region, - `\n manifest id: ${row.id}\n` + - ` oracle: ${row.oracle} -> ${expectedRaw}\n` + - ` expected: "${expectedFormatted}"\n` + - ` region: ${JSON.stringify(region)}\n`, - ).toMatch(pattern); - } -} diff --git a/apps/desktop/test-e2e/agent-monitor/helpers/launch-sidecar.mjs b/apps/desktop/test-e2e/agent-monitor/helpers/launch-sidecar.mjs deleted file mode 100644 index caf9ea80..00000000 --- a/apps/desktop/test-e2e/agent-monitor/helpers/launch-sidecar.mjs +++ /dev/null @@ -1,154 +0,0 @@ -// Boot the real built Agent Monitor sidecar against a fixture DB on a random -// localhost port, then wait until it answers /api/health. Same code path the -// Electron host uses in production via agent-monitor-sidecar.ts. - -import { spawn } from "node:child_process"; -import { createServer } from "node:net"; -import { existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { delimiter, dirname, join } from "node:path"; -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const SIDECAR_DIR = join(HERE, "..", "..", "..", ".generated", "agent-monitor"); -const SIDECAR_ENTRY = join(SIDECAR_DIR, "server", "index.js"); -const DESKTOP_PKG = join(HERE, "..", "..", "..", "package.json"); - -// Mirror src/main/agent-monitor-sidecar.ts buildRuntimeNodePath(): point at the -// pnpm-managed agent-dashboard package root + its sibling node_modules dir so -// the sidecar can resolve express/cors/ws/etc. without bundling them. -function buildRuntimeNodePath() { - const require_ = createRequire(DESKTOP_PKG); - const candidates = []; - try { - const pkgJson = require_.resolve("agent-dashboard/package.json"); - const pkgRoot = dirname(realpathSync(pkgJson)); - candidates.push(dirname(pkgRoot)); // pnpm .pnpm//node_modules/ - candidates.push(join(pkgRoot, "node_modules")); - } catch { - /* fall through; tests will fail with a useful error from the sidecar */ - } - if (process.env.NODE_PATH) candidates.push(process.env.NODE_PATH); - return [...new Set(candidates.filter((p) => p && existsSync(p)))].join( - delimiter, - ); -} - -function pickFreePort() { - return new Promise((resolve, reject) => { - const srv = createServer(); - srv.unref(); - srv.on("error", reject); - srv.listen(0, "127.0.0.1", () => { - const { port } = srv.address(); - srv.close(() => resolve(port)); - }); - }); -} - -async function waitForHealth(port, timeoutMs = 30000) { - const deadline = Date.now() + timeoutMs; - let lastErr; - while (Date.now() < deadline) { - try { - const res = await fetch(`http://127.0.0.1:${port}/api/health`); - if (res.ok) return; - lastErr = new Error(`status ${res.status}`); - } catch (err) { - lastErr = err; - } - await new Promise((r) => setTimeout(r, 100)); - } - throw new Error( - `sidecar did not become healthy on port ${port}: ${lastErr?.message}`, - ); -} - -export async function launchSidecar({ dbPath, env = {} } = {}) { - if (!dbPath) throw new Error("launchSidecar requires { dbPath }"); - if (!existsSync(SIDECAR_ENTRY)) { - throw new Error( - `Agent Monitor sidecar bundle not found at ${SIDECAR_ENTRY}. Run ` + - `\`pnpm --filter desktop build:agent-monitor\` before invoking the ` + - `test harness, or use the \`test:contract\`/\`test:e2e\` npm scripts ` + - `which chain the build automatically.`, - ); - } - const port = await pickFreePort(); - const nodePath = buildRuntimeNodePath(); - // The sidecar's startup also ingests sessions/packs from harness home dirs - // (Claude/Codex/Cursor/Copilot/OpenCode) and refreshes the pack catalog from - // GitHub. Point every home env var at an empty sandbox dir so tests see ONLY - // fixture data, and tarpit GitHub with an unroutable proxy so the catalog - // refresh fails fast and silently (the upserter no-ops on error). - const sandboxHome = mkdtempSync(join(tmpdir(), "closedloop-e2e-home-")); - const child = spawn(process.execPath, [SIDECAR_ENTRY], { - cwd: SIDECAR_DIR, - env: { - ...process.env, - NODE_ENV: "production", - ...(nodePath ? { NODE_PATH: nodePath } : {}), - DASHBOARD_PORT: String(port), - DASHBOARD_DB_PATH: dbPath, - CCAM_AUTO_INSTALL_HOOKS: "0", - CCAM_ENABLE_RUN: "0", - // FEA-1407 sandbox scoping: the hook handler silently drops events - // whose data.cwd is outside SANDBOX_BASE_DIRECTORY. Tests post - // synthetic fixture events with cwd="/Users/dev/repo"; widening the - // sandbox to "/" lets fixture cwds pass without exposing real data - // (tests run against a temp DB seeded by seed-fixture-db.mjs). - SANDBOX_BASE_DIRECTORY: "/", - // Harness ingest sandboxing — every importer reads from these. - HOME: sandboxHome, - CLAUDE_HOME: join(sandboxHome, ".claude"), - CODEX_HOME: join(sandboxHome, ".codex"), - CURSOR_HOME: join(sandboxHome, ".cursor"), - COPILOT_HOME: join(sandboxHome, ".copilot"), - OPENCODE_DATA_DIR: join(sandboxHome, ".local", "share", "opencode"), - // pack-scanner reads SKIP_CATALOG_DETECTORS to short-circuit detector - // logic that walks plugin dirs. - SKIP_CATALOG_DETECTORS: "1", - // Block outbound HTTPS so catalog-fetcher's GitHub call fails fast. - // 127.0.0.1:1 is closed; fetch returns a connection error instantly. - HTTPS_PROXY: "http://127.0.0.1:1", - HTTP_PROXY: "http://127.0.0.1:1", - ...env, - }, - stdio: ["ignore", "pipe", "pipe"], - }); - - const logs = { stdout: "", stderr: "" }; - child.stdout.on("data", (b) => { - logs.stdout += b.toString(); - }); - child.stderr.on("data", (b) => { - logs.stderr += b.toString(); - }); - - const exited = new Promise((resolve) => child.once("exit", resolve)); - - try { - await waitForHealth(port); - } catch (err) { - child.kill("SIGKILL"); - err.message += `\n--- sidecar stdout ---\n${logs.stdout}\n--- sidecar stderr ---\n${logs.stderr}`; - throw err; - } - - return { - baseUrl: `http://127.0.0.1:${port}`, - port, - pid: child.pid, - logs, - async stop() { - if (child.exitCode === null) { - child.kill("SIGTERM"); - const killTimer = setTimeout(() => child.kill("SIGKILL"), 3000); - await exited; - clearTimeout(killTimer); - } - rmSync(sandboxHome, { recursive: true, force: true }); - }, - }; -} diff --git a/apps/desktop/test-e2e/agent-monitor/helpers/playwright-global-setup.ts b/apps/desktop/test-e2e/agent-monitor/helpers/playwright-global-setup.ts deleted file mode 100644 index 47133d87..00000000 --- a/apps/desktop/test-e2e/agent-monitor/helpers/playwright-global-setup.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Boots one fixture-loaded sidecar that every UI spec shares. The base URL is -// passed to specs via E2E_BASE_URL (read by playwright.config.ts), and the -// teardown file kills the sidecar. - -import type { FullConfig } from "@playwright/test"; -// @ts-expect-error — .mjs files are loaded by ts-node-less Node -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "./seed-fixture-db.mjs"; -// @ts-expect-error — see above -import { launchSidecar } from "./launch-sidecar.mjs"; -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; - -export default async function globalSetup(_config: FullConfig) { - const tmp = makeTempDbPath(); - seedFixtureDb(tmp.dbPath); - const sidecar = await launchSidecar({ dbPath: tmp.dbPath }); - reseedPacksAndSkills(tmp.dbPath); - - // Hand off to teardown via env + a tiny state file. Playwright forks workers, - // so process-level globals don't survive — the state file is the bridge. - const statePath = join( - process.env.RUNNER_TEMP || "/tmp", - "closedloop-e2e-sidecar-state.json", - ); - writeFileSync( - statePath, - JSON.stringify({ baseUrl: sidecar.baseUrl, pid: sidecar.pid, dbPath: tmp.dbPath }), - ); - process.env.E2E_BASE_URL = sidecar.baseUrl; - process.env.E2E_SIDECAR_STATE = statePath; -} diff --git a/apps/desktop/test-e2e/agent-monitor/helpers/playwright-global-teardown.ts b/apps/desktop/test-e2e/agent-monitor/helpers/playwright-global-teardown.ts deleted file mode 100644 index a5f20763..00000000 --- a/apps/desktop/test-e2e/agent-monitor/helpers/playwright-global-teardown.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { readFileSync, rmSync, existsSync } from "node:fs"; -import { dirname } from "node:path"; - -export default async function globalTeardown() { - const statePath = process.env.E2E_SIDECAR_STATE; - if (!statePath || !existsSync(statePath)) return; - try { - const { pid, dbPath } = JSON.parse(readFileSync(statePath, "utf8")); - if (pid) { - try { - process.kill(pid, "SIGTERM"); - await new Promise((r) => setTimeout(r, 500)); - try { - process.kill(pid, 0); - // still alive — escalate - process.kill(pid, "SIGKILL"); - } catch { - /* already gone */ - } - } catch { - /* already gone */ - } - } - if (dbPath) rmSync(dirname(dbPath), { recursive: true, force: true }); - } finally { - rmSync(statePath, { force: true }); - } -} diff --git a/apps/desktop/test-e2e/agent-monitor/helpers/playwright-region.ts b/apps/desktop/test-e2e/agent-monitor/helpers/playwright-region.ts deleted file mode 100644 index 45f8a077..00000000 --- a/apps/desktop/test-e2e/agent-monitor/helpers/playwright-region.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Shared DOM-slicing helpers for the manifest-driven UI audit (PLN-760, Phase 3). -// -// Two strategies for locating a tile's rendered value: -// - sliceAtSelector: preferred. Reads the innerText of a stable -// `data-testid` element. Use when the manifest tile's -// `selector.present_in_code` is true. -// - sliceAtLabel: fallback. Slices
innerText to a window near a known -// label string. Use for tiles that have no data-testid yet (the bulk today -// — see PHASE3-RENDERER-MAP.md: present_in_code is false for most tiles). -// -// Keeping both here means every screen's spec shares one implementation instead -// of copy-pasting the slice logic (per the repo's "extract shared test helpers" -// convention in CLAUDE.md). - -import { expect, type Page } from "@playwright/test"; - -/** - * Return the innerText of the element matching `selector` (a CSS selector, - * typically `[data-testid='…']`). Fails with a clear message if the element is - * absent, so a missing/renamed testid surfaces as a test failure rather than a - * silent empty match. - */ -export async function sliceAtSelector( - page: Page, - selector: string, -): Promise { - const locator = page.locator(selector); - await expect( - locator, - `selector "${selector}" must match exactly one element in the page`, - ).toHaveCount(1); - return (await locator.innerText()).trim(); -} - -/** - * Slice
innerText to a `windowChars`-wide window starting at `label`. - * Label-based fallback for tiles without a data-testid. Matches the pattern - * originally inlined in dashboard.ui-audit.spec.ts. - */ -export async function sliceAtLabel( - page: Page, - label: string, - windowChars = 80, -): Promise { - const text = await page.locator("main").innerText(); - const idx = text.toLowerCase().indexOf(label.toLowerCase()); - expect(idx, `label "${label}" must appear in
`).toBeGreaterThan(-1); - return text.slice(idx, idx + windowChars); -} - -/** - * Resolve the region for a manifest tile: prefer its data-testid selector when - * the manifest records one as present in code, else fall back to label slicing. - * `row` is a manifest tile (loaded via manifest-loader.mjs). - */ -export async function sliceForTile( - page: Page, - row: { - label: string; - selector?: { value?: string; present_in_code?: boolean }; - }, -): Promise { - if (row.selector?.present_in_code && row.selector.value) { - return sliceAtSelector(page, row.selector.value); - } - return sliceAtLabel(page, row.label); -} diff --git a/apps/desktop/test-e2e/agent-monitor/helpers/seed-fixture-db.mjs b/apps/desktop/test-e2e/agent-monitor/helpers/seed-fixture-db.mjs deleted file mode 100644 index 786e7b1a..00000000 --- a/apps/desktop/test-e2e/agent-monitor/helpers/seed-fixture-db.mjs +++ /dev/null @@ -1,119 +0,0 @@ -// Build a fresh SQLite file from the committed schema + JSON fixture rows. -// Used by both Layer-1 HTTP contract tests and Layer-2 Playwright specs so the -// shape under test is identical to what the live app reads. - -import { DatabaseSync } from "node:sqlite"; -import { readFileSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const FIXTURES_DIR = join(HERE, "..", "fixtures"); -const SCHEMA_PATH = join(FIXTURES_DIR, "schema.sql"); - -function loadJson(name) { - return JSON.parse(readFileSync(join(FIXTURES_DIR, name), "utf8")); -} - -function bindRow(db, table, row) { - const cols = Object.keys(row); - const placeholders = cols.map(() => "?").join(", "); - const sql = `INSERT INTO ${table} (${cols.join(", ")}) VALUES (${placeholders})`; - db.prepare(sql).run(...cols.map((c) => (row[c] === undefined ? null : row[c]))); -} - -// Post-FEA-1390 the sidecar's startup cleanup anchors on updated_at and uses -// the same 180-minute default as the runtime sweep. Our fixture rows have -// fixed-date timestamps in the past, so we bump updated_at on active rows -// (sessions + their agents) to "1 minute ago" at seed time. No anchor events -// are required anymore (pre-fix we also had to insert a heartbeat per active -// session to dodge the buggy `started_at + last-event` clause). -function refreshActiveTimestamps(sessions, agents /*, events */) { - const oneMinuteAgo = new Date(Date.now() - 60_000).toISOString(); - const activeIds = new Set( - sessions.filter((s) => s.status === "active").map((s) => s.id), - ); - if (activeIds.size === 0) return; - for (const s of sessions) { - if (!activeIds.has(s.id)) continue; - s.updated_at = oneMinuteAgo; - } - for (const a of agents) { - if (!activeIds.has(a.session_id)) continue; - a.updated_at = oneMinuteAgo; - } -} - -// Build a fresh DB file at `dbPath` and return some derived counts so tests -// can assert against the fixture without hard-coding numbers twice. -export function seedFixtureDb(dbPath) { - const db = new DatabaseSync(dbPath); - db.exec(readFileSync(SCHEMA_PATH, "utf8")); - - const sessions = loadJson("sessions.json"); - const agents = loadJson("agents.json"); - const events = loadJson("events.json"); - const tokenUsage = loadJson("token_usage.json"); - const modelPricing = loadJson("model_pricing.json"); - const packs = loadJson("agent_packs.json"); - const skills = loadJson("skills.json"); - const packCatalog = loadJson("pack_catalog.json"); - const pullRequests = loadJson("pull_requests.json"); - - refreshActiveTimestamps(sessions, agents); - - for (const row of sessions) bindRow(db, "sessions", row); - for (const row of agents) bindRow(db, "agents", row); - for (const row of events) bindRow(db, "events", row); - for (const row of tokenUsage) bindRow(db, "token_usage", row); - for (const row of modelPricing) bindRow(db, "model_pricing", row); - for (const row of packs) bindRow(db, "agent_packs", row); - for (const row of skills) bindRow(db, "skills", row); - for (const row of packCatalog) bindRow(db, "pack_catalog", row); - for (const row of pullRequests) bindRow(db, "pull_requests", row); - - db.close(); - - return { - counts: { - sessions: sessions.length, - sessionsActive: sessions.filter((s) => s.status === "active").length, - sessionsCompleted: sessions.filter((s) => s.status === "completed").length, - sessionsError: sessions.filter((s) => s.status === "error").length, - agents: agents.length, - agentsWorking: agents.filter((a) => a.status === "working").length, - events: events.length, - packs: packs.length, - packsInstalled: [...new Set(packs.map((p) => p.pack_id))].length, - skills: skills.length, - pullRequests: pullRequests.length, - }, - }; -} - -// Re-seed packs/skills AFTER the sidecar's startup pack-scanner has run. The -// scanner walks the sandboxed CLAUDE_HOME, finds nothing, and tombstones every -// row it doesn't see. So we wait until the sidecar is healthy, then put the -// fixture rows back with uninstalled_at = NULL. This is the same DB file the -// sidecar holds open — SQLite's file locking handles the concurrent writer. -export function reseedPacksAndSkills(dbPath) { - const db = new DatabaseSync(dbPath); - const packs = loadJson("agent_packs.json"); - const skills = loadJson("skills.json"); - // Drop tombstoned rows the scanner inserted so the fixture rows can land - // cleanly (PRIMARY KEY collision otherwise). - db.exec("DELETE FROM agent_packs"); - db.exec("DELETE FROM skills"); - for (const row of packs) bindRow(db, "agent_packs", row); - for (const row of skills) bindRow(db, "skills", row); - db.close(); -} - -export function makeTempDbPath() { - const dir = mkdtempSync(join(tmpdir(), "closedloop-e2e-db-")); - return { - dbPath: join(dir, "dashboard.db"), - cleanup: () => rmSync(dir, { recursive: true, force: true }), - }; -} diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-RENDERER-MAP.md b/apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-RENDERER-MAP.md deleted file mode 100644 index 09357217..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-RENDERER-MAP.md +++ /dev/null @@ -1,134 +0,0 @@ -# PHASE3-RENDERER-MAP - -> Generated by the FEA-1437 pre-explorer pass (PLN-760). For every manifest tile, the React file + line where its number renders, the render component, and how testable it is at the DOM today. - -**Parent:** FEA-1437 / PLN-760 · **Substrate:** FEA-1415 / PLN-738 (PR #246, `2a3a371`) - -## How to read render_kind - -| render_kind | meaning | Phase-3 implication | -|---|---|---| -| ✅ text | a single numeric value renders as DOM text | `label_slice` works today; data-testid is a nicety | -| ✅ text (sub) | value renders as a StatPill secondary (`sub`) line | sliceable, but label collides with the primary pill — needs sub-specific selector | -| ⚠️ text (embedded) | number is inside a larger string (i18n / pagination) | needs a regex slice OR a wrapping data-testid span | -| ⚠️ per-group | only per-group / per-row counts render; no global total | needs an aggregate count element to assert the manifest total | -| ⚠️ DOM count | no numeric text; count = number of rendered cards/rows | assert by counting DOM nodes, or add a total element | -| ❌ chart only | value feeds a chart/SVG/tooltip, never sliceable text | DOM assertion impossible without a follow-up; API-layer coverage stands | -| ❌ absent | tile is not rendered anywhere in the current overlay | manifest row needs a render or a reclassification | - -**Note on file paths:** `upstream/…` = the pinned `agent-dashboard-client` package (`Claude-Code-Agent-Monitor_66e710f3…`); read read-only as reference. `scripts/…` = in-repo ClosedLoop overlays under `apps/desktop/`. - -**Critical finding:** there are **zero `data-testid` attributes** on any manifest-tile render path today (the only data-testids in the whole render surface are in upstream `SessionDetail.tsx`, which is not a manifest screen). Every `selector.value` in the manifest is therefore a *proposed* selector, gated on a one-line React follow-up. - -## Dashboard - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `dashboard.monitor.total_sessions` | Monitor | ✅ text | `scripts/agent-monitor-client/Dashboard.tsx:1800` | StatPill | fmt(analyticsData?.overview.total_sessions ?? stats?.total_sessions ?? 0) | -| `dashboard.monitor.total_sessions.trend_active` | Monitor | ✅ text (sub) | `scripts/agent-monitor-client/Dashboard.tsx:1806` | StatPill.sub | …active_sessions… (sub line) | -| `dashboard.monitor.active_agents` | Monitor | ✅ text (sub) | `scripts/agent-monitor-client/Dashboard.tsx` (Total Agents pill SUB) | StatPill.sub | `${active_agents} active` — pill VALUE is overview.total_agents (different number); active_agents only in the sub. Selector must target the sub, not the value. | -| `dashboard.monitor.active_subagents` | Monitor | ⚠️ DOM count | `scripts/agent-monitor-client/Dashboard.tsx:1485` | agent-tree (allSubagents) | derived from allSubagents[] tree — no stat pill | -| `dashboard.monitor.active_subagents.trend_total` | Monitor | ⚠️ DOM count | `scripts/agent-monitor-client/Dashboard.tsx:1485` | agent-tree (allSubagents) | derived from allSubagents[] tree — no text | -| `dashboard.monitor.events_today` | Monitor | ❌ absent | `scripts/agent-monitor-client/Dashboard.tsx` | — | 0 matches for events_today / 'Events Today' in the overlay | -| `dashboard.monitor.total_events` | Monitor | ✅ text | `scripts/agent-monitor-client/Dashboard.tsx:1840` | StatPill | fmt(analyticsData?.overview.total_events ?? stats?.total_events ?? 0) | -| `dashboard.monitor.total_cost` | Monitor | ✅ text | `scripts/agent-monitor-client/Dashboard.tsx:1828` | StatPill | fmtCost(costData.total_cost) [bug FEA-1418] | -| `dashboard.health.db.counts.sessions` | Health | ✅ text | `scripts/agent-monitor-client/Dashboard.tsx:912` | Health Tip card | info.db.counts?.sessions ?? 0 | -| `dashboard.health.db.counts.agents` | Health | ✅ text | `scripts/agent-monitor-client/Dashboard.tsx:918` | Health Tip card | info.db.counts?.agents ?? 0 | -| `dashboard.health.db.counts.events` | Health | ✅ text | `scripts/agent-monitor-client/Dashboard.tsx:924` | Health Tip card | info.db.counts?.events ?? 0 | -| `dashboard.health.workflow.compaction.totalCompactions` | Health | ✅ text | `scripts/agent-monitor-client/Dashboard.tsx:1042` | Health Tip card | workflow.compaction?.totalCompactions ?? 0 | -| `dashboard.health.workflow.stats.successRate` | Health | ⚠️ per-group | `scripts/agent-monitor-client/Dashboard.tsx:1260` | per-agent table row + computed@704 | item.successRate (per-row); aggregate successRate computed@704 used in tooltips, not a discrete tile | -| `dashboard.health.workflow.errorPropagation.errorRate` | Health | ✅ text | `scripts/agent-monitor-client/Dashboard.tsx:1031` | Errors Tip card | errorRate.toFixed(1)+'%' | -| `dashboard.monitor.active_agents_section.cards_count` | Monitor | ⚠️ DOM count | `scripts/agent-monitor-client/Dashboard.tsx:1939` | renderAgentNode() grid | main agents .filter(type==='main').slice(0,visibleAgentCount) | - -## Analytics - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `analytics.tokens.total_input` | — | ✅ text | `upstream/Analytics.tsx:937` | token bar list | data?.tokens.total_input ?? 0 | -| `analytics.tokens.total_output` | — | ✅ text | `upstream/Analytics.tsx:940` | token bar list | data?.tokens.total_output ?? 0 | -| `analytics.tokens.total_cache_read` | — | ✅ text | `upstream/Analytics.tsx:945` | token bar list | data?.tokens.total_cache_read ?? 0 | -| `analytics.tokens.total_cache_write` | — | ✅ text | `upstream/Analytics.tsx:950` | token bar list | data?.tokens.total_cache_write ?? 0 | -| `analytics.avg_events_per_session` | — | ✅ text (sub) | `upstream/Analytics.tsx:854` | StatPill.sub | data?.avg_events_per_session ?? 0 | -| `analytics.total_subagents` | — | ⚠️ per-group | `upstream/Analytics.tsx:1160` | .map(agent_types) | count via .map over agent_types — no single total tile | -| `analytics.overview.total_sessions` | — | ✅ text | `upstream/Analytics.tsx:816` | StatPill | fmt(data?.overview.total_sessions ?? 0) | -| `analytics.overview.total_agents` | — | ✅ text | `upstream/Analytics.tsx:824` | StatPill | fmt(data?.overview.total_agents ?? 0) | -| `analytics.overview.total_events` | — | ✅ text | `upstream/Analytics.tsx:852` | StatPill | fmt(data?.overview.total_events ?? 0) | -| `analytics.overview.active_sessions` | — | ✅ text (sub) | `upstream/Analytics.tsx:818` | StatPill.sub | data?.overview.active_sessions ?? 0 | -| `analytics.overview.active_agents` | — | ✅ text (sub) | `upstream/Analytics.tsx:826` | StatPill.sub | data?.overview.active_agents ?? 0 | - -## Workflows - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `workflows.stats.totalSessions` | — | ❌ chart only | `upstream/components/workflows/*` | ErrorPropagationMap/CompactionImpact denominators | used as denominator in sub-component charts — not a tile | -| `workflows.stats.totalAgents` | — | ❌ chart only | `upstream/components/workflows/ModelDelegationFlow.tsx:273` | internal countTotalAgents() | computed for tooltip share% — not rendered as a tile | -| `workflows.stats.totalSubagents` | — | ❌ chart only | `upstream/components/workflows/*` | — | in stats type, no discrete tile render | -| `workflows.stats.avgSubagents` | — | ✅ text | `upstream/components/workflows/WorkflowStats.tsx:287` | StatCard | stats.avgSubagents.toFixed(1) | -| `workflows.stats.avgCompactions` | — | ✅ text | `upstream/components/workflows/WorkflowStats.tsx:314` | StatCard | stats.avgCompactions.toFixed(1) | -| `workflows.stats.avgDurationSec` | — | ✅ text | `upstream/components/workflows/WorkflowStats.tsx:323` | StatCard | formatDurationSec(stats.avgDurationSec) | -| `workflows.stats.avgDepth` | — | ✅ text | `upstream/components/workflows/WorkflowStats.tsx:278` | StatCard | stats.avgDepth.toFixed(1) | -| `workflows.compaction.tokensRecovered` | — | ✅ text | `upstream/components/workflows/CompactionImpact.tsx:215` | StatCard | fmtTokens(data.tokensRecovered) | -| `workflows.compaction.sessionsWithCompactions` | — | ❌ chart only | `upstream/components/workflows/CompactionImpact.tsx:173` | derived pct | rendered as pct (sessionsWithCompactions/totalSessions); raw count not shown | -| `workflows.concurrency.aggregateLanes.length` | — | ❌ chart only | `upstream/components/workflows/ConcurrencyTimeline.tsx` | timeline chart | array length feeds chart, not text | -| `workflows.complexity.length` | — | ❌ chart only | `upstream/components/workflows/SessionComplexityScatter.tsx` | scatter chart | array length feeds scatter, not text | -| `workflows.modelDelegation.tokensByModel.length` | — | ❌ chart only | `upstream/components/workflows/ModelDelegationFlow.tsx` | delegation chart | array length feeds chart, not text | - -## PullRequests - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `pr.stats.pull_requests` | — | ✅ text | `scripts/agent-monitor-pull-requests/client/PullRequests.tsx:141` | stat grid .map | s.value (stats?.pull_requests) | -| `pr.stats.sessions_with_pr` | — | ✅ text | `scripts/agent-monitor-pull-requests/client/PullRequests.tsx:141` | stat grid .map | s.value (stats?.sessions_with_pull_requests) | -| `pr.stats.repos` | — | ✅ text | `scripts/agent-monitor-pull-requests/client/PullRequests.tsx:141` | stat grid .map | s.value (stats?.repos) | - -## Sessions - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `sessions.list.total` | — | ⚠️ text (embedded) | `scripts/agent-monitor-client/Sessions.tsx:195` | i18n subtitle | t('sessionCount', { count: total }) | - -## ActivityFeed - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `activityfeed.events.total` | — | ⚠️ text (embedded) | `upstream/ActivityFeed.tsx:446` | pagination footer | 'showing {from}-{to} of {total}' — total embedded in string | - -## Plans - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `plans.total` | — | ⚠️ DOM count | `scripts/agent-monitor-plans/client/Plans.tsx:164` | .map(plans) | plan list .map; no header count | - -## Tools - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `tools.list.length` | — | ⚠️ DOM count | `scripts/agent-monitor-packs/client/Tools.tsx:123` | .map(tools) | per-tool cards; distinct count = card count, no total text | -| `tools.event_types.length` | — | ⚠️ DOM count | `scripts/agent-monitor-packs/client/Tools.tsx:123` | .map(event facets) | event-type facet list; count = item count, no total text | - -## Skills - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `skills.list.length` | — | ⚠️ per-group | `scripts/agent-monitor-packs/client/Skills.tsx:140` | per-group header ({items.length}) | per pack-group count; no single global total | - -## SubAgents - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `subagents.list.length` | — | ⚠️ per-group | `scripts/agent-monitor-packs/client/SubAgents.tsx:130` | per-type button count | per subagent-type count; no global total [bug FEA-1419] | - -## Packs - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `packs.installed.list.length` | — | ⚠️ DOM count | `scripts/agent-monitor-packs/client/PacksInstalled.tsx:139` | .map(packs) | installed-pack list .map; no header count | - -## KanbanBoard - -| tile id | tab | render_kind | file:line | component | rendered expression | -|---|---|---|---|---|---| -| `kanban.agents.working.count` | — | ✅ text | `upstream/KanbanBoard.tsx:397` | column count badge | groupedAgents['working']?.length ?? 0 | -| `kanban.agents.waiting.count` | — | ✅ text | `upstream/KanbanBoard.tsx:397` | column count badge | groupedAgents['waiting']?.length ?? 0 | -| `kanban.agents.completed.count` | — | ✅ text | `upstream/KanbanBoard.tsx:397` | column count badge | groupedAgents['completed']?.length ?? 0 | -| `kanban.agents.error.count` | — | ✅ text | `upstream/KanbanBoard.tsx:397` | column count badge | groupedAgents['error']?.length ?? 0 | diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-SIDECAR-REUSE.md b/apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-SIDECAR-REUSE.md deleted file mode 100644 index 36887d6f..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-SIDECAR-REUSE.md +++ /dev/null @@ -1,50 +0,0 @@ -# PHASE3-SIDECAR-REUSE - -> FEA-1437 pre-explorer pass (PLN-760). Question: can the existing `helpers/launch-sidecar.mjs` (used by the node:test API audits) also serve the Phase-3 Playwright UI tests, or does Playwright need its own boot path? - -**Parent:** FEA-1437 / PLN-760 · **Substrate:** FEA-1415 / PLN-738 (PR #246, `2a3a371`) - -## Answer: YES — reuse, and it's already wired. No second boot path. - -Pre-FEA-1407 the expected answer was "probably yes." Post-FEA-1407 it is **confirmed yes**, and PR #246 already ships the wiring. The boot *path* (`launchSidecar`) is shared between both test families today; only the *lifecycle wrapper* differs, for a structural reason (Playwright forks workers). - -### Evidence in the merged tree - -| consumer | how it boots | file | -|---|---|---| -| node:test API audits | call `launchSidecar({ dbPath })` directly in each file's `before()` | `specs/audit/all-screens.api-audit.test.mjs:35`, `pack-detail.audit.test.mjs:27`, `claude-hooks.contract.test.mjs:48` | -| Playwright UI specs | one shared sidecar booted in `globalSetup` → `baseUrl` handed to specs via a state file + `E2E_BASE_URL`; specs `page.goto("/")` | `helpers/playwright-global-setup.ts:20`, `playwright.config.ts:43` | - -Both call the **same** `launchSidecar`. It boots the same `.generated/agent-monitor/server/index.js` that the Electron host runs in production — which serves both the JSON API **and** the client bundle, so the `baseUrl` is directly navigable by Playwright (`page.goto(baseUrl + route)`), exactly as the four existing `specs/ui/*.spec.ts` already do. - -### Why there are two *wrappers* but one *boot path* - -- **node:test** boots a sidecar per test file (per-`before()`), because node:test runs in one process and each file owns its lifecycle. -- **Playwright** forks workers, so process-level globals don't survive. `globalSetup` boots **one** fixture-loaded sidecar, writes `{ baseUrl, pid, dbPath }` to a state file (`closedloop-e2e-sidecar-state.json`), and `playwright.config.ts` reads `baseURL` back from that file (it can't trust `process.env` because the config is evaluated before `globalSetup` runs — see the comment at `playwright.config.ts:5-10`). `globalTeardown` kills the pid and removes the temp DB dir. - -This is a lifecycle difference, not a boot difference. Phase 3 does **not** need a new boot path — it extends the existing `globalSetup`/`globalTeardown` pair already in the repo. - -## The two FEA-1407 concerns, re-confirmed - -### 1. Sandbox scoping (`SANDBOX_BASE_DIRECTORY`) — handled, automatically inherited ✅ - -FEA-1407 makes the hook handler silently drop events whose `data.cwd` falls outside `SANDBOX_BASE_DIRECTORY`. PR #246's merge resolution sets **`SANDBOX_BASE_DIRECTORY=/`** — and critically, it sets it **inside `launch-sidecar.mjs` itself** (`launch-sidecar.mjs:96-101`), not per-test. So every consumer of the helper inherits it for free. The PLN-760 risk row ("Phase 3 specs must inherit `SANDBOX_BASE_DIRECTORY=/` or hook fixtures silently no-op") is **already satisfied by construction** — there is nothing for Phase 3 to remember to set. The "one-line check in the helper" the feature doc asked for already exists. - -> Guard to keep: a Phase-3 reviewer should ensure no spec passes its own `env` to `launchSidecar` that *overrides* `SANDBOX_BASE_DIRECTORY` to something narrower. The helper spreads caller `env` last (`launch-sidecar.mjs:116`), so a careless override would win. Worth a lint/assert. - -### 2. Enqueue/drain hook handler — orthogonal to Playwright UI tests ✅ - -This is the key clarification. **Phase-3 Playwright UI tests never traverse the hook enqueue/drain path.** They read **pre-seeded** fixture data: `globalSetup` calls `seedFixtureDb(dbPath)` + `reseedPacksAndSkills(dbPath)` (`playwright-global-setup.ts:18-21`) to write rows directly into the temp SQLite, then the sidecar serves them through the unchanged API → UI. The hook handler (`POST /api/hooks/event`) is not in that loop. - -The hook handler — including the FEA-1407 cwd-sandbox-drop behavior and the enqueue/drain semantics — is exercised separately by `specs/audit/claude-hooks.contract.test.mjs`, which POSTs synthetic events to `/api/hooks/event` (`claude-hooks.contract.test.mjs:59`) with a fixture `cwd` that the widened sandbox (`/`) lets through. That coverage lives in the node:test family and stays there. - -**Implication for Phase 3:** the hook-handler re-confirmation FEA-1437 worried about is real, but it does **not** gate Playwright reuse — it's already covered by the contract test on the node:test side. Phase-3 UI specs depend only on (a) the shared boot path and (b) direct DB seeding, both of which are stable. - -## Recommendation for Phase 3 kickoff - -1. **Reuse `launchSidecar` + the existing `globalSetup`/`globalTeardown` as-is.** Do not write a second boot path. -2. New P0 specs go in **`specs/audit/`** (run via `playwright.audit.config.ts` / `test:audit:ui`), not `specs/ui/` — because audit specs intentionally assert current-buggy values (e.g. the FEA-1418 cost bug) and must not block the default `test:e2e` gate. The config split for this already exists (`playwright.config.ts:28-34`, `playwright.audit.config.ts:13-16`). -3. Build the Phase-3 helpers (`helpers/playwright-region.ts`, `helpers/playwright-oracle.ts`) on top of the shared `baseUrl` — no sidecar concerns leak into them. -4. Add a one-line assertion (or a comment) guarding against a spec overriding `SANDBOX_BASE_DIRECTORY` to a narrower path via the `env` arg. - -**Net:** sidecar reuse is a solved problem. Phase 3 starts from "boot is done" and spends its budget on DOM-slicing + oracle bridging, exactly as PLN-760's Phase-3 task list assumes. diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-WEAK-TRIAGE.md b/apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-WEAK-TRIAGE.md deleted file mode 100644 index b6e538a2..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-WEAK-TRIAGE.md +++ /dev/null @@ -1,156 +0,0 @@ -# PHASE3-WEAK-TRIAGE - -> Generated by the FEA-1437 pre-explorer pass (PLN-760). Triage of all **118 `cross_ref_weak`** detections in `coverage.json`. - -**Parent:** FEA-1437 / PLN-760 · **Substrate:** FEA-1415 / PLN-738 (PR #246, `2a3a371`) - -> **Status update (FEA-1437 Phase 6):** the 12 category-(c) detections below were reclassified to `out_of_scope` in `coverage-classifier.mjs` (new HEALTH_GAUGE / CONFIG_STATE / TIMESTAMP rules). `cross_ref_weak` is now **106** (was 118); `out_of_scope` is **41** (was 29). The remaining 106 are category (b) — chart aggregates needing the `covered_by` annotation convention. - -## Categories - -- **(a) auto-resolve via Phase-3 selector binding** — detection maps to a manifest tile that renders as DOM text; once the tile's `data-testid` lands, the classifier graduates it `cross_ref_weak → cross_ref`. **Count: 0** -- **(b) needs explicit covered-by annotation** — detection is a chart/sparkline/breakdown series, or a list/row count, whose underlying aggregate IS oracle-backed at the API layer but does NOT render as a sliceable per-value DOM node. Selector binding can't fix it; it needs an explicit `covered_by` note (API-layer assertion stands; DOM is a chart). **Count: 106** -- **(c) actually out_of_scope** — server-runtime/health gauges, local config state, and timestamp formatting: not log-derived numeric data. Reclassify to `out_of_scope`. **Count: 12** - -### The headline for the weak-count goal - -PLN-738/FEA-1437 set a target of driving `cross_ref_weak` to 0. This triage shows the path: - -- **12** detections should simply move to `out_of_scope` (category c) — they were never in scope; the scanner over-collected. -- **0** will fall out automatically as Phase 3 adds selectors (category a). **This being 0 is itself the headline finding:** none of the 118 weak detections are simple tile-text values that merely lack a `data-testid`. Every weak detection is either out-of-scope (c) or a chart aggregate (b), so the selector-binding work — though essential for the manifest tiles themselves — does **not** move the weak count. -- **106** are the real work (category b): they're chart aggregates. The honest resolution is an explicit `covered_by` annotation ("aggregate of , asserted at API layer; DOM is a chart") rather than pretending a tile-region text assertion exists. This is the bulk of the weak count and it lives mostly on the Dashboard (30-day cost/token charts, heatmaps, model breakdown) and the Analytics screen (same chart family). Note: the 4 Analytics cost detections fall here (not category a) because `coverage-classifier.mjs:178` binds candidates screen-scoped and there is no Analytics cost tile to bind to. - -> **Recommendation:** split the weak-count goal into "weak that should be out_of_scope" (mechanical, do now) and "weak that are chart aggregates" (needs the `covered_by` annotation convention + a classifier rule for chart-series detections). Do NOT chase a literal 0 by forcing data-testids onto chart internals. - -## Category (c) — actually out_of_scope (reclassify) — 12 detections - -| screen | detected_kind | value_expr | file:line | rationale | -|---|---|---|---|---| -| Dashboard | toFixed | `load` | `scripts/agent-monitor-client/Dashboard.tsx:762` | server-runtime / health gauge (CPU/mem/heap/healthScore/cacheHitRate) — not log-derived | -| Dashboard | toFixed | `memUsedPct` | `scripts/agent-monitor-client/Dashboard.tsx:788` | server-runtime / health gauge (CPU/mem/heap/healthScore/cacheHitRate) — not log-derived | -| Dashboard | toFixed | `heapUsedPct` | `scripts/agent-monitor-client/Dashboard.tsx:805` | server-runtime / health gauge (CPU/mem/heap/healthScore/cacheHitRate) — not log-derived | -| Dashboard | toFixed | `healthScore` | `scripts/agent-monitor-client/Dashboard.tsx:993` | server-runtime / health gauge (CPU/mem/heap/healthScore/cacheHitRate) — not log-derived | -| Dashboard | toFixed | `cacheHitRate` | `scripts/agent-monitor-client/Dashboard.tsx:1018` | server-runtime / health gauge (CPU/mem/heap/healthScore/cacheHitRate) — not log-derived | -| Dashboard | data_property | `info.hooks.installed` | `scripts/agent-monitor-client/Dashboard.tsx:1307` | local config state (hooks installed) — not log-derived data | -| Dashboard | data_property | `info.hooks.installed` | `scripts/agent-monitor-client/Dashboard.tsx:1309` | local config state (hooks installed) — not log-derived data | -| PacksInstalled | formatter_call | `fmt(a.last_seen_at)` | `scripts/agent-monitor-packs/client/PacksInstalled.tsx:180` | timestamp formatting (date, not a numeric audit target) | -| Skills | formatter_call | `fmt(inv.created_at)` | `scripts/agent-monitor-packs/client/Skills.tsx:188` | timestamp formatting (date, not a numeric audit target) | -| SubAgents | formatter_call | `fmt(d.started_at)` | `scripts/agent-monitor-packs/client/SubAgents.tsx:140` | timestamp formatting (date, not a numeric audit target) | -| Tools | formatter_call | `fmt(ev.created_at)` | `scripts/agent-monitor-packs/client/Tools.tsx:155` | timestamp formatting (date, not a numeric audit target) | -| Plans | formatter_call | `fmt(v.created_at)` | `scripts/agent-monitor-plans/client/Plans.tsx:209` | timestamp formatting (date, not a numeric audit target) | - -## Category (a) — auto-resolve via selector binding — 0 detections - -| screen | detected_kind | value_expr | file:line | rationale | -|---|---|---|---|---| - -## Category (b) — needs explicit covered-by annotation — 106 detections - -| screen | detected_kind | value_expr | file:line | rationale | -|---|---|---|---|---| -| Dashboard | toLocaleString | `cell.count` | `scripts/agent-monitor-client/Dashboard.tsx:214` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | math | `Math.max(` | `scripts/agent-monitor-client/Dashboard.tsx:295` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `count` | `scripts/agent-monitor-client/Dashboard.tsx:300` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCostFull(point.cost)` | `scripts/agent-monitor-client/Dashboard.tsx:366` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `count` | `scripts/agent-monitor-client/Dashboard.tsx:426` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmt(count)` | `scripts/agent-monitor-client/Dashboard.tsx:427` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCostFull(cost)` | `scripts/agent-monitor-client/Dashboard.tsx:457` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCost(cost)` | `scripts/agent-monitor-client/Dashboard.tsx:457` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | data_property | `a.input_tokens` | `scripts/agent-monitor-client/Dashboard.tsx:649` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | data_property | `a.output_tokens` | `scripts/agent-monitor-client/Dashboard.tsx:649` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | data_property | `m.input_tokens` | `scripts/agent-monitor-client/Dashboard.tsx:649` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | data_property | `m.output_tokens` | `scripts/agent-monitor-client/Dashboard.tsx:649` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | data_property | `seg.pct` | `scripts/agent-monitor-client/Dashboard.tsx:852` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | data_property | `seg.pct` | `scripts/agent-monitor-client/Dashboard.tsx:852` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `item.value` | `scripts/agent-monitor-client/Dashboard.tsx:909` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toFixed | `item.pct` | `scripts/agent-monitor-client/Dashboard.tsx:909` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | math | `Math.round(` | `scripts/agent-monitor-client/Dashboard.tsx:909` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `m.input_tokens` | `scripts/agent-monitor-client/Dashboard.tsx:1074` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `m.output_tokens` | `scripts/agent-monitor-client/Dashboard.tsx:1074` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `m.cache_read_tokens` | `scripts/agent-monitor-client/Dashboard.tsx:1074` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toFixed | `pct` | `scripts/agent-monitor-client/Dashboard.tsx:1074` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toFixed | `pct` | `scripts/agent-monitor-client/Dashboard.tsx:1074` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | math | `Math.min(` | `scripts/agent-monitor-client/Dashboard.tsx:1131` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `tool.count` | `scripts/agent-monitor-client/Dashboard.tsx:1205` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| Dashboard | math | `Math.round(` | `scripts/agent-monitor-client/Dashboard.tsx:1205` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | math | `Math.max(` | `scripts/agent-monitor-client/Dashboard.tsx:1444` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | data_property | `d.count` | `scripts/agent-monitor-client/Dashboard.tsx:1575` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | data_property | `d.count` | `scripts/agent-monitor-client/Dashboard.tsx:1621` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | data_property | `d.cost` | `scripts/agent-monitor-client/Dashboard.tsx:1634` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmt(totalTokens)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmt(Math.max(...last30.map((d)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmt(last30.reduce((s, d)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCostFull(peakCostDay.cost)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCost(peakCostDay.cost)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCostFull(totalCost30d)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCost(totalCost30d)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCost(cents / 100)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCostFull(b.cost)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCost(b.cost)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCostFull(totalCost30d)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmtCost(totalCost30d)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmt(totalTokens)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmt(total)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmt(segment.value)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmt(s.value)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | formatter_call | `fmt(s.value)` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `totalTokens` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `totalTokens` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `value` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `segment.value` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `s.value` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | toLocaleString | `s.value` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Dashboard | math | `Math.max(` | `scripts/agent-monitor-client/Dashboard.tsx:1794` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| CatalogCard | data_property | `pack.usage.sessions` | `scripts/agent-monitor-packs/client/CatalogCard.tsx:192` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| CatalogCard | data_property | `pack.usage.sessions` | `scripts/agent-monitor-packs/client/CatalogCard.tsx:192` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| CatalogDetail | data_property | `entry.installed_harnesses.length` | `scripts/agent-monitor-packs/client/CatalogDetail.tsx:247` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| CatalogDetail | data_property | `entry.harnesses.length` | `scripts/agent-monitor-packs/client/CatalogDetail.tsx:266` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| PacksCatalog | toLocaleString | `totalStars` | `scripts/agent-monitor-packs/client/PacksCatalog.tsx:65` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| PacksInstalled | data_property | `packs.length` | `scripts/agent-monitor-packs/client/PacksInstalled.tsx:115` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| SubAgents | data_property | `data.agents` | `scripts/agent-monitor-packs/client/SubAgents.tsx:51` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| Tools | data_property | `a.count` | `scripts/agent-monitor-packs/client/Tools.tsx:39` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| Tools | data_property | `data.events` | `scripts/agent-monitor-packs/client/Tools.tsx:75` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| Analytics | data_property | `cell.count` | `upstream/Analytics.tsx:171` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | data_property | `cell.count` | `upstream/Analytics.tsx:171` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | math | `Math.max(` | `upstream/Analytics.tsx:252` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCostFull(point.cost)` | `upstream/Analytics.tsx:323` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | toLocaleString | `count` | `upstream/Analytics.tsx:383` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmt(count)` | `upstream/Analytics.tsx:384` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCostFull(cost)` | `upstream/Analytics.tsx:414` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCost(cost)` | `upstream/Analytics.tsx:414` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmt(totalTokens)` | `upstream/Analytics.tsx:832` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | toLocaleString | `totalTokens` | `upstream/Analytics.tsx:833` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCost(costData.total_cost)` | `upstream/Analytics.tsx:840` | renders a cost total but there is NO Analytics cost manifest tile; coverage-classifier.mjs binds candidates screen-scoped (t.screen === row.screen), so the Dashboard Total Cost selector can't resolve it — needs an Analytics cost tile or an explicit covered-by annotation | -| Analytics | formatter_call | `fmtCostFull(costData.total_cost)` | `upstream/Analytics.tsx:841` | renders a cost total but there is NO Analytics cost manifest tile; coverage-classifier.mjs binds candidates screen-scoped (t.screen === row.screen), so the Dashboard Total Cost selector can't resolve it — needs an Analytics cost tile or an explicit covered-by annotation | -| Analytics | data_property | `costData.breakdown.length` | `upstream/Analytics.tsx:842` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| Analytics | data_property | `costData.breakdown.length` | `upstream/Analytics.tsx:842` | list/row count tied to a manifest dom_count/per_group tile — bind by counting rendered items or annotate covered-by | -| Analytics | math | `Math.max(` | `upstream/Analytics.tsx:882` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmt(Math.max(...last30.map((d)` | `upstream/Analytics.tsx:883` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | math | `Math.max(` | `upstream/Analytics.tsx:883` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | data_property | `d.count` | `upstream/Analytics.tsx:891` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmt(last30.reduce((s, d)` | `upstream/Analytics.tsx:892` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmt(totalTokens)` | `upstream/Analytics.tsx:926` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmt(total)` | `upstream/Analytics.tsx:926` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmt(segment.value)` | `upstream/Analytics.tsx:926` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | toLocaleString | `totalTokens` | `upstream/Analytics.tsx:926` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | toLocaleString | `value` | `upstream/Analytics.tsx:926` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | toLocaleString | `segment.value` | `upstream/Analytics.tsx:926` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | math | `Math.max(` | `upstream/Analytics.tsx:926` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCostFull(peakCostDay.cost)` | `upstream/Analytics.tsx:1049` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCost(peakCostDay.cost)` | `upstream/Analytics.tsx:1049` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCostFull(totalCost30d)` | `upstream/Analytics.tsx:1049` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCost(totalCost30d)` | `upstream/Analytics.tsx:1049` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCost(cents / 100)` | `upstream/Analytics.tsx:1082` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCostFull(b.cost)` | `upstream/Analytics.tsx:1082` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCost(b.cost)` | `upstream/Analytics.tsx:1082` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCostFull(costData?.total_cost ?? 0)` | `upstream/Analytics.tsx:1082` | renders a cost total but there is NO Analytics cost manifest tile; coverage-classifier.mjs binds candidates screen-scoped (t.screen === row.screen), so the Dashboard Total Cost selector can't resolve it — needs an Analytics cost tile or an explicit covered-by annotation | -| Analytics | formatter_call | `fmtCost(costData?.total_cost ?? 0)` | `upstream/Analytics.tsx:1082` | renders a cost total but there is NO Analytics cost manifest tile; coverage-classifier.mjs binds candidates screen-scoped (t.screen === row.screen), so the Dashboard Total Cost selector can't resolve it — needs an Analytics cost tile or an explicit covered-by annotation | -| Analytics | math | `Math.round(` | `upstream/Analytics.tsx:1082` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCostFull(totalCost30d)` | `upstream/Analytics.tsx:1123` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmtCost(totalCost30d)` | `upstream/Analytics.tsx:1123` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmt(s.value)` | `upstream/Analytics.tsx:1186` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | toLocaleString | `s.value` | `upstream/Analytics.tsx:1186` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | formatter_call | `fmt(s.value)` | `upstream/Analytics.tsx:1263` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | toLocaleString | `s.value` | `upstream/Analytics.tsx:1263` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Analytics | math | `Math.max(` | `upstream/Analytics.tsx:1286` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | -| Workflows | math | `Math.max(` | `upstream/Workflows.tsx:344` | chart/sparkline/breakdown series aggregate of token/cost/event data that IS oracle-backed at the API layer, but renders as a chart (no sliceable per-value DOM) — needs explicit covered-by annotation, will NOT auto-resolve via selector binding | diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/audit-runner.mjs b/apps/desktop/test-e2e/agent-monitor/inventory/audit-runner.mjs deleted file mode 100644 index 4fc1b0cc..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/audit-runner.mjs +++ /dev/null @@ -1,118 +0,0 @@ -// Shared audit machinery used by the API-audit (node:test) and UI-audit -// (Playwright) specs, plus the standalone report generator. -// -// One implementation, three call sites — so a change to comparison logic -// can't silently diverge across test layers. - -import { DatabaseSync } from "node:sqlite"; - -import { oracles } from "./oracles.mjs"; -import { applyFormatter } from "./formatters.mjs"; - -/** - * Resolve a dotted JSON path against an object. Supports array indexing. - * Returns undefined if any segment is missing. - * - * getField({ a: { b: [1, { c: 2 }] } }, "a.b.1.c") → 2 - */ -export function getField(obj, path) { - if (path == null || path === "") return obj; - let cur = obj; - for (const seg of String(path).split(".")) { - if (cur == null) return undefined; - cur = cur[seg]; - } - return cur; -} - -/** - * Compute the oracle expected value for a manifest row. - * Returns { expected, expectedFormatted } where expectedFormatted is the - * string the UI is supposed to render (formatter applied). - * - * @param {object} row - * @param {DatabaseSync} db - * @param {{ tzOffsetMinutes?: number, now?: Date }} [opts] - */ -export function computeOracle(row, db, opts = {}) { - const fn = oracles[row.oracle]; - if (!fn) { - throw new Error( - `Oracle "${row.oracle}" referenced by manifest row "${row.id}" is not ` + - `exported from inventory/oracles.mjs.`, - ); - } - const expected = fn(db, opts); - const expectedFormatted = row.formatter - ? applyFormatter(row.formatter, expected) - : undefined; - return { expected, expectedFormatted }; -} - -/** - * Open the same fixture DB the sidecar is reading and return a handle. - * Read-only mode is forbidden by DatabaseSync's surface, but we only call - * COUNT/SUM here so concurrent writes from the sidecar don't matter — SQLite - * file-locking handles it. - */ -export function openDb(dbPath) { - return new DatabaseSync(dbPath); -} - -/** - * Compare a rendered (or API-returned) value against the oracle. Returns - * { ok: true } if they agree, or { ok: false, ... } with diagnostic detail. - * - * Comparison strategy: - * - For numeric API audits: compare raw numeric value, with optional epsilon. - * - For UI audits: compare the formatted string from the oracle against the - * rendered text. The rendered text may include surrounding whitespace - * or sibling text; the caller is responsible for slicing first. - */ -export function compareNumeric(actual, expected, { eps = 0.005 } = {}) { - if (typeof actual !== "number" || !Number.isFinite(actual)) { - return { - ok: false, - reason: `actual is not a finite number: ${JSON.stringify(actual)}`, - actual, - expected, - }; - } - if (Math.abs(actual - expected) <= eps) return { ok: true, actual, expected }; - return { - ok: false, - reason: `numeric mismatch (|Δ| = ${Math.abs(actual - expected)})`, - actual, - expected, - }; -} - -export function compareString(actual, expected) { - const a = String(actual ?? "").trim(); - const e = String(expected ?? "").trim(); - if (a === e) return { ok: true, actual: a, expected: e }; - return { - ok: false, - reason: `string mismatch`, - actual: a, - expected: e, - }; -} - -/** - * Classify a disagreement by which layer is most likely wrong. - * Used by the report generator to populate triage hints. - * - * - If API value disagrees with oracle: API↔DB layer. - * - If API matches oracle but UI doesn't: UI↔API layer (rendering / formatter). - * - If both disagree the same way: parser↔DB or oracle (DB has wrong data). - * - * @param {{ apiOk: boolean, uiOk: boolean }} signals - */ -export function classifyDrift({ apiOk, uiOk }) { - if (apiOk && uiOk) return "agree"; - if (!apiOk && !uiOk) return "parser-or-oracle"; - if (!apiOk && uiOk) return "api-vs-db"; - if (apiOk && !uiOk) return "ui-vs-api"; - return "unknown"; -} diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/coverage-classifier.mjs b/apps/desktop/test-e2e/agent-monitor/inventory/coverage-classifier.mjs deleted file mode 100644 index 742f4074..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/coverage-classifier.mjs +++ /dev/null @@ -1,328 +0,0 @@ -// Auto-classifier: for every scanner detection in manifest.scanned.json, -// assign a coverage status. Output: coverage.json mapping -// (screen, file, line, valueExpr) → { status, reason, oracle?, manifest_id? }. -// -// Statuses: -// - tested: directly bound to a manifest tile with an oracle -// OR covered by a dedicated test file -// - cross_ref: same data summary as another `tested` row -// (e.g. tooltip vs main display of total_sessions) -// - bug_filed: audit fails, bug filed in closedloop -// - out_of_scope: not log-derived (server runtime, config, pagination, -// pure formatter, visualization-only) -// -// The classifier is rule-driven so re-running after a scanner change -// preserves intent. Unclassified rows surface as `needs_review` and the -// coverage-validator test fails until they get a status. - -import { readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const SCANNED = JSON.parse( - readFileSync(join(HERE, "manifest.scanned.json"), "utf8"), -); -const CURATED = JSON.parse( - readFileSync(join(HERE, "manifest.json"), "utf8"), -); - -// Index curated manifest by `(file path under apps/desktop, line)`-ish. -// Curated tiles don't carry file:line so we can't perfectly bind — the -// matching is by detected_kind + value_expr heuristics. For now, we just -// declare which manifest tile a given detection maps to via rule. - -// ---------------------------------------------------------------- RULES - -// "Out of scope: server-runtime metric" — anything reading server uptime, -// CPU, memory, transcript-cache stats, DB file size, etc. These come from -// /api/settings/info.{server, transcript_cache, db.size} which reflect -// the running process and disk, not parsed agent logs. -const SERVER_RUNTIME_PATTERNS = [ - /server\.(uptime|memory|cpu_load|node_version|platform|total_mem|free_mem|cpus|arch|ws_connections|memory\.|cpu_load\b)/i, - /transcript_cache\.(size|maxSize|hits|misses|hitRate)/i, - /\binfo\.db\.size\b/i, - /\binfo\.db\.pragmas\./i, - /\binfo\.db\.load_stats\./i, - /formatBytes|formatUptime/, -]; - -// "Out of scope: server-runtime health gauges" — composite/health figures -// computed from process state (CPU load, memory %, heap %, the weighted -// health-score index) and the cache-hit gauge in that same index. Not -// log-parsed agent data. (FEA-1437 weak-triage category c; these are bare -// local computed vars in Dashboard.tsx, distinct from token-derived metrics.) -const HEALTH_GAUGE_PATTERNS = [ - /^(load|memUsedPct|heapUsedPct|healthScore|cacheHitRate)$/, -]; - -// "Out of scope: local config/install state" — e.g. info.hooks.installed -// reflects whether Claude Code hooks are written to ~/.claude/settings.json, -// a config toggle, not a log-derived number. -const CONFIG_STATE_PATTERNS = [/^info\.hooks\./]; - -// "Out of scope: timestamp formatting" — fmt(...created_at/started_at/ -// last_seen_at/updated_at) renders a date, not a numeric audit target. -const TIMESTAMP_PATTERNS = [ - /^fmt\([a-z_]+\.(created_at|started_at|last_seen_at|updated_at)\)$/i, -]; - -// "Out of scope: filesystem-backed config" — CcConfig reads ~/.claude/*. -const CONFIG_PATTERNS = [/^CcConfig$/]; - -// "Out of scope: pagination/UI math" — Math.ceil(total/PAGE_SIZE), Math.min, -// page+1 / totalPages, etc. These are render-side derivations on already- -// audited totals, not new data summaries. -const PAGINATION_PATTERNS = [ - /Math\.(min|max)\([^)]*page/i, - /Math\.ceil\([^)]*PAGE_SIZE/i, - /totalPages|page\s*\+\s*1|p\s*-\s*1/, -]; - -// "Out of scope: visualization-only" — Sparkline component drawing math. -// The data it draws is sourced from another oracle-checked endpoint. -const VIZ_ONLY_PATTERNS = [/^Sparkline$/]; - -// "Out of scope: Run page control plane" — Run starts/stops processes; its -// numbers are real-time process state, not log aggregations. -const RUN_PATTERNS = [/^Run$/]; - -// "Out of scope: Settings page non-log fields" — claudeHome path, hooks -// install state, debug toggles. Pricing list is treated as config. -const SETTINGS_OUT_PATTERNS = [ - /^Settings$/, -]; - -// Screens with a per-row or per-id dedicated test file. Detections inside -// these screens are "cross_ref" against the dedicated test. -const DEDICATED_TEST_SCREENS = { - Sessions: { - file: "sessions.per-row.audit.test.mjs", - reason: "per-row session aggregates (agent_count, cost) covered by Sessions list per-row audit", - }, - SessionDetail: { - file: "session-detail.audit.test.mjs", - reason: "all numeric fields covered by per-session drill-in audit", - }, - PackDetail: { - file: "pack-detail.audit.test.mjs", - reason: "installs/skills/associations counts covered by per-pack audit", - }, - ActivityFeed: { - file: "all-screens.api-audit.test.mjs", - reason: "events.total covered by manifest tile", - }, - KanbanBoard: { - file: "all-screens.api-audit.test.mjs", - reason: "per-status column counts covered by 4 manifest tiles", - }, -}; - -// Screens with manifest tile coverage of their primary data summaries. -// Multiple scanner detections per screen tend to be the same value rendered -// as tile + tooltip + badge — they're cross-references to the manifest tile. -const MANIFEST_COVERED_SCREENS = new Set([ - "Dashboard", - "Analytics", - "Workflows", - "Tools", - "Plans", - "PullRequests", - "Skills", - "Packs", - "PacksInstalled", - "PacksCatalog", - "SubAgents", - "CatalogCard", - "CatalogDetail", -]); - -function classify(row) { - // 1. Out of scope by content pattern - if (SERVER_RUNTIME_PATTERNS.some((re) => re.test(row.value_expr))) { - return { - status: "out_of_scope", - reason: "server-runtime metric (uptime/memory/CPU/cache/file-size — not log-parsed data)", - }; - } - if (PAGINATION_PATTERNS.some((re) => re.test(row.value_expr))) { - return { - status: "out_of_scope", - reason: "pagination/UI math — derived render-side from already-audited totals", - }; - } - if (HEALTH_GAUGE_PATTERNS.some((re) => re.test(row.value_expr))) { - return { - status: "out_of_scope", - reason: "server-runtime health gauge (CPU load / memory % / heap % / composite health-score index / cache-hit gauge) — computed from process state, not log-parsed data", - }; - } - if (CONFIG_STATE_PATTERNS.some((re) => re.test(row.value_expr))) { - return { - status: "out_of_scope", - reason: "local config/install state (Claude hooks install flag) — a config toggle, not a log-derived number", - }; - } - if (TIMESTAMP_PATTERNS.some((re) => re.test(row.value_expr))) { - return { - status: "out_of_scope", - reason: "timestamp formatting (date display via fmt(...At)) — not a numeric audit target", - }; - } - - // 2. Out of scope by screen - if (CONFIG_PATTERNS.some((re) => re.test(row.screen))) { - return { - status: "out_of_scope", - reason: "CcConfig reads ~/.claude/* via filesystem, not the SQL DB — different audit scope", - }; - } - if (VIZ_ONLY_PATTERNS.some((re) => re.test(row.screen))) { - return { - status: "out_of_scope", - reason: "Sparkline is a visualization component; data sourced from oracle-checked endpoints", - }; - } - if (RUN_PATTERNS.some((re) => re.test(row.screen))) { - return { - status: "out_of_scope", - reason: "Run page is control-plane (process state, not log aggregations)", - }; - } - if (SETTINGS_OUT_PATTERNS.some((re) => re.test(row.screen))) { - return { - status: "out_of_scope", - reason: "Settings page surfaces host config (pricing list, hooks state, claudeHome) — not log-derived data summaries", - }; - } - - // 3. Cross-references to dedicated test files - if (DEDICATED_TEST_SCREENS[row.screen]) { - return { - status: "cross_ref", - covered_by: DEDICATED_TEST_SCREENS[row.screen].file, - reason: DEDICATED_TEST_SCREENS[row.screen].reason, - }; - } - - // 4. Manifest-covered screens — try to bind each detection to a specific - // manifest tile via value-expression substring matching against the - // tile's endpoint_field tail, oracle name tail, or id tail. A *bound* - // cross_ref proves the rendered value is one we audit; an *unbound* - // cross_ref (status: cross_ref_weak) flags that the screen has - // coverage but this specific detection didn't match — the kind of - // weak claim PR #246 codex-review finding [P2] #4 called out. - if (MANIFEST_COVERED_SCREENS.has(row.screen)) { - const candidates = CURATED.tiles.filter((t) => t.screen === row.screen); - const bound = bindDetectionToTile(row, candidates); - if (bound) { - return { - status: "cross_ref", - covered_by: "all-screens.api-audit.test.mjs", - bound_to_tile: bound.tile.id, - bound_via: bound.via, - reason: `value_expr matched manifest tile ${bound.tile.id} via ${bound.via} (token: "${bound.token}")`, - }; - } - return { - status: "cross_ref_weak", - covered_by: "all-screens.api-audit.test.mjs", - reason: `${row.screen} has manifest coverage but this detection (\`${row.value_expr}\`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3`, - }; - } - - // 5. Unclassified — will fail the coverage-validator test until handled. - return { status: "needs_review", reason: "no classifier rule matched" }; -} - -/** - * Try to match a detection's value_expr to a specific manifest tile by - * looking for tail-tokens of (endpoint_field | oracle | id) inside - * value_expr. Returns `{ tile, via, token }` on success, null on failure. - * - * The "tail" of a dotted path is the last segment. For oracle/id, we also - * strip a common prefix (e.g. `dashboard_total_sessions` → tail - * `total_sessions`). Matching is substring + case-insensitive to absorb - * surface variations (`stats.total_sessions.toLocaleString()` vs - * `total_sessions`). - */ -function bindDetectionToTile(row, candidates) { - const haystack = row.value_expr.toLowerCase(); - // Prefer longest matching token so we don't bind to a generic - // `total` if a specific `total_sessions` is available. - const matches = []; - for (const tile of candidates) { - const tokens = []; - if (tile.endpoint_field) { - tokens.push({ via: "endpoint_field", t: tile.endpoint_field.split(".").pop() }); - } - if (tile.oracle) { - const parts = tile.oracle.split("_"); - // Drop a known leading category like "dashboard"/"analytics" so the - // tail reads as `total_sessions` instead of `dashboard_total_sessions`. - const tail = parts.slice(1).join("_") || parts.join("_"); - if (tail) tokens.push({ via: "oracle_tail", t: tail }); - } - if (tile.id) { - const idTail = tile.id.split(".").pop(); - if (idTail) tokens.push({ via: "id_tail", t: idTail }); - } - for (const { via, t } of tokens) { - if (t && t.length >= 4 && haystack.includes(t.toLowerCase())) { - matches.push({ tile, via, token: t }); - } - } - } - if (matches.length === 0) return null; - matches.sort((a, b) => b.token.length - a.token.length); - return matches[0]; -} - -const coverage = { - $schema: "./coverage.schema.json", - // No generated_at timestamp — the git commit timestamp IS the generation - // time, and burning the wall-clock into the file caused a spurious diff - // every time anyone ran audit:classify, even when no classification - // actually changed (PR #246 review @ coverage.json:3). - source: "manifest.scanned.json", - total_detections: SCANNED.tiles.length, - by_status: { - tested: 0, - cross_ref: 0, - cross_ref_weak: 0, - bug_filed: 0, - out_of_scope: 0, - needs_review: 0, - }, - rows: [], -}; - -for (const tile of SCANNED.tiles) { - const c = classify(tile); - coverage.by_status[c.status] = (coverage.by_status[c.status] ?? 0) + 1; - coverage.rows.push({ - detection_id: tile.id, - screen: tile.screen, - file: tile.file, - line: tile.line, - detected_kind: tile.detected_kind, - value_expr: tile.value_expr, - ...c, - }); -} - -const OUT = join(HERE, "coverage.json"); -writeFileSync(OUT, JSON.stringify(coverage, null, 2)); -console.log(`Wrote ${OUT}`); -console.log( - `Coverage: tested=${coverage.by_status.tested} cross_ref=${coverage.by_status.cross_ref} cross_ref_weak=${coverage.by_status.cross_ref_weak} bug_filed=${coverage.by_status.bug_filed} out_of_scope=${coverage.by_status.out_of_scope} needs_review=${coverage.by_status.needs_review}`, -); -const totalAccounted = - coverage.by_status.tested + - coverage.by_status.cross_ref + - coverage.by_status.cross_ref_weak + - coverage.by_status.bug_filed + - coverage.by_status.out_of_scope; -console.log( - `Accounted: ${totalAccounted}/${SCANNED.tiles.length} (${((totalAccounted / SCANNED.tiles.length) * 100).toFixed(1)}%)`, -); diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/coverage.json b/apps/desktop/test-e2e/agent-monitor/inventory/coverage.json deleted file mode 100644 index 19465619..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/coverage.json +++ /dev/null @@ -1,2166 +0,0 @@ -{ - "$schema": "./coverage.schema.json", - "source": "manifest.scanned.json", - "total_detections": 196, - "by_status": { - "tested": 0, - "cross_ref": 49, - "cross_ref_weak": 106, - "bug_filed": 0, - "out_of_scope": 41, - "needs_review": 0 - }, - "rows": [ - { - "detection_id": "auto.dashboard.0", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 214, - "detected_kind": "toLocaleString", - "value_expr": "cell.count", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`cell.count`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.1", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 295, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`Math.max(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.2", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 300, - "detected_kind": "toLocaleString", - "value_expr": "count", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`count`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.3", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 366, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(point.cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCostFull(point.cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.4", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 426, - "detected_kind": "toLocaleString", - "value_expr": "count", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`count`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.5", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 427, - "detected_kind": "formatter_call", - "value_expr": "fmt(count)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmt(count)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.6", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 457, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCostFull(cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.7", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 457, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCost(cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.8", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 659, - "detected_kind": "data_property", - "value_expr": "a.input_tokens", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`a.input_tokens`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.9", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 659, - "detected_kind": "data_property", - "value_expr": "a.output_tokens", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`a.output_tokens`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.10", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 659, - "detected_kind": "data_property", - "value_expr": "m.input_tokens", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`m.input_tokens`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.11", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 659, - "detected_kind": "data_property", - "value_expr": "m.output_tokens", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`m.output_tokens`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.12", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 766, - "detected_kind": "formatter_call", - "value_expr": "formatUptime(info.server.uptime)", - "status": "out_of_scope", - "reason": "server-runtime metric (uptime/memory/CPU/cache/file-size — not log-parsed data)" - }, - { - "detection_id": "auto.dashboard.13", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 772, - "detected_kind": "toFixed", - "value_expr": "load", - "status": "out_of_scope", - "reason": "server-runtime health gauge (CPU load / memory % / heap % / composite health-score index / cache-hit gauge) — computed from process state, not log-parsed data" - }, - { - "detection_id": "auto.dashboard.14", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 785, - "detected_kind": "formatter_call", - "value_expr": "formatBytes(info.server.memory.rss)", - "status": "out_of_scope", - "reason": "server-runtime metric (uptime/memory/CPU/cache/file-size — not log-parsed data)" - }, - { - "detection_id": "auto.dashboard.15", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 798, - "detected_kind": "toFixed", - "value_expr": "memUsedPct", - "status": "out_of_scope", - "reason": "server-runtime health gauge (CPU load / memory % / heap % / composite health-score index / cache-hit gauge) — computed from process state, not log-parsed data" - }, - { - "detection_id": "auto.dashboard.16", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 815, - "detected_kind": "toFixed", - "value_expr": "heapUsedPct", - "status": "out_of_scope", - "reason": "server-runtime health gauge (CPU load / memory % / heap % / composite health-score index / cache-hit gauge) — computed from process state, not log-parsed data" - }, - { - "detection_id": "auto.dashboard.17", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 847, - "detected_kind": "formatter_call", - "value_expr": "formatBytes(info.db.size)", - "status": "out_of_scope", - "reason": "server-runtime metric (uptime/memory/CPU/cache/file-size — not log-parsed data)" - }, - { - "detection_id": "auto.dashboard.18", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 862, - "detected_kind": "data_property", - "value_expr": "seg.pct", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`seg.pct`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.19", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 862, - "detected_kind": "data_property", - "value_expr": "seg.pct", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`seg.pct`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.20", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 919, - "detected_kind": "toLocaleString", - "value_expr": "item.value", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`item.value`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.21", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 919, - "detected_kind": "toFixed", - "value_expr": "item.pct", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`item.pct`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.22", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 919, - "detected_kind": "math", - "value_expr": "Math.round(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`Math.round(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.23", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1003, - "detected_kind": "toFixed", - "value_expr": "healthScore", - "status": "out_of_scope", - "reason": "server-runtime health gauge (CPU load / memory % / heap % / composite health-score index / cache-hit gauge) — computed from process state, not log-parsed data" - }, - { - "detection_id": "auto.dashboard.24", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1028, - "detected_kind": "toFixed", - "value_expr": "cacheHitRate", - "status": "out_of_scope", - "reason": "server-runtime health gauge (CPU load / memory % / heap % / composite health-score index / cache-hit gauge) — computed from process state, not log-parsed data" - }, - { - "detection_id": "auto.dashboard.25", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1041, - "detected_kind": "toFixed", - "value_expr": "errorRate", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.health.workflow.errorPropagation.errorRate", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.health.workflow.errorPropagation.errorRate via endpoint_field (token: \"errorRate\")" - }, - { - "detection_id": "auto.dashboard.26", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1084, - "detected_kind": "toLocaleString", - "value_expr": "m.input_tokens", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`m.input_tokens`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.27", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1084, - "detected_kind": "toLocaleString", - "value_expr": "m.output_tokens", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`m.output_tokens`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.28", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1084, - "detected_kind": "toLocaleString", - "value_expr": "m.cache_read_tokens", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`m.cache_read_tokens`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.29", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1084, - "detected_kind": "toFixed", - "value_expr": "pct", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`pct`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.30", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1084, - "detected_kind": "toFixed", - "value_expr": "pct", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`pct`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.31", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1141, - "detected_kind": "math", - "value_expr": "Math.min(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`Math.min(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.32", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1215, - "detected_kind": "toLocaleString", - "value_expr": "tool.count", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`tool.count`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.33", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1215, - "detected_kind": "math", - "value_expr": "Math.round(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`Math.round(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.34", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1270, - "detected_kind": "toFixed", - "value_expr": "item.successRate", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.health.workflow.stats.successRate", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.health.workflow.stats.successRate via endpoint_field (token: \"successRate\")" - }, - { - "detection_id": "auto.dashboard.35", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1270, - "detected_kind": "toFixed", - "value_expr": "item.successRate", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.health.workflow.stats.successRate", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.health.workflow.stats.successRate via endpoint_field (token: \"successRate\")" - }, - { - "detection_id": "auto.dashboard.36", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1317, - "detected_kind": "data_property", - "value_expr": "info.hooks.installed", - "status": "out_of_scope", - "reason": "local config/install state (Claude hooks install flag) — a config toggle, not a log-derived number" - }, - { - "detection_id": "auto.dashboard.37", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1319, - "detected_kind": "data_property", - "value_expr": "info.hooks.installed", - "status": "out_of_scope", - "reason": "local config/install state (Claude hooks install flag) — a config toggle, not a log-derived number" - }, - { - "detection_id": "auto.dashboard.38", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1454, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`Math.max(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.39", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1472, - "detected_kind": "data_property", - "value_expr": "r.agents", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.health.db.counts.agents", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.health.db.counts.agents via endpoint_field (token: \"agents\")" - }, - { - "detection_id": "auto.dashboard.40", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1585, - "detected_kind": "data_property", - "value_expr": "d.count", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`d.count`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.41", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1631, - "detected_kind": "data_property", - "value_expr": "d.count", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`d.count`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.42", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1644, - "detected_kind": "data_property", - "value_expr": "d.cost", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`d.cost`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.43", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(analyticsData?.overview.total_sessions ?? stats?.total_sessions ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.monitor.total_sessions", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.monitor.total_sessions via endpoint_field (token: \"total_sessions\")" - }, - { - "detection_id": "auto.dashboard.44", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(analyticsData?.overview.total_agents ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.health.db.counts.agents", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.health.db.counts.agents via endpoint_field (token: \"agents\")" - }, - { - "detection_id": "auto.dashboard.45", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(totalTokens)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmt(totalTokens)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.46", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(costData.total_cost)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.monitor.total_cost", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.monitor.total_cost via endpoint_field (token: \"total_cost\")" - }, - { - "detection_id": "auto.dashboard.47", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(costData.total_cost)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.monitor.total_cost", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.monitor.total_cost via endpoint_field (token: \"total_cost\")" - }, - { - "detection_id": "auto.dashboard.48", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(analyticsData?.overview.total_events ?? stats?.total_events ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.monitor.total_events", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.monitor.total_events via endpoint_field (token: \"total_events\")" - }, - { - "detection_id": "auto.dashboard.49", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(Math.max(...last30.map((d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmt(Math.max(...last30.map((d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.50", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(last30.reduce((s, d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmt(last30.reduce((s, d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.51", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(peakCostDay.cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCostFull(peakCostDay.cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.52", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(peakCostDay.cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCost(peakCostDay.cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.53", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(totalCost30d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCostFull(totalCost30d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.54", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(totalCost30d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCost(totalCost30d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.55", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(cents / 100)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCost(cents / 100)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.56", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(b.cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCostFull(b.cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.57", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(b.cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCost(b.cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.58", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(costData?.total_cost ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.monitor.total_cost", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.monitor.total_cost via endpoint_field (token: \"total_cost\")" - }, - { - "detection_id": "auto.dashboard.59", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(costData?.total_cost ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.monitor.total_cost", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.monitor.total_cost via endpoint_field (token: \"total_cost\")" - }, - { - "detection_id": "auto.dashboard.60", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(totalCost30d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCostFull(totalCost30d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.61", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(totalCost30d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmtCost(totalCost30d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.62", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(totalTokens)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmt(totalTokens)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.63", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(total)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmt(total)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.64", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(segment.value)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmt(segment.value)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.65", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(analyticsData?.overview.total_sessions ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.monitor.total_sessions", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.monitor.total_sessions via endpoint_field (token: \"total_sessions\")" - }, - { - "detection_id": "auto.dashboard.66", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(s.value)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmt(s.value)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.67", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(analyticsData?.overview.total_agents ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "dashboard.health.db.counts.agents", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile dashboard.health.db.counts.agents via endpoint_field (token: \"agents\")" - }, - { - "detection_id": "auto.dashboard.68", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "formatter_call", - "value_expr": "fmt(s.value)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`fmt(s.value)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.69", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "toLocaleString", - "value_expr": "totalTokens", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`totalTokens`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.70", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "toLocaleString", - "value_expr": "totalTokens", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`totalTokens`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.71", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "toLocaleString", - "value_expr": "value", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`value`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.72", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "toLocaleString", - "value_expr": "segment.value", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`segment.value`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.73", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "toLocaleString", - "value_expr": "s.value", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`s.value`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.74", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "toLocaleString", - "value_expr": "s.value", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`s.value`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.dashboard.75", - "screen": "Dashboard", - "file": "apps/desktop/scripts/agent-monitor-client/Dashboard.tsx", - "line": 1804, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Dashboard has manifest coverage but this detection (`Math.max(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.sessions.76", - "screen": "Sessions", - "file": "apps/desktop/scripts/agent-monitor-client/Sessions.tsx", - "line": 295, - "detected_kind": "formatter_call", - "value_expr": "formatDateTime(session.last_activity || session.started_at)", - "status": "cross_ref", - "covered_by": "sessions.per-row.audit.test.mjs", - "reason": "per-row session aggregates (agent_count, cost) covered by Sessions list per-row audit" - }, - { - "detection_id": "auto.sessions.77", - "screen": "Sessions", - "file": "apps/desktop/scripts/agent-monitor-client/Sessions.tsx", - "line": 295, - "detected_kind": "formatter_call", - "value_expr": "formatDuration(session.started_at, session.ended_at)", - "status": "cross_ref", - "covered_by": "sessions.per-row.audit.test.mjs", - "reason": "per-row session aggregates (agent_count, cost) covered by Sessions list per-row audit" - }, - { - "detection_id": "auto.sessions.78", - "screen": "Sessions", - "file": "apps/desktop/scripts/agent-monitor-client/Sessions.tsx", - "line": 295, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(session.cost)", - "status": "cross_ref", - "covered_by": "sessions.per-row.audit.test.mjs", - "reason": "per-row session aggregates (agent_count, cost) covered by Sessions list per-row audit" - }, - { - "detection_id": "auto.sessions.79", - "screen": "Sessions", - "file": "apps/desktop/scripts/agent-monitor-client/Sessions.tsx", - "line": 295, - "detected_kind": "math", - "value_expr": "Math.min(", - "status": "cross_ref", - "covered_by": "sessions.per-row.audit.test.mjs", - "reason": "per-row session aggregates (agent_count, cost) covered by Sessions list per-row audit" - }, - { - "detection_id": "auto.catalogcard.80", - "screen": "CatalogCard", - "file": "apps/desktop/scripts/agent-monitor-packs/client/CatalogCard.tsx", - "line": 192, - "detected_kind": "data_property", - "value_expr": "pack.usage.sessions", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "CatalogCard has manifest coverage but this detection (`pack.usage.sessions`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.catalogcard.81", - "screen": "CatalogCard", - "file": "apps/desktop/scripts/agent-monitor-packs/client/CatalogCard.tsx", - "line": 192, - "detected_kind": "data_property", - "value_expr": "pack.usage.sessions", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "CatalogCard has manifest coverage but this detection (`pack.usage.sessions`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.catalogdetail.82", - "screen": "CatalogDetail", - "file": "apps/desktop/scripts/agent-monitor-packs/client/CatalogDetail.tsx", - "line": 247, - "detected_kind": "data_property", - "value_expr": "entry.installed_harnesses.length", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "CatalogDetail has manifest coverage but this detection (`entry.installed_harnesses.length`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.catalogdetail.83", - "screen": "CatalogDetail", - "file": "apps/desktop/scripts/agent-monitor-packs/client/CatalogDetail.tsx", - "line": 266, - "detected_kind": "data_property", - "value_expr": "entry.harnesses.length", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "CatalogDetail has manifest coverage but this detection (`entry.harnesses.length`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.packdetail.84", - "screen": "PackDetail", - "file": "apps/desktop/scripts/agent-monitor-packs/client/PackDetail.tsx", - "line": 232, - "detected_kind": "data_property", - "value_expr": "data.total", - "status": "cross_ref", - "covered_by": "pack-detail.audit.test.mjs", - "reason": "installs/skills/associations counts covered by per-pack audit" - }, - { - "detection_id": "auto.packdetail.85", - "screen": "PackDetail", - "file": "apps/desktop/scripts/agent-monitor-packs/client/PackDetail.tsx", - "line": 232, - "detected_kind": "data_property", - "value_expr": "data.total", - "status": "cross_ref", - "covered_by": "pack-detail.audit.test.mjs", - "reason": "installs/skills/associations counts covered by per-pack audit" - }, - { - "detection_id": "auto.packdetail.86", - "screen": "PackDetail", - "file": "apps/desktop/scripts/agent-monitor-packs/client/PackDetail.tsx", - "line": 357, - "detected_kind": "data_property", - "value_expr": "entry.installed_harnesses.length", - "status": "cross_ref", - "covered_by": "pack-detail.audit.test.mjs", - "reason": "installs/skills/associations counts covered by per-pack audit" - }, - { - "detection_id": "auto.packdetail.87", - "screen": "PackDetail", - "file": "apps/desktop/scripts/agent-monitor-packs/client/PackDetail.tsx", - "line": 376, - "detected_kind": "data_property", - "value_expr": "entry.harnesses.length", - "status": "cross_ref", - "covered_by": "pack-detail.audit.test.mjs", - "reason": "installs/skills/associations counts covered by per-pack audit" - }, - { - "detection_id": "auto.packdetail.88", - "screen": "PackDetail", - "file": "apps/desktop/scripts/agent-monitor-packs/client/PackDetail.tsx", - "line": 490, - "detected_kind": "data_property", - "value_expr": "skills.length", - "status": "cross_ref", - "covered_by": "pack-detail.audit.test.mjs", - "reason": "installs/skills/associations counts covered by per-pack audit" - }, - { - "detection_id": "auto.packdetail.89", - "screen": "PackDetail", - "file": "apps/desktop/scripts/agent-monitor-packs/client/PackDetail.tsx", - "line": 490, - "detected_kind": "data_property", - "value_expr": "skills.length", - "status": "cross_ref", - "covered_by": "pack-detail.audit.test.mjs", - "reason": "installs/skills/associations counts covered by per-pack audit" - }, - { - "detection_id": "auto.packdetail.90", - "screen": "PackDetail", - "file": "apps/desktop/scripts/agent-monitor-packs/client/PackDetail.tsx", - "line": 527, - "detected_kind": "math", - "value_expr": "Math.min(", - "status": "cross_ref", - "covered_by": "pack-detail.audit.test.mjs", - "reason": "installs/skills/associations counts covered by per-pack audit" - }, - { - "detection_id": "auto.packscatalog.91", - "screen": "PacksCatalog", - "file": "apps/desktop/scripts/agent-monitor-packs/client/PacksCatalog.tsx", - "line": 65, - "detected_kind": "toLocaleString", - "value_expr": "totalStars", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "PacksCatalog has manifest coverage but this detection (`totalStars`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.packsinstalled.92", - "screen": "PacksInstalled", - "file": "apps/desktop/scripts/agent-monitor-packs/client/PacksInstalled.tsx", - "line": 115, - "detected_kind": "data_property", - "value_expr": "packs.length", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "PacksInstalled has manifest coverage but this detection (`packs.length`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.packsinstalled.93", - "screen": "PacksInstalled", - "file": "apps/desktop/scripts/agent-monitor-packs/client/PacksInstalled.tsx", - "line": 181, - "detected_kind": "formatter_call", - "value_expr": "fmt(a.last_seen_at)", - "status": "out_of_scope", - "reason": "timestamp formatting (date display via fmt(...At)) — not a numeric audit target" - }, - { - "detection_id": "auto.skills.94", - "screen": "Skills", - "file": "apps/desktop/scripts/agent-monitor-packs/client/Skills.tsx", - "line": 189, - "detected_kind": "formatter_call", - "value_expr": "fmt(inv.created_at)", - "status": "out_of_scope", - "reason": "timestamp formatting (date display via fmt(...At)) — not a numeric audit target" - }, - { - "detection_id": "auto.sparkline.95", - "screen": "Sparkline", - "file": "apps/desktop/scripts/agent-monitor-packs/client/Sparkline.tsx", - "line": 21, - "detected_kind": "toFixed", - "value_expr": "x", - "status": "out_of_scope", - "reason": "Sparkline is a visualization component; data sourced from oracle-checked endpoints" - }, - { - "detection_id": "auto.sparkline.96", - "screen": "Sparkline", - "file": "apps/desktop/scripts/agent-monitor-packs/client/Sparkline.tsx", - "line": 21, - "detected_kind": "toFixed", - "value_expr": "y", - "status": "out_of_scope", - "reason": "Sparkline is a visualization component; data sourced from oracle-checked endpoints" - }, - { - "detection_id": "auto.sparkline.97", - "screen": "Sparkline", - "file": "apps/desktop/scripts/agent-monitor-packs/client/Sparkline.tsx", - "line": 21, - "detected_kind": "math", - "value_expr": "Math.min(", - "status": "out_of_scope", - "reason": "Sparkline is a visualization component; data sourced from oracle-checked endpoints" - }, - { - "detection_id": "auto.subagents.98", - "screen": "SubAgents", - "file": "apps/desktop/scripts/agent-monitor-packs/client/SubAgents.tsx", - "line": 51, - "detected_kind": "data_property", - "value_expr": "data.agents", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "SubAgents has manifest coverage but this detection (`data.agents`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.subagents.99", - "screen": "SubAgents", - "file": "apps/desktop/scripts/agent-monitor-packs/client/SubAgents.tsx", - "line": 140, - "detected_kind": "formatter_call", - "value_expr": "fmt(d.started_at)", - "status": "out_of_scope", - "reason": "timestamp formatting (date display via fmt(...At)) — not a numeric audit target" - }, - { - "detection_id": "auto.tools.100", - "screen": "Tools", - "file": "apps/desktop/scripts/agent-monitor-packs/client/Tools.tsx", - "line": 39, - "detected_kind": "data_property", - "value_expr": "a.count", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Tools has manifest coverage but this detection (`a.count`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.tools.101", - "screen": "Tools", - "file": "apps/desktop/scripts/agent-monitor-packs/client/Tools.tsx", - "line": 75, - "detected_kind": "data_property", - "value_expr": "data.events", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Tools has manifest coverage but this detection (`data.events`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.tools.102", - "screen": "Tools", - "file": "apps/desktop/scripts/agent-monitor-packs/client/Tools.tsx", - "line": 156, - "detected_kind": "formatter_call", - "value_expr": "fmt(ev.created_at)", - "status": "out_of_scope", - "reason": "timestamp formatting (date display via fmt(...At)) — not a numeric audit target" - }, - { - "detection_id": "auto.plans.103", - "screen": "Plans", - "file": "apps/desktop/scripts/agent-monitor-plans/client/Plans.tsx", - "line": 210, - "detected_kind": "formatter_call", - "value_expr": "fmt(v.created_at)", - "status": "out_of_scope", - "reason": "timestamp formatting (date display via fmt(...At)) — not a numeric audit target" - }, - { - "detection_id": "auto.activityfeed.104", - "screen": "ActivityFeed", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/ActivityFeed.tsx", - "line": 184, - "detected_kind": "math", - "value_expr": "Math.min(", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "events.total covered by manifest tile" - }, - { - "detection_id": "auto.activityfeed.105", - "screen": "ActivityFeed", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/ActivityFeed.tsx", - "line": 344, - "detected_kind": "formatter_call", - "value_expr": "formatTime(event.created_at)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "events.total covered by manifest tile" - }, - { - "detection_id": "auto.activityfeed.106", - "screen": "ActivityFeed", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/ActivityFeed.tsx", - "line": 344, - "detected_kind": "formatter_call", - "value_expr": "timeAgo(event.created_at)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "events.total covered by manifest tile" - }, - { - "detection_id": "auto.activityfeed.107", - "screen": "ActivityFeed", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/ActivityFeed.tsx", - "line": 344, - "detected_kind": "math", - "value_expr": "Math.min(", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "events.total covered by manifest tile" - }, - { - "detection_id": "auto.analytics.108", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 171, - "detected_kind": "data_property", - "value_expr": "cell.count", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`cell.count`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.109", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 171, - "detected_kind": "data_property", - "value_expr": "cell.count", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`cell.count`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.110", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 252, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`Math.max(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.111", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 323, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(point.cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCostFull(point.cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.112", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 383, - "detected_kind": "toLocaleString", - "value_expr": "count", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`count`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.113", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 384, - "detected_kind": "formatter_call", - "value_expr": "fmt(count)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmt(count)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.114", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 414, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCostFull(cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.115", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 414, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCost(cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.116", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 816, - "detected_kind": "formatter_call", - "value_expr": "fmt(data?.overview.total_sessions ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "analytics.overview.total_sessions", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile analytics.overview.total_sessions via endpoint_field (token: \"total_sessions\")" - }, - { - "detection_id": "auto.analytics.117", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 824, - "detected_kind": "formatter_call", - "value_expr": "fmt(data?.overview.total_agents ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "analytics.overview.total_agents", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile analytics.overview.total_agents via endpoint_field (token: \"total_agents\")" - }, - { - "detection_id": "auto.analytics.118", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 832, - "detected_kind": "formatter_call", - "value_expr": "fmt(totalTokens)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmt(totalTokens)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.119", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 833, - "detected_kind": "toLocaleString", - "value_expr": "totalTokens", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`totalTokens`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.120", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 840, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(costData.total_cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCost(costData.total_cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.121", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 841, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(costData.total_cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCostFull(costData.total_cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.122", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 842, - "detected_kind": "data_property", - "value_expr": "costData.breakdown.length", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`costData.breakdown.length`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.123", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 842, - "detected_kind": "data_property", - "value_expr": "costData.breakdown.length", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`costData.breakdown.length`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.124", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 852, - "detected_kind": "formatter_call", - "value_expr": "fmt(data?.overview.total_events ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "analytics.overview.total_events", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile analytics.overview.total_events via endpoint_field (token: \"total_events\")" - }, - { - "detection_id": "auto.analytics.125", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 882, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`Math.max(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.126", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 883, - "detected_kind": "formatter_call", - "value_expr": "fmt(Math.max(...last30.map((d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmt(Math.max(...last30.map((d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.127", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 883, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`Math.max(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.128", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 891, - "detected_kind": "data_property", - "value_expr": "d.count", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`d.count`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.129", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 892, - "detected_kind": "formatter_call", - "value_expr": "fmt(last30.reduce((s, d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmt(last30.reduce((s, d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.130", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 926, - "detected_kind": "formatter_call", - "value_expr": "fmt(totalTokens)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmt(totalTokens)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.131", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 926, - "detected_kind": "formatter_call", - "value_expr": "fmt(total)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmt(total)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.132", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 926, - "detected_kind": "formatter_call", - "value_expr": "fmt(segment.value)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmt(segment.value)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.133", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 926, - "detected_kind": "toLocaleString", - "value_expr": "totalTokens", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`totalTokens`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.134", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 926, - "detected_kind": "toLocaleString", - "value_expr": "value", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`value`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.135", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 926, - "detected_kind": "toLocaleString", - "value_expr": "segment.value", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`segment.value`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.136", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 926, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`Math.max(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.137", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1049, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(peakCostDay.cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCostFull(peakCostDay.cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.138", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1049, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(peakCostDay.cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCost(peakCostDay.cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.139", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1049, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(totalCost30d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCostFull(totalCost30d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.140", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1049, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(totalCost30d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCost(totalCost30d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.141", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1082, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(cents / 100)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCost(cents / 100)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.142", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1082, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(b.cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCostFull(b.cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.143", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1082, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(b.cost)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCost(b.cost)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.144", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1082, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(costData?.total_cost ?? 0)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCostFull(costData?.total_cost ?? 0)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.145", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1082, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(costData?.total_cost ?? 0)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCost(costData?.total_cost ?? 0)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.146", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1082, - "detected_kind": "math", - "value_expr": "Math.round(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`Math.round(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.147", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1123, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(totalCost30d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCostFull(totalCost30d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.148", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1123, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(totalCost30d)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmtCost(totalCost30d)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.149", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1182, - "detected_kind": "formatter_call", - "value_expr": "fmt(data?.overview.total_agents ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "analytics.overview.total_agents", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile analytics.overview.total_agents via endpoint_field (token: \"total_agents\")" - }, - { - "detection_id": "auto.analytics.150", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1186, - "detected_kind": "formatter_call", - "value_expr": "fmt(s.value)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmt(s.value)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.151", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1186, - "detected_kind": "toLocaleString", - "value_expr": "s.value", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`s.value`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.152", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1259, - "detected_kind": "formatter_call", - "value_expr": "fmt(data?.overview.total_sessions ?? 0)", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "bound_to_tile": "analytics.overview.total_sessions", - "bound_via": "endpoint_field", - "reason": "value_expr matched manifest tile analytics.overview.total_sessions via endpoint_field (token: \"total_sessions\")" - }, - { - "detection_id": "auto.analytics.153", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1263, - "detected_kind": "formatter_call", - "value_expr": "fmt(s.value)", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`fmt(s.value)`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.154", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1263, - "detected_kind": "toLocaleString", - "value_expr": "s.value", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`s.value`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.analytics.155", - "screen": "Analytics", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Analytics.tsx", - "line": 1286, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Analytics has manifest coverage but this detection (`Math.max(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - }, - { - "detection_id": "auto.ccconfig.156", - "screen": "CcConfig", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/CcConfig.tsx", - "line": 619, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "out_of_scope", - "reason": "CcConfig reads ~/.claude/* via filesystem, not the SQL DB — different audit scope" - }, - { - "detection_id": "auto.ccconfig.157", - "screen": "CcConfig", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/CcConfig.tsx", - "line": 772, - "detected_kind": "data_property", - "value_expr": "data.agents", - "status": "out_of_scope", - "reason": "CcConfig reads ~/.claude/* via filesystem, not the SQL DB — different audit scope" - }, - { - "detection_id": "auto.ccconfig.158", - "screen": "CcConfig", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/CcConfig.tsx", - "line": 1341, - "detected_kind": "data_property", - "value_expr": "p.enabled", - "status": "out_of_scope", - "reason": "CcConfig reads ~/.claude/* via filesystem, not the SQL DB — different audit scope" - }, - { - "detection_id": "auto.ccconfig.159", - "screen": "CcConfig", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/CcConfig.tsx", - "line": 1662, - "detected_kind": "formatter_call", - "value_expr": "formatBytes(s.size)", - "status": "out_of_scope", - "reason": "server-runtime metric (uptime/memory/CPU/cache/file-size — not log-parsed data)" - }, - { - "detection_id": "auto.ccconfig.160", - "screen": "CcConfig", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/CcConfig.tsx", - "line": 1756, - "detected_kind": "formatter_call", - "value_expr": "formatBytes(s.size)", - "status": "out_of_scope", - "reason": "server-runtime metric (uptime/memory/CPU/cache/file-size — not log-parsed data)" - }, - { - "detection_id": "auto.ccconfig.161", - "screen": "CcConfig", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/CcConfig.tsx", - "line": 1932, - "detected_kind": "formatter_call", - "value_expr": "formatBytes(m.size)", - "status": "out_of_scope", - "reason": "server-runtime metric (uptime/memory/CPU/cache/file-size — not log-parsed data)" - }, - { - "detection_id": "auto.ccconfig.162", - "screen": "CcConfig", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/CcConfig.tsx", - "line": 2722, - "detected_kind": "formatter_call", - "value_expr": "formatBytes(backup.size)", - "status": "out_of_scope", - "reason": "server-runtime metric (uptime/memory/CPU/cache/file-size — not log-parsed data)" - }, - { - "detection_id": "auto.kanbanboard.163", - "screen": "KanbanBoard", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/KanbanBoard.tsx", - "line": 83, - "detected_kind": "data_property", - "value_expr": "r.agents", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "per-status column counts covered by 4 manifest tiles" - }, - { - "detection_id": "auto.kanbanboard.164", - "screen": "KanbanBoard", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/KanbanBoard.tsx", - "line": 98, - "detected_kind": "data_property", - "value_expr": "r.sessions", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "per-status column counts covered by 4 manifest tiles" - }, - { - "detection_id": "auto.kanbanboard.165", - "screen": "KanbanBoard", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/KanbanBoard.tsx", - "line": 249, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "cross_ref", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "per-status column counts covered by 4 manifest tiles" - }, - { - "detection_id": "auto.run.166", - "screen": "Run", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Run.tsx", - "line": 1472, - "detected_kind": "data_property", - "value_expr": "tokens.cost", - "status": "out_of_scope", - "reason": "Run page is control-plane (process state, not log aggregations)" - }, - { - "detection_id": "auto.run.167", - "screen": "Run", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Run.tsx", - "line": 1666, - "detected_kind": "data_property", - "value_expr": "a.score", - "status": "out_of_scope", - "reason": "Run page is control-plane (process state, not log aggregations)" - }, - { - "detection_id": "auto.run.168", - "screen": "Run", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Run.tsx", - "line": 1666, - "detected_kind": "data_property", - "value_expr": "a.cmd.name.length", - "status": "out_of_scope", - "reason": "Run page is control-plane (process state, not log aggregations)" - }, - { - "detection_id": "auto.run.169", - "screen": "Run", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Run.tsx", - "line": 1709, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "out_of_scope", - "reason": "Run page is control-plane (process state, not log aggregations)" - }, - { - "detection_id": "auto.run.170", - "screen": "Run", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Run.tsx", - "line": 1737, - "detected_kind": "math", - "value_expr": "Math.min(", - "status": "out_of_scope", - "reason": "Run page is control-plane (process state, not log aggregations)" - }, - { - "detection_id": "auto.run.171", - "screen": "Run", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Run.tsx", - "line": 2748, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "out_of_scope", - "reason": "Run page is control-plane (process state, not log aggregations)" - }, - { - "detection_id": "auto.run.172", - "screen": "Run", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Run.tsx", - "line": 2758, - "detected_kind": "math", - "value_expr": "Math.min(", - "status": "out_of_scope", - "reason": "Run page is control-plane (process state, not log aggregations)" - }, - { - "detection_id": "auto.run.173", - "screen": "Run", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Run.tsx", - "line": 2864, - "detected_kind": "data_property", - "value_expr": "r.sessions", - "status": "out_of_scope", - "reason": "Run page is control-plane (process state, not log aggregations)" - }, - { - "detection_id": "auto.run.174", - "screen": "Run", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Run.tsx", - "line": 3057, - "detected_kind": "math", - "value_expr": "Math.min(", - "status": "out_of_scope", - "reason": "Run page is control-plane (process state, not log aggregations)" - }, - { - "detection_id": "auto.sessiondetail.175", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 168, - "detected_kind": "data_property", - "value_expr": "data.agents", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.176", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 406, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.177", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 500, - "detected_kind": "formatter_call", - "value_expr": "formatDateTime(session.started_at)", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.178", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 507, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(cost.total_cost)", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.179", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 616, - "detected_kind": "data_property", - "value_expr": "agents.length", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.180", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 616, - "detected_kind": "data_property", - "value_expr": "agents.length", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.181", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 794, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(row.cost, 4)", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.182", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 794, - "detected_kind": "toLocaleString", - "value_expr": "row.input_tokens", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.183", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 794, - "detected_kind": "toLocaleString", - "value_expr": "row.output_tokens", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.184", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 794, - "detected_kind": "toLocaleString", - "value_expr": "row.cache_read_tokens", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.185", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 794, - "detected_kind": "toLocaleString", - "value_expr": "row.cache_write_tokens", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.186", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 821, - "detected_kind": "formatter_call", - "value_expr": "fmtCostFull(cost.total_cost, 4)", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.sessiondetail.187", - "screen": "SessionDetail", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/SessionDetail.tsx", - "line": 883, - "detected_kind": "formatter_call", - "value_expr": "timeAgo(event.created_at)", - "status": "cross_ref", - "covered_by": "session-detail.audit.test.mjs", - "reason": "all numeric fields covered by per-session drill-in audit" - }, - { - "detection_id": "auto.settings.188", - "screen": "Settings", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Settings.tsx", - "line": 149, - "detected_kind": "math", - "value_expr": "Math.min(", - "status": "out_of_scope", - "reason": "Settings page surfaces host config (pricing list, hooks state, claudeHome) — not log-derived data summaries" - }, - { - "detection_id": "auto.settings.189", - "screen": "Settings", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Settings.tsx", - "line": 235, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "out_of_scope", - "reason": "Settings page surfaces host config (pricing list, hooks state, claudeHome) — not log-derived data summaries" - }, - { - "detection_id": "auto.settings.190", - "screen": "Settings", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Settings.tsx", - "line": 726, - "detected_kind": "formatter_call", - "value_expr": "fmtCost(animatedTotalCost)", - "status": "out_of_scope", - "reason": "Settings page surfaces host config (pricing list, hooks state, claudeHome) — not log-derived data summaries" - }, - { - "detection_id": "auto.settings.191", - "screen": "Settings", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Settings.tsx", - "line": 1138, - "detected_kind": "formatter_call", - "value_expr": "fmt(count)", - "status": "out_of_scope", - "reason": "Settings page surfaces host config (pricing list, hooks state, claudeHome) — not log-derived data summaries" - }, - { - "detection_id": "auto.settings.192", - "screen": "Settings", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Settings.tsx", - "line": 1138, - "detected_kind": "formatter_call", - "value_expr": "formatBytes(sysInfo.db.size)", - "status": "out_of_scope", - "reason": "server-runtime metric (uptime/memory/CPU/cache/file-size — not log-parsed data)" - }, - { - "detection_id": "auto.settings.193", - "screen": "Settings", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Settings.tsx", - "line": 1138, - "detected_kind": "toLocaleString", - "value_expr": "count", - "status": "out_of_scope", - "reason": "Settings page surfaces host config (pricing list, hooks state, claudeHome) — not log-derived data summaries" - }, - { - "detection_id": "auto.settings.194", - "screen": "Settings", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Settings.tsx", - "line": 1317, - "detected_kind": "formatter_call", - "value_expr": "formatUptime(sysInfo.server.uptime)", - "status": "out_of_scope", - "reason": "server-runtime metric (uptime/memory/CPU/cache/file-size — not log-parsed data)" - }, - { - "detection_id": "auto.workflows.195", - "screen": "Workflows", - "file": "node_modules/.pnpm/agent-dashboard-client@https+++codeload.github.com+hoangsonww+Claude-Code-Agent-Monitor_66e710f3b6a270bd3d6d9689bd6abe1e/node_modules/agent-dashboard-client/src/pages/Workflows.tsx", - "line": 344, - "detected_kind": "math", - "value_expr": "Math.max(", - "status": "cross_ref_weak", - "covered_by": "all-screens.api-audit.test.mjs", - "reason": "Workflows has manifest coverage but this detection (`Math.max(`) did not bind to a specific tile via endpoint_field/oracle/id substring match — needs explicit annotation in Phase 3" - } - ] -} \ No newline at end of file diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/formatters.mjs b/apps/desktop/test-e2e/agent-monitor/inventory/formatters.mjs deleted file mode 100644 index 80630e2f..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/formatters.mjs +++ /dev/null @@ -1,49 +0,0 @@ -// Mirrors the agent-dashboard client's value formatters so the audit runner -// can predict the rendered text from an oracle value. If the upstream -// formatter changes, this file must change with it — the build hard-gates -// would catch a divergent shape, but a silent locale/precision shift is -// exactly the kind of bug the audit is meant to surface. -// -// Source of truth: agent-dashboard-client/src/lib/format.ts (`fmt`, `fmtCost`). - -/** fmt(n) — large-number short form. Mirrors client `fmt`. */ -export function fmt(n) { - if (!Number.isFinite(n)) return "0"; - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return String(n); -} - -/** fmtCost(n) — short dollar form. Mirrors client `fmtCost`. */ -export function fmtCost(n) { - if (!Number.isFinite(n) || n < 0) return "$0.00"; - if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`; - if (n >= 1_000) return `$${(n / 1_000).toFixed(2)}K`; - return `$${n.toFixed(2)}`; -} - -/** Raw integer — just `String(n)`. Used for tiles that render the number - * directly without short-form (e.g., Active Agents tile renders - * `stats.active_agents` not `fmt(stats.active_agents)`). */ -export function raw_int(n) { - return String(Math.trunc(Number(n))); -} - -export const formatters = { fmt, fmtCost, raw_int }; - -/** - * Apply the named formatter from a manifest row. Returns a string. - * Throws if the formatter name is unknown — fail loudly rather than - * silently miscompare. - */ -export function applyFormatter(name, value) { - const fn = formatters[name]; - if (!fn) { - throw new Error( - `Unknown formatter "${name}" — add it to inventory/formatters.mjs ` + - `or fix the manifest row.`, - ); - } - return fn(value); -} diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/manifest-loader.mjs b/apps/desktop/test-e2e/agent-monitor/inventory/manifest-loader.mjs deleted file mode 100644 index 5cbc4fa3..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/manifest-loader.mjs +++ /dev/null @@ -1,83 +0,0 @@ -// Single entry point for reading the manifest. Centralizes the JSON parse and -// the (light) schema validation so callers don't drift apart. - -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { oracles } from "./oracles.mjs"; -import { formatters } from "./formatters.mjs"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const MANIFEST_PATH = join(HERE, "manifest.json"); - -function validateRow(row, kind) { - const need = (k) => { - if (row[k] === undefined || row[k] === null) { - throw new Error( - `Manifest ${kind} row "${row.id ?? "(no id)"}" is missing required field "${k}"`, - ); - } - }; - need("id"); - need("screen"); - need("route"); - need("oracle"); - need("priority"); - if (!oracles[row.oracle]) { - throw new Error( - `Manifest row "${row.id}" references oracle "${row.oracle}" — but it is ` + - `not exported from inventory/oracles.mjs. Add it or fix the manifest.`, - ); - } - if (row.formatter && !formatters[row.formatter]) { - throw new Error( - `Manifest row "${row.id}" references formatter "${row.formatter}" — but ` + - `it is not exported from inventory/formatters.mjs.`, - ); - } -} - -/** - * Load and validate the manifest. Throws on any schema/wiring error. - * Returns { tiles, structural }, each an array of rows. - */ -export function loadManifest() { - const raw = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")); - for (const row of raw.tiles ?? []) validateRow(row, "tile"); - for (const row of raw.structural_assertions ?? []) validateRow(row, "structural"); - return { tiles: raw.tiles ?? [], structural: raw.structural_assertions ?? [] }; -} - -/** Filter helper: return only manifest rows for a given screen/tab. */ -export function tilesForScreen(manifest, screen, tab = null) { - return manifest.tiles.filter( - (t) => t.screen === screen && (tab === null || t.tab === tab), - ); -} - -/** - * Resolve a manifest row's endpoint declaration to the URL the test runner - * should actually GET. Centralized here so the API audit and the report - * generator can't drift apart (PR #246 review @ all-screens.api-audit:62). - * - * Returns null for derived / UI-only tiles that have no API counterpart. - * - * Special cases: - * - /api/stats and /api/analytics always need tz_offset=0 to make their - * daily-bucketing deterministic in the audit DB. - * - The manifest writes /api/pricing/totalCost as a shorthand; the - * sidecar actually exposes /api/pricing/cost. - * - * Anything else passes through; a missing leading slash is prepended so - * the caller can write fetch(`${baseUrl}${url}`) without thinking about it. - */ -export function endpointUrlForRow(row) { - if (!row?.endpoint || row.endpoint === "derived") return null; - if (row.endpoint === "/api/stats") return "/api/stats?tz_offset=0"; - if (row.endpoint === "/api/analytics") return "/api/analytics?tz_offset=0"; - if (row.endpoint === "/api/pricing/totalCost") { - return "/api/pricing/cost?tz_offset=0"; - } - return row.endpoint.startsWith("/") ? row.endpoint : `/${row.endpoint}`; -} diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/manifest.json b/apps/desktop/test-e2e/agent-monitor/inventory/manifest.json deleted file mode 100644 index d1f4ed34..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/manifest.json +++ /dev/null @@ -1,1246 +0,0 @@ -{ - "$schema": "./manifest.schema.json", - "version": 1, - "description": "UI Numbers Audit — manifest of every numeric tile, with the API endpoint it pulls from and the oracle that computes the expected value from the fixture DB. See PLN-738 / FEA-1415. | FEA-1437 pre-explorer: every tile carries a `selector` object — see PHASE3-RENDERER-MAP.md. present_in_code is false for ALL tiles (no data-testid exists yet); each `value` is the PROPOSED selector to add as a Phase-3 follow-up.", - "tiles": [ - { - "id": "dashboard.monitor.total_sessions", - "screen": "Dashboard", - "tab": "Monitor", - "route": "/", - "label": "Total Sessions", - "selector_kind": "label_slice", - "endpoint": "/api/stats", - "endpoint_field": "total_sessions", - "oracle": "dashboard_total_sessions", - "formatter": "fmt", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-dashboard-monitor-total-sessions']", - "present_in_code": true, - "render_kind": "text", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx (StatPill value, testid added FEA-1437 Phase 3)", - "followup": "Done — data-testid added to the StatPill value element." - } - }, - { - "id": "dashboard.monitor.total_sessions.trend_active", - "screen": "Dashboard", - "tab": "Monitor", - "route": "/", - "label": "Total Sessions", - "trend_label": "active", - "selector_kind": "label_slice_trend", - "endpoint": "/api/stats", - "endpoint_field": "active_sessions", - "oracle": "dashboard_active_sessions", - "formatter": "raw_int", - "tile_kind": "trend", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-dashboard-monitor-total-sessions-trend-active']", - "present_in_code": true, - "render_kind": "text_sub", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx (StatPill sub, subTestid added FEA-1437 Phase 3)", - "followup": "Done — data-testid added to the StatPill sub element." - } - }, - { - "id": "dashboard.monitor.active_agents", - "screen": "Dashboard", - "tab": "Monitor", - "route": "/", - "label": "Active Agents", - "selector_kind": "label_slice", - "endpoint": "/api/stats", - "endpoint_field": "active_agents", - "oracle": "dashboard_active_agents", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-dashboard-monitor-active-agents']", - "present_in_code": true, - "render_kind": "text_sub", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx — 'Total Agents' StatPill SUB line ('{active_agents} active'), subTestid added FEA-1437 Phase 3. The pill VALUE is overview.total_agents (a different number); the audit binds the SUB element, where active_agents renders.", - "followup": "Done — subTestid on the Total Agents pill sub binds active_agents to its real render site (not the section heading the old label-slice coincidentally matched)." - } - }, - { - "id": "dashboard.monitor.active_subagents", - "screen": "Dashboard", - "tab": "Monitor", - "route": "/", - "label": "Active Subagents", - "selector_kind": "label_slice", - "endpoint": "derived", - "endpoint_field": "subagents_working_for_active_main_sessions", - "oracle": "dashboard_active_subagents", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": "FEA-1442", - "notes": "Computed in the client by fetching subagents per active main session — no single API field. Oracle replicates the same derivation. FEA-1442: no Active Subagents tile is rendered today — audit test.skips until the tile (or descope) lands.", - "selector": { - "value": "[data-testid='audit-dashboard-monitor-active-subagents']", - "present_in_code": false, - "render_kind": "dom_count", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx:1485", - "followup": "Either add a numeric total element with a data-testid, or test by counting rendered items under a data-testid'd agent-tree (allSubagents) container." - } - }, - { - "id": "dashboard.monitor.active_subagents.trend_total", - "screen": "Dashboard", - "tab": "Monitor", - "route": "/", - "label": "Active Subagents", - "trend_label": "total", - "selector_kind": "label_slice_trend", - "endpoint": "derived", - "endpoint_field": "subagents_total_for_active_main_sessions", - "oracle": "dashboard_total_subagents_for_active", - "formatter": "raw_int", - "tile_kind": "trend", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": "FEA-1442", - "selector": { - "value": "[data-testid='audit-dashboard-monitor-active-subagents-trend-total']", - "present_in_code": false, - "render_kind": "dom_count", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx:1485", - "followup": "Either add a numeric total element with a data-testid, or test by counting rendered items under a data-testid'd agent-tree (allSubagents) container." - } - }, - { - "id": "dashboard.monitor.events_today", - "screen": "Dashboard", - "tab": "Monitor", - "route": "/", - "label": "Events Today", - "selector_kind": "label_slice", - "endpoint": "/api/stats", - "endpoint_field": "events_today", - "oracle": "dashboard_events_today", - "formatter": "fmt", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": "FEA-1443", - "notes": "Depends on the sidecar's interpretation of 'today' — UTC midnight vs. tz_offset. /api/stats accepts tz_offset. FEA-1443: no Events Today tile is rendered today — audit test.skips until the tile (or descope) lands.", - "selector": { - "value": "[data-testid='audit-dashboard-monitor-events-today']", - "present_in_code": false, - "render_kind": "absent", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx (no single line)", - "followup": "Tile is NOT rendered in the current overlay — add the tile (with data-testid) or reclassify the manifest row." - } - }, - { - "id": "dashboard.monitor.total_events", - "screen": "Dashboard", - "tab": "Monitor", - "route": "/", - "label": "Total Events", - "selector_kind": "label_slice", - "endpoint": "/api/stats", - "endpoint_field": "total_events", - "oracle": "dashboard_total_events", - "formatter": "fmt", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-dashboard-monitor-total-events']", - "present_in_code": true, - "render_kind": "text", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx (StatPill value, testid added FEA-1437 Phase 3)", - "followup": "Done — data-testid added to the StatPill value element." - } - }, - { - "id": "dashboard.monitor.total_cost", - "screen": "Dashboard", - "tab": "Monitor", - "route": "/", - "label": "Total Cost", - "selector_kind": "label_slice", - "endpoint": "/api/pricing/totalCost", - "endpoint_field": "total_cost", - "oracle": "dashboard_total_cost", - "formatter": "fmtCost", - "tile_kind": "money", - "priority": "P0", - "owner": null, - "status": "bug_filed", - "bug_ref": "FEA-1418", - "notes": "Oracle replicates the per-row pricing formula: sum over token_usage of (input*input_per_mtok + output*output_per_mtok + cache_read*cache_read_per_mtok + cache_write*cache_write_per_mtok) / 1e6.", - "selector": { - "value": "[data-testid='audit-dashboard-monitor-total-cost']", - "present_in_code": true, - "render_kind": "text", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx (StatPill value, testid added FEA-1437 Phase 3)", - "followup": "Done — data-testid added to the StatPill value element." - } - }, - { - "id": "analytics.tokens.total_input", - "screen": "Analytics", - "route": "/analytics", - "label": "input tokens", - "selector_kind": "label_slice", - "endpoint": "/api/analytics", - "endpoint_field": "tokens.total_input", - "oracle": "analytics_tokens_total_input", - "formatter": "raw_int", - "tile_kind": "sum", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-analytics-tokens-total-input']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/Analytics.tsx:937", - "followup": "Add data-testid to the token bar list value element, keyed per tile." - } - }, - { - "id": "analytics.tokens.total_output", - "screen": "Analytics", - "route": "/analytics", - "label": "output tokens", - "selector_kind": "label_slice", - "endpoint": "/api/analytics", - "endpoint_field": "tokens.total_output", - "oracle": "analytics_tokens_total_output", - "formatter": "raw_int", - "tile_kind": "sum", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-analytics-tokens-total-output']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/Analytics.tsx:940", - "followup": "Add data-testid to the token bar list value element, keyed per tile." - } - }, - { - "id": "analytics.tokens.total_cache_read", - "screen": "Analytics", - "route": "/analytics", - "label": "cache read tokens", - "selector_kind": "label_slice", - "endpoint": "/api/analytics", - "endpoint_field": "tokens.total_cache_read", - "oracle": "analytics_tokens_total_cache_read", - "formatter": "raw_int", - "tile_kind": "sum", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-analytics-tokens-total-cache-read']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/Analytics.tsx:945", - "followup": "Add data-testid to the token bar list value element, keyed per tile." - } - }, - { - "id": "analytics.tokens.total_cache_write", - "screen": "Analytics", - "route": "/analytics", - "label": "cache write tokens", - "selector_kind": "label_slice", - "endpoint": "/api/analytics", - "endpoint_field": "tokens.total_cache_write", - "oracle": "analytics_tokens_total_cache_write", - "formatter": "raw_int", - "tile_kind": "sum", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-analytics-tokens-total-cache-write']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/Analytics.tsx:950", - "followup": "Add data-testid to the token bar list value element, keyed per tile." - } - }, - { - "id": "analytics.avg_events_per_session", - "screen": "Analytics", - "route": "/analytics", - "label": "avg events per session", - "selector_kind": "label_slice", - "endpoint": "/api/analytics", - "endpoint_field": "avg_events_per_session", - "oracle": "analytics_avg_events_per_session", - "formatter": "raw_int", - "tile_kind": "avg", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-analytics-avg-events-per-session']", - "present_in_code": false, - "render_kind": "text_sub", - "renders_at": "upstream/Analytics.tsx:854", - "followup": "Add data-testid to the StatPill.sub secondary (sub) text element." - } - }, - { - "id": "analytics.total_subagents", - "screen": "Analytics", - "route": "/analytics", - "label": "total subagents", - "selector_kind": "label_slice", - "endpoint": "/api/analytics", - "endpoint_field": "total_subagents", - "oracle": "analytics_total_subagents", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-analytics-total-subagents']", - "present_in_code": false, - "render_kind": "per_group", - "renders_at": "upstream/Analytics.tsx:1160", - "followup": "Add a single aggregate count element (sum across groups) with a data-testid; today only per-group counts render in .map(agent_types)." - } - }, - { - "id": "analytics.overview.total_sessions", - "screen": "Analytics", - "route": "/analytics", - "label": "total sessions (analytics overview)", - "selector_kind": "label_slice", - "endpoint": "/api/analytics", - "endpoint_field": "overview.total_sessions", - "oracle": "analytics_overview_total_sessions", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "notes": "Same number as /api/stats.total_sessions. If the two endpoints disagree, that's a bug (same DB, two reads).", - "selector": { - "value": "[data-testid='audit-analytics-overview-total-sessions']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/Analytics.tsx:816", - "followup": "Add data-testid to the StatPill value element, keyed per tile." - } - }, - { - "id": "analytics.overview.total_agents", - "screen": "Analytics", - "route": "/analytics", - "label": "total agents (analytics overview)", - "selector_kind": "label_slice", - "endpoint": "/api/analytics", - "endpoint_field": "overview.total_agents", - "oracle": "analytics_overview_total_agents", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-analytics-overview-total-agents']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/Analytics.tsx:824", - "followup": "Add data-testid to the StatPill value element, keyed per tile." - } - }, - { - "id": "analytics.overview.total_events", - "screen": "Analytics", - "route": "/analytics", - "label": "total events (analytics overview)", - "selector_kind": "label_slice", - "endpoint": "/api/analytics", - "endpoint_field": "overview.total_events", - "oracle": "analytics_overview_total_events", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-analytics-overview-total-events']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/Analytics.tsx:852", - "followup": "Add data-testid to the StatPill value element, keyed per tile." - } - }, - { - "id": "analytics.overview.active_sessions", - "screen": "Analytics", - "route": "/analytics", - "label": "active sessions (analytics overview)", - "selector_kind": "label_slice", - "endpoint": "/api/analytics", - "endpoint_field": "overview.active_sessions", - "oracle": "analytics_overview_active_sessions", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-analytics-overview-active-sessions']", - "present_in_code": false, - "render_kind": "text_sub", - "renders_at": "upstream/Analytics.tsx:818", - "followup": "Add data-testid to the StatPill.sub secondary (sub) text element." - } - }, - { - "id": "analytics.overview.active_agents", - "screen": "Analytics", - "route": "/analytics", - "label": "active agents (analytics overview)", - "selector_kind": "label_slice", - "endpoint": "/api/analytics", - "endpoint_field": "overview.active_agents", - "oracle": "analytics_overview_active_agents", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-analytics-overview-active-agents']", - "present_in_code": false, - "render_kind": "text_sub", - "renders_at": "upstream/Analytics.tsx:826", - "followup": "Add data-testid to the StatPill.sub secondary (sub) text element." - } - }, - { - "id": "pr.stats.pull_requests", - "screen": "PullRequests", - "route": "/pull-requests", - "label": "total pull requests", - "selector_kind": "label_slice", - "endpoint": "/api/pull-requests/stats", - "endpoint_field": "pull_requests", - "oracle": "pull_requests_total", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-pr-stats-pull-requests']", - "present_in_code": true, - "render_kind": "text", - "renders_at": "scripts/agent-monitor-pull-requests/client/PullRequests.tsx (summary stat grid value, testid added FEA-1437 Phase 3)", - "followup": "Done — data-testid added to the stat grid value element." - } - }, - { - "id": "pr.stats.sessions_with_pr", - "screen": "PullRequests", - "route": "/pull-requests", - "label": "sessions with PRs", - "selector_kind": "label_slice", - "endpoint": "/api/pull-requests/stats", - "endpoint_field": "sessions_with_pull_requests", - "oracle": "pull_requests_sessions_with_pr", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-pr-stats-sessions-with-pr']", - "present_in_code": true, - "render_kind": "text", - "renders_at": "scripts/agent-monitor-pull-requests/client/PullRequests.tsx (summary stat grid value, testid added FEA-1437 Phase 3)", - "followup": "Done — data-testid added to the stat grid value element." - } - }, - { - "id": "pr.stats.repos", - "screen": "PullRequests", - "route": "/pull-requests", - "label": "distinct repos", - "selector_kind": "label_slice", - "endpoint": "/api/pull-requests/stats", - "endpoint_field": "repos", - "oracle": "pull_requests_repos", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-pr-stats-repos']", - "present_in_code": true, - "render_kind": "text", - "renders_at": "scripts/agent-monitor-pull-requests/client/PullRequests.tsx (summary stat grid value, testid added FEA-1437 Phase 3)", - "followup": "Done — data-testid added to the stat grid value element." - } - }, - { - "id": "dashboard.health.db.counts.sessions", - "screen": "Dashboard", - "tab": "Health", - "route": "/", - "label": "DB Sessions count", - "selector_kind": "label_slice", - "endpoint": "/api/settings/info", - "endpoint_field": "db.counts.sessions", - "oracle": "db_counts_sessions", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "notes": "Same number as /api/stats.total_sessions. Cross-endpoint disagreement = bug.", - "selector": { - "value": "[data-testid='audit-dashboard-health-db-counts-sessions']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx:912", - "followup": "Add data-testid to the Health Tip card value element, keyed per tile." - } - }, - { - "id": "dashboard.health.db.counts.agents", - "screen": "Dashboard", - "tab": "Health", - "route": "/", - "label": "DB Agents count", - "selector_kind": "label_slice", - "endpoint": "/api/settings/info", - "endpoint_field": "db.counts.agents", - "oracle": "db_counts_agents", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-dashboard-health-db-counts-agents']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx:918", - "followup": "Add data-testid to the Health Tip card value element, keyed per tile." - } - }, - { - "id": "dashboard.health.db.counts.events", - "screen": "Dashboard", - "tab": "Health", - "route": "/", - "label": "DB Events count", - "selector_kind": "label_slice", - "endpoint": "/api/settings/info", - "endpoint_field": "db.counts.events", - "oracle": "db_counts_events", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-dashboard-health-db-counts-events']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx:924", - "followup": "Add data-testid to the Health Tip card value element, keyed per tile." - } - }, - { - "id": "dashboard.health.workflow.compaction.totalCompactions", - "screen": "Dashboard", - "tab": "Health", - "route": "/", - "label": "compactions", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "compaction.totalCompactions", - "oracle": "workflow_total_compactions", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-dashboard-health-workflow-compaction-totalCompactions']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx:1042", - "followup": "Add data-testid to the Health Tip card value element, keyed per tile." - } - }, - { - "id": "dashboard.health.workflow.stats.successRate", - "screen": "Dashboard", - "tab": "Health", - "route": "/", - "label": "agent success rate", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "stats.successRate", - "oracle": "workflow_success_rate", - "formatter": "raw_int", - "tile_kind": "percentage", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-dashboard-health-workflow-stats-successRate']", - "present_in_code": false, - "render_kind": "per_group", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx:1260", - "followup": "Expose the aggregate success-rate PERCENTAGE (the workflow_success_rate oracle), not a summed count, with a data-testid. The per-row item.successRate values are percentages and cannot be summed." - } - }, - { - "id": "workflows.stats.totalSessions", - "screen": "Workflows", - "route": "/workflows", - "label": "total sessions (workflows)", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "stats.totalSessions", - "oracle": "workflow_total_sessions", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-stats-totalSessions']", - "present_in_code": false, - "render_kind": "chart_only", - "renders_at": "upstream/components/workflows/* (no single line)", - "followup": "Value renders only inside a chart/tooltip (ErrorPropagationMap/CompactionImpact denominators). Add a screen-reader / data-testid'd numeric caption, OR cover at the API layer only and annotate the tile as DOM-exempt." - } - }, - { - "id": "workflows.stats.totalAgents", - "screen": "Workflows", - "route": "/workflows", - "label": "total agents (workflows)", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "stats.totalAgents", - "oracle": "workflow_total_agents", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-stats-totalAgents']", - "present_in_code": false, - "render_kind": "chart_only", - "renders_at": "upstream/components/workflows/ModelDelegationFlow.tsx:273", - "followup": "Value renders only inside a chart/tooltip (internal countTotalAgents()). Add a screen-reader / data-testid'd numeric caption, OR cover at the API layer only and annotate the tile as DOM-exempt." - } - }, - { - "id": "workflows.stats.totalSubagents", - "screen": "Workflows", - "route": "/workflows", - "label": "total subagents", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "stats.totalSubagents", - "oracle": "workflow_total_subagents", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-stats-totalSubagents']", - "present_in_code": false, - "render_kind": "chart_only", - "renders_at": "upstream/components/workflows/* (no single line)", - "followup": "Value renders only inside a chart/tooltip (—). Add a screen-reader / data-testid'd numeric caption, OR cover at the API layer only and annotate the tile as DOM-exempt." - } - }, - { - "id": "workflows.stats.avgSubagents", - "screen": "Workflows", - "route": "/workflows", - "label": "avg subagents per session", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "stats.avgSubagents", - "oracle": "workflow_avg_subagents", - "formatter": "raw_int", - "tile_kind": "avg", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-stats-avgSubagents']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/components/workflows/WorkflowStats.tsx:287", - "followup": "Add data-testid to the StatCard value element, keyed per tile." - } - }, - { - "id": "workflows.stats.avgCompactions", - "screen": "Workflows", - "route": "/workflows", - "label": "avg compactions per session", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "stats.avgCompactions", - "oracle": "workflow_avg_compactions", - "formatter": "raw_int", - "tile_kind": "avg", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-stats-avgCompactions']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/components/workflows/WorkflowStats.tsx:314", - "followup": "Add data-testid to the StatCard value element, keyed per tile." - } - }, - { - "id": "workflows.stats.avgDurationSec", - "screen": "Workflows", - "route": "/workflows", - "label": "avg session duration", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "stats.avgDurationSec", - "oracle": "workflow_avg_duration_sec", - "formatter": "raw_int", - "tile_kind": "avg", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-stats-avgDurationSec']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/components/workflows/WorkflowStats.tsx:323", - "followup": "Add data-testid to the StatCard value element, keyed per tile." - } - }, - { - "id": "workflows.stats.avgDepth", - "screen": "Workflows", - "route": "/workflows", - "label": "avg agent tree depth", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "stats.avgDepth", - "oracle": "workflow_avg_depth", - "formatter": "raw_int", - "tile_kind": "avg", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-stats-avgDepth']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/components/workflows/WorkflowStats.tsx:278", - "followup": "Add data-testid to the StatCard value element, keyed per tile." - } - }, - { - "id": "tools.list.length", - "screen": "Tools", - "route": "/tools", - "label": "distinct tools", - "selector_kind": "section_card_count", - "endpoint": "/api/events/facets", - "endpoint_field": "tool_names.length", - "oracle": "events_facets_tool_names_length", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-tool-row']", - "present_in_code": true, - "render_kind": "dom_count", - "renders_at": "scripts/agent-monitor-packs/client/Tools.tsx (one button per distinct tool from /api/events/facets; testid added FEA-1437 Phase 4)", - "followup": "Done — each tool row has data-testid='audit-tool-row'; the audit asserts toHaveCount == events_facets_tool_names_length." - } - }, - { - "id": "activityfeed.events.total", - "screen": "ActivityFeed", - "route": "/activity", - "label": "total events (pagination)", - "selector_kind": "label_slice", - "endpoint": "/api/events?limit=100", - "endpoint_field": "total", - "oracle": "activityfeed_total_events", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-activityfeed-events-total']", - "present_in_code": false, - "render_kind": "text_embed", - "renders_at": "upstream/ActivityFeed.tsx:446", - "followup": "Wrap the numeric token in a data-testid span inside the pagination footer string (i18n interpolation must keep the number in its own element)." - } - }, - { - "id": "sessions.list.total", - "screen": "Sessions", - "route": "/sessions", - "label": "total sessions (pagination)", - "selector_kind": "label_slice", - "endpoint": "/api/sessions?limit=50", - "endpoint_field": "total", - "oracle": "dashboard_total_sessions", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "notes": "Cross-check: /api/sessions.total should equal /api/stats.total_sessions. Different endpoints, same DB.", - "selector": { - "value": "[data-testid='audit-sessions-list-total']", - "present_in_code": true, - "render_kind": "text_embed", - "renders_at": "scripts/agent-monitor-client/Sessions.tsx (i18n subtitle

, testid added FEA-1437 Phase 3)", - "followup": "Done — data-testid on the subtitle

; the count token is asserted within the i18n string via \\b\\b." - } - }, - { - "id": "plans.total", - "screen": "Plans", - "route": "/plans", - "label": "total plans", - "selector_kind": "label_slice", - "endpoint": "/api/plans?limit=200", - "endpoint_field": "total", - "oracle": "plans_total", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-plan-row']", - "present_in_code": false, - "render_kind": "dom_count", - "renders_at": "scripts/agent-monitor-plans/client/Plans.tsx (one button per plan; data-testid='audit-plan-row' added FEA-1437 Phase 4)", - "followup": "testid + dom_count assertion ready, but UI audit is BLOCKED on fixture data — seed-fixture-db.mjs seeds no plans, so /api/plans returns 0. Before enabling: seed N plan rows, confirm planStore.listPlans count == countPlans (no version-join drop), then flip present_in_code true and add specs/audit/plans.ui-audit.spec.ts (dom_count branch already supports it)." - } - }, - { - "id": "tools.event_types.length", - "screen": "Tools", - "route": "/tools", - "label": "distinct event types", - "selector_kind": "section_card_count", - "endpoint": "/api/events/facets", - "endpoint_field": "event_types.length", - "oracle": "events_facets_event_types_length", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-tools-event-types-length']", - "present_in_code": false, - "render_kind": "dom_count", - "renders_at": "scripts/agent-monitor-packs/client/Tools.tsx — NOT rendered as a countable list. event_type only appears per-event in the right-hand detail panel; there is no distinct-event-types card list.", - "followup": "BLOCKED: no rendered element represents the distinct-event-types count. Add a count element (data-testid) on the Tools screen, or descope this tile. The Tools audit spec skips it (present_in_code false)." - } - }, - { - "id": "workflows.compaction.tokensRecovered", - "screen": "Workflows", - "route": "/workflows", - "label": "tokens recovered via compaction", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "compaction.tokensRecovered", - "oracle": "workflow_tokens_recovered", - "formatter": "raw_int", - "tile_kind": "sum", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-compaction-tokensRecovered']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/components/workflows/CompactionImpact.tsx:215", - "followup": "Add data-testid to the StatCard value element, keyed per tile." - } - }, - { - "id": "workflows.compaction.sessionsWithCompactions", - "screen": "Workflows", - "route": "/workflows", - "label": "sessions with compactions", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "compaction.sessionsWithCompactions", - "oracle": "workflow_sessions_with_compactions", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-compaction-sessionsWithCompactions']", - "present_in_code": false, - "render_kind": "chart_only", - "renders_at": "upstream/components/workflows/CompactionImpact.tsx:173", - "followup": "Value renders only inside a chart/tooltip (derived pct). Add a screen-reader / data-testid'd numeric caption, OR cover at the API layer only and annotate the tile as DOM-exempt." - } - }, - { - "id": "workflows.concurrency.aggregateLanes.length", - "screen": "Workflows", - "route": "/workflows", - "label": "concurrency lanes", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "concurrency.aggregateLanes.length", - "oracle": "workflow_concurrency_lanes_length", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-concurrency-aggregateLanes-length']", - "present_in_code": false, - "render_kind": "chart_only", - "renders_at": "upstream/components/workflows/ConcurrencyTimeline.tsx (no single line)", - "followup": "Value renders only inside a chart/tooltip (timeline chart). Add a screen-reader / data-testid'd numeric caption, OR cover at the API layer only and annotate the tile as DOM-exempt." - } - }, - { - "id": "workflows.complexity.length", - "screen": "Workflows", - "route": "/workflows", - "label": "complexity scatter points", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "complexity.length", - "oracle": "workflow_complexity_length", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-complexity-length']", - "present_in_code": false, - "render_kind": "chart_only", - "renders_at": "upstream/components/workflows/SessionComplexityScatter.tsx (no single line)", - "followup": "Value renders only inside a chart/tooltip (scatter chart). Add a screen-reader / data-testid'd numeric caption, OR cover at the API layer only and annotate the tile as DOM-exempt." - } - }, - { - "id": "workflows.modelDelegation.tokensByModel.length", - "screen": "Workflows", - "route": "/workflows", - "label": "distinct models", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "modelDelegation.tokensByModel.length", - "oracle": "workflow_models_length", - "formatter": "raw_int", - "tile_kind": "count", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-workflows-modelDelegation-tokensByModel-length']", - "present_in_code": false, - "render_kind": "chart_only", - "renders_at": "upstream/components/workflows/ModelDelegationFlow.tsx (no single line)", - "followup": "Value renders only inside a chart/tooltip (delegation chart). Add a screen-reader / data-testid'd numeric caption, OR cover at the API layer only and annotate the tile as DOM-exempt." - } - }, - { - "id": "dashboard.health.workflow.errorPropagation.errorRate", - "screen": "Dashboard", - "tab": "Health", - "route": "/", - "label": "session error rate", - "selector_kind": "label_slice", - "endpoint": "/api/workflows", - "endpoint_field": "errorPropagation.errorRate", - "oracle": "workflow_error_rate", - "formatter": "raw_int", - "tile_kind": "percentage", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-dashboard-health-workflow-errorPropagation-errorRate']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx:1031", - "followup": "Add data-testid to the Errors Tip card value element, keyed per tile." - } - } - ], - "structural_assertions": [ - { - "id": "skills.list.length", - "screen": "Skills", - "route": "/skills", - "label": "Skills total", - "kind": "list_length", - "selector_kind": "section_card_count", - "endpoint": "/api/skills", - "oracle": "skills_total", - "tile_kind": "list_length", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-skill-row']", - "present_in_code": true, - "render_kind": "dom_count", - "renders_at": "scripts/agent-monitor-packs/client/Skills.tsx (one button per skill across pack groups; testid added FEA-1437 Phase 4)", - "followup": "Done — every skill row has data-testid='audit-skill-row' (across all groups); the audit asserts toHaveCount == skills_total, which equals the sum of the per-group counts." - } - }, - { - "id": "kanban.agents.working.count", - "screen": "KanbanBoard", - "route": "/kanban", - "label": "Working column count", - "kind": "list_length", - "selector_kind": "section_card_count", - "endpoint": "/api/agents?status=working", - "oracle": "agents_count_working", - "tile_kind": "list_length", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-kanban-agents-working-count']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/KanbanBoard.tsx:397", - "followup": "Add data-testid to the column count badge value element, keyed per tile." - } - }, - { - "id": "kanban.agents.waiting.count", - "screen": "KanbanBoard", - "route": "/kanban", - "label": "Waiting column count", - "kind": "list_length", - "selector_kind": "section_card_count", - "endpoint": "/api/agents?status=waiting", - "oracle": "agents_count_waiting", - "tile_kind": "list_length", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-kanban-agents-waiting-count']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/KanbanBoard.tsx:397", - "followup": "Add data-testid to the column count badge value element, keyed per tile." - } - }, - { - "id": "kanban.agents.completed.count", - "screen": "KanbanBoard", - "route": "/kanban", - "label": "Completed column count", - "kind": "list_length", - "selector_kind": "section_card_count", - "endpoint": "/api/agents?status=completed", - "oracle": "agents_count_completed", - "tile_kind": "list_length", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-kanban-agents-completed-count']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/KanbanBoard.tsx:397", - "followup": "Add data-testid to the column count badge value element, keyed per tile." - } - }, - { - "id": "kanban.agents.error.count", - "screen": "KanbanBoard", - "route": "/kanban", - "label": "Error column count", - "kind": "list_length", - "selector_kind": "section_card_count", - "endpoint": "/api/agents?status=error", - "oracle": "agents_count_error", - "tile_kind": "list_length", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "selector": { - "value": "[data-testid='audit-kanban-agents-error-count']", - "present_in_code": false, - "render_kind": "text", - "renders_at": "upstream/KanbanBoard.tsx:397", - "followup": "Add data-testid to the column count badge value element, keyed per tile." - } - }, - { - "id": "subagents.list.length", - "screen": "SubAgents", - "route": "/agents", - "label": "subagent rows", - "kind": "list_length", - "selector_kind": "section_card_count", - "endpoint": "/api/agents?type=subagent&limit=500", - "oracle": "analytics_total_subagents", - "tile_kind": "list_length", - "priority": "P1", - "owner": null, - "status": "bug_filed", - "bug_ref": "FEA-1419", - "notes": "/api/agents silently drops the ?type= query param (FEA-1419). Until the upstream supports the filter, this audit will fail intentionally — it's the regression signal that proves the bug is still there.", - "selector": { - "value": "[data-testid='audit-subagents-list-length']", - "present_in_code": false, - "render_kind": "per_group", - "renders_at": "scripts/agent-monitor-packs/client/SubAgents.tsx:130", - "followup": "Add a single aggregate count element (sum across groups) with a data-testid; today only per-group counts render in per-type button count." - } - }, - { - "id": "packs.installed.list.length", - "screen": "Packs", - "route": "/packs", - "label": "Installed Packs total", - "kind": "list_length", - "selector_kind": "section_card_count", - "endpoint": "/api/packs", - "oracle": "packs_distinct_pack_ids", - "tile_kind": "list_length", - "priority": "P1", - "owner": null, - "status": "pending", - "bug_ref": null, - "notes": "/api/packs dedupes by pack_id (one row per distinct pack), even if agent_packs has multiple rows per pack_id (one per install_scope/harness). Oracle uses COUNT(DISTINCT pack_id) to match. The packs_installed_count oracle counts all rows — useful for a different audit if we ever audit the per-scope view.", - "selector": { - "value": "[data-testid='audit-pack-row']", - "present_in_code": false, - "render_kind": "dom_count", - "renders_at": "scripts/agent-monitor-packs/client/PacksInstalled.tsx (one button per installed pack; data-testid='audit-pack-row' added FEA-1437 Phase 4)", - "followup": "testid + dom_count assertion ready, but UI audit is BLOCKED: in the test sidecar the installed-packs list renders 0 rows (launch-sidecar.mjs sets SKIP_CATALOG_DETECTORS=1, and /api/packs may not surface the seeded agent_packs rows without install detection). Before enabling: confirm whether /api/packs should return the fixture agent_packs in-test (possible real bug) or whether install detection is required, then flip present_in_code and re-add specs/audit/packs.ui-audit.spec.ts." - } - }, - { - "id": "dashboard.monitor.active_agents_section.cards_count", - "screen": "Dashboard", - "tab": "Monitor", - "route": "/", - "label": "Active Agents (section, working subset)", - "kind": "list_length", - "selector_kind": "section_card_count", - "endpoint": "/api/agents?status=working", - "oracle": "dashboard_active_main_agents_working_only", - "tile_kind": "list_length", - "priority": "P0", - "owner": null, - "status": "pending", - "bug_ref": null, - "notes": "The Dashboard SECTION renders cards for both working AND waiting main agents (two API calls in Dashboard.tsx). This single-endpoint structural assertion covers only the working subset — paired with the working oracle so scopes match. TODO: add a parallel /api/agents?status=waiting assertion to cover the rendered union.", - "selector": { - "value": "[data-testid='audit-dashboard-monitor-active-agents-section-cards-count']", - "present_in_code": false, - "render_kind": "dom_count", - "renders_at": "scripts/agent-monitor-client/Dashboard.tsx:1939", - "followup": "Either add a numeric total element with a data-testid, or test by counting rendered items under a data-testid'd renderAgentNode() grid container." - } - } - ] -} diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/oracles.mjs b/apps/desktop/test-e2e/agent-monitor/inventory/oracles.mjs deleted file mode 100644 index c8aa77d1..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/oracles.mjs +++ /dev/null @@ -1,1168 +0,0 @@ -// Oracle functions: compute expected values from the fixture SQLite DB. -// -// EVERY ORACLE is a pure function (DatabaseSync, opts) => number | { ... } -// that queries the DB and returns the value the UI/API is supposed to show. -// -// Rules: -// - Oracles MUST NOT call the parsers or the sidecar. They are the ground -// truth that those layers are audited against. -// - Oracles MUST be deterministic for a given DB state. -// - Oracles SHOULD be expressible as a single SQL query whenever possible; -// keep JS to the minimum needed to shape the return value. -// - If an oracle disagrees with the rendered UI or API output, the result -// is a triage decision — not automatically a failing test. See -// audit-runner.mjs for the comparison logic. - -/** - * @typedef {import("node:sqlite").DatabaseSync} DatabaseSync - */ - -/** - * Returns the count of all sessions in the DB. - * @param {DatabaseSync} db - */ -export function dashboard_total_sessions(db) { - const row = db.prepare("SELECT COUNT(*) AS n FROM sessions").get(); - return Number(row.n); -} - -/** - * Sessions with status='active'. - * @param {DatabaseSync} db - */ -export function dashboard_active_sessions(db) { - const row = db - .prepare("SELECT COUNT(*) AS n FROM sessions WHERE status = 'active'") - .get(); - return Number(row.n); -} - -/** - * Main agents (type='main') currently working. - * The Dashboard's "Active Agents" tile reads stats.active_agents, which the - * sidecar computes from main agents only. - * @param {DatabaseSync} db - */ -export function dashboard_active_agents(db) { - const row = db - .prepare( - "SELECT COUNT(*) AS n FROM agents WHERE type = 'main' AND status = 'working'", - ) - .get(); - return Number(row.n); -} - -/** - * Subagents in working state for sessions whose main agent is also active. - * Mirrors the Dashboard's `allSubagents.filter(a => a.status === "working")` - * derivation. The Dashboard scopes subagents to sessions of active mains — - * which means we must filter by session, not just by agent status. - * @param {DatabaseSync} db - */ -export function dashboard_active_subagents(db) { - const row = db - .prepare( - `SELECT COUNT(*) AS n - FROM agents sub - WHERE sub.type = 'subagent' - AND sub.status = 'working' - AND sub.session_id IN ( - SELECT session_id FROM agents - WHERE type = 'main' AND status = 'working' - )`, - ) - .get(); - return Number(row.n); -} - -/** - * Total subagents (any status) for sessions whose main agent is active. - * Renders as the trend "{n} total" beneath the Active Subagents tile. - * @param {DatabaseSync} db - */ -export function dashboard_total_subagents_for_active(db) { - const row = db - .prepare( - `SELECT COUNT(*) AS n - FROM agents sub - WHERE sub.type = 'subagent' - AND sub.session_id IN ( - SELECT session_id FROM agents - WHERE type = 'main' AND status = 'working' - )`, - ) - .get(); - return Number(row.n); -} - -/** - * Events created today. "Today" = local-day window computed from a UTC - * `now` and a tz_offset (in minutes, JS convention: positive west of UTC). - * - * The sidecar computes events_today using strftime('%Y-%m-%d', created_at) - * matched against the same local day. We replicate that here so the oracle - * stays parallel to the implementation while remaining independent code. - * - * @param {DatabaseSync} db - * @param {{ tzOffsetMinutes?: number, now?: Date }} [opts] - */ -export function dashboard_events_today(db, opts = {}) { - const tzOffsetMinutes = opts.tzOffsetMinutes ?? 0; - const now = opts.now ?? new Date(); - const shifted = new Date(now.getTime() - tzOffsetMinutes * 60_000); - const yyyy = shifted.getUTCFullYear(); - const mm = String(shifted.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(shifted.getUTCDate()).padStart(2, "0"); - const localDay = `${yyyy}-${mm}-${dd}`; - const row = db - .prepare( - `SELECT COUNT(*) AS n - FROM events - WHERE substr(created_at, 1, 10) = ?`, - ) - .get(localDay); - return Number(row.n); -} - -/** - * Total events across all sessions. - * @param {DatabaseSync} db - */ -export function dashboard_total_events(db) { - const row = db.prepare("SELECT COUNT(*) AS n FROM events").get(); - return Number(row.n); -} - -/** - * Total cost = sum over token_usage rows of: - * (input_tokens * input_per_mtok - * + output_tokens * output_per_mtok - * + cache_read_tokens * cache_read_per_mtok - * + cache_write_tokens * cache_write_per_mtok) / 1_000_000 - * - * Joined to model_pricing by exact model match. Rows with no matching - * model_pricing entry contribute zero — the same fail-open behavior the - * sidecar should use (verify this in triage if the API disagrees). - * - * The oracle does the math in plain SQL with REAL columns; floating-point - * noise is on the order of 1e-12, far below the cent rounding the UI uses. - * - * @param {DatabaseSync} db - */ -export function dashboard_total_cost(db) { - // Include baseline_* columns so this oracle stays consistent with - // cost_breakdown_by_model_map and session_cost_by_id. The upstream - // pricing route also uses (input_tokens + baseline_input) — re-imports - // can produce nonzero baseline values, and a fixture with baselines - // would otherwise make oracle-vs-oracle comparisons disagree. - // See PR #246 review comment from @thadeusb @ oracles.mjs:152. - const row = db - .prepare( - `SELECT COALESCE(SUM( - (tu.input_tokens + tu.baseline_input) * mp.input_per_mtok / 1000000.0 - + (tu.output_tokens + tu.baseline_output) * mp.output_per_mtok / 1000000.0 - + (tu.cache_read_tokens + tu.baseline_cache_read) * mp.cache_read_per_mtok / 1000000.0 - + (tu.cache_write_tokens + tu.baseline_cache_write) * mp.cache_write_per_mtok / 1000000.0 - ), 0) AS total - FROM token_usage tu - LEFT JOIN model_pricing mp - ON mp.model_pattern = tu.model`, - ) - .get(); - return Number(row.total); -} - -/** - * Main agents (type='main') with status in (working, waiting). The Dashboard - * "Active Agents" section card-list combines working+waiting mains in the - * RENDERED UI — but covering that requires two API calls. For single-endpoint - * audits, use `dashboard_active_main_agents_working_only` to match scope. - * @param {DatabaseSync} db - */ -export function dashboard_active_main_agents(db) { - const row = db - .prepare( - `SELECT COUNT(*) AS n - FROM agents - WHERE type = 'main' AND status IN ('working', 'waiting')`, - ) - .get(); - return Number(row.n); -} - -/** - * Main agents (type='main') currently working — used by audits that hit - * `/api/agents?status=working` exactly so the oracle and endpoint scopes - * match. The Dashboard's "Active Agents" SECTION renders working+waiting, - * but the API audit can only assert one endpoint at a time; use this - * oracle for the working endpoint and pair it with a separate waiting - * audit once we add `/api/agents?status=waiting` to the manifest. - * @param {DatabaseSync} db - */ -export function dashboard_active_main_agents_working_only(db) { - const row = db - .prepare( - `SELECT COUNT(*) AS n - FROM agents - WHERE type = 'main' AND status = 'working'`, - ) - .get(); - return Number(row.n); -} - -/** - * Map of oracle name → function. The audit runner looks up by name from - * manifest.json so adding a new tile means adding a manifest row + a function - * here. Nothing in test code needs to change. - */ -// ============================================================================ -// Dashboard Health tab oracles (route /, tab=Health, endpoints -// /api/settings/info and /api/workflows). Server-runtime metrics (uptime, -// memory, CPU) are NOT log-derived and are deliberately out of scope here. -// ============================================================================ - -/** /api/settings/info.db.counts.sessions */ -export function db_counts_sessions(db) { - return dashboard_total_sessions(db); -} - -/** /api/settings/info.db.counts.agents — total of any type */ -export function db_counts_agents(db) { - const row = db.prepare(`SELECT COUNT(*) AS n FROM agents`).get(); - return Number(row.n); -} - -/** /api/settings/info.db.counts.events */ -export function db_counts_events(db) { - return dashboard_total_events(db); -} - -/** workflow.compaction.totalCompactions — agents with subagent_type='compaction' */ -export function workflow_total_compactions(db) { - const row = db - .prepare( - `SELECT COUNT(*) AS n FROM agents WHERE subagent_type = 'compaction'`, - ) - .get(); - return Number(row.n); -} - -/** workflow.stats.successRate — completed / (completed + error) * 100, or 100 if no finished agents */ -export function workflow_success_rate(db) { - const row = db - .prepare( - `SELECT - (SELECT COUNT(*) FROM agents WHERE status = 'completed') AS completed, - (SELECT COUNT(*) FROM agents WHERE status = 'error') AS errored`, - ) - .get(); - const finished = Number(row.completed) + Number(row.errored); - if (finished === 0) return 100; - return Number(((Number(row.completed) / finished) * 100).toFixed(1)); -} - -/** workflow.stats.totalSessions — same as dashboard_total_sessions */ -export function workflow_total_sessions(db) { - return dashboard_total_sessions(db); -} -/** workflow.stats.totalAgents — total agents of any type */ -export function workflow_total_agents(db) { - return db_counts_agents(db); -} -/** workflow.stats.totalSubagents — same as analytics_total_subagents */ -export function workflow_total_subagents(db) { - return analytics_total_subagents(db); -} -/** workflow.stats.avgSubagents — totalSubagents / totalSessions, .toFixed(1), 0 if no sessions */ -export function workflow_avg_subagents(db) { - const total = workflow_total_subagents(db); - const sessions = workflow_total_sessions(db); - if (sessions === 0) return 0; - return Number((total / sessions).toFixed(1)); -} -/** workflow.stats.avgCompactions — totalCompactions / totalSessions, .toFixed(1) */ -export function workflow_avg_compactions(db) { - const total = workflow_total_compactions(db); - const sessions = workflow_total_sessions(db); - if (sessions === 0) return 0; - return Number((total / sessions).toFixed(1)); -} -/** workflow.stats.avgDurationSec — Math.round(sum(ended-started seconds)/count) over ENDED sessions */ -export function workflow_avg_duration_sec(db) { - const rows = db - .prepare( - `SELECT started_at, ended_at FROM sessions WHERE ended_at IS NOT NULL`, - ) - .all(); - if (rows.length === 0) return 0; - let total = 0; - for (const r of rows) { - total += (new Date(r.ended_at) - new Date(r.started_at)) / 1000; - } - return Math.round(total / rows.length); -} -/** workflow.stats.avgDepth — average max-depth per session over the recursive agent tree */ -export function workflow_avg_depth(db) { - const rows = db - .prepare( - `WITH RECURSIVE agent_depth AS ( - SELECT id, session_id, parent_agent_id, 0 AS depth FROM agents WHERE parent_agent_id IS NULL - UNION ALL - SELECT a.id, a.session_id, a.parent_agent_id, ad.depth + 1 - FROM agents a JOIN agent_depth ad ON a.parent_agent_id = ad.id - ) - SELECT session_id, MAX(depth) AS max_depth FROM agent_depth GROUP BY session_id`, - ) - .all(); - if (rows.length === 0) return 0; - const sum = rows.reduce((s, r) => s + Number(r.max_depth), 0); - return Number((sum / rows.length).toFixed(1)); -} - -/** workflow.toolFlow.toolCounts.length — distinct tools that appear in events */ -export function workflow_tool_counts_length(db) { - const row = db - .prepare( - `SELECT COUNT(DISTINCT tool_name) AS n - FROM events - WHERE tool_name IS NOT NULL`, - ) - .get(); - return Number(row.n); -} - -/** workflow.effectiveness.length — distinct subagent types (excluding compaction noise per upstream code) */ -export function workflow_effectiveness_length(db) { - // Upstream: SELECT subagent_type ... WHERE type = 'subagent' GROUP BY subagent_type - const row = db - .prepare( - `SELECT COUNT(DISTINCT subagent_type) AS n - FROM agents - WHERE type = 'subagent' AND subagent_type IS NOT NULL`, - ) - .get(); - return Number(row.n); -} - -/** - * Count of distinct tool_names across ALL events (any event_type). Used by - * /api/events/facets.tool_names.length — drives the Tools page list. - * - * Distinct from analytics_distinct_tools_count, which only counts PreToolUse. - * If these two diverge it's interesting — would mean PostToolUse events - * mention tools that PreToolUse doesn't. - * @param {DatabaseSync} db - */ -export function events_facets_tool_names_length(db) { - const row = db - .prepare( - `SELECT COUNT(DISTINCT tool_name) AS n - FROM events - WHERE tool_name IS NOT NULL`, - ) - .get(); - return Number(row.n); -} - -/** /api/plans.total — count of plans rows */ -export function plans_total(db) { - try { - const row = db.prepare(`SELECT COUNT(*) AS n FROM plans`).get(); - return Number(row.n); - } catch { - return 0; - } -} - -/** Distinct subagent_types — drives SubAgents page card count */ -export function subagents_distinct_types(db) { - const row = db - .prepare( - `SELECT COUNT(DISTINCT subagent_type) AS n - FROM agents - WHERE type = 'subagent' AND subagent_type IS NOT NULL`, - ) - .get(); - return Number(row.n); -} - -/** Total events visible to the ActivityFeed (all events) */ -export function activityfeed_total_events(db) { - return dashboard_total_events(db); -} - -/** Sessions in a given status — for KanbanBoard columns. */ -export function sessions_count_by_status(db, opts) { - if (!opts?.status) throw new Error("sessions_count_by_status requires opts.status"); - const row = db - .prepare(`SELECT COUNT(*) AS n FROM sessions WHERE status = ?`) - .get(opts.status); - return Number(row.n); -} - -/** Agents in a given status — for KanbanBoard columns. */ -export function agents_count_by_status(db, opts) { - if (!opts?.status) throw new Error("agents_count_by_status requires opts.status"); - const row = db - .prepare(`SELECT COUNT(*) AS n FROM agents WHERE status = ?`) - .get(opts.status); - return Number(row.n); -} - -// Frozen-status oracles so manifest can bind structural assertions without -// needing opts injection. -export function agents_count_working(db) { - return agents_count_by_status(db, { status: "working" }); -} -export function agents_count_waiting(db) { - return agents_count_by_status(db, { status: "waiting" }); -} -export function agents_count_completed(db) { - return agents_count_by_status(db, { status: "completed" }); -} -export function agents_count_error(db) { - return agents_count_by_status(db, { status: "error" }); -} - -/** Per-model token totals — keyed by model. Returns - * { [model]: { input_tokens, output_tokens, cache_read_tokens, cache_write_tokens } }. - * Mirrors what /api/workflows.modelDelegation.tokensByModel must produce. - */ -export function tokens_by_model_map(db) { - const rows = db - .prepare( - `SELECT - model, - COALESCE(SUM(input_tokens + baseline_input), 0) AS input_tokens, - COALESCE(SUM(output_tokens + baseline_output), 0) AS output_tokens, - COALESCE(SUM(cache_read_tokens + baseline_cache_read), 0) AS cache_read_tokens, - COALESCE(SUM(cache_write_tokens + baseline_cache_write), 0) AS cache_write_tokens - FROM token_usage - WHERE model IS NOT NULL - GROUP BY model`, - ) - .all(); - const out = {}; - for (const r of rows) { - out[r.model] = { - input_tokens: Number(r.input_tokens), - output_tokens: Number(r.output_tokens), - cache_read_tokens: Number(r.cache_read_tokens), - cache_write_tokens: Number(r.cache_write_tokens), - }; - } - return out; -} - -/** - * Tool-to-tool transitions counted as INVOCATION→INVOCATION. The matching - * upstream query joins on any next event with a tool_name, which lets a - * PostToolUse row sneak in and either (a) form a `(X, X)` self-loop where - * the PostUse of X immediately follows the PreUse of X or (b) double-count - * when a PostUse sits between two real invocations. - * - * Oracle returns map keyed by `source||target`. - */ -export function tool_transitions_map(db) { - const rows = db - .prepare( - `SELECT e1.tool_name AS source, e2.tool_name AS target, COUNT(*) AS n - FROM events e1 - JOIN events e2 - ON e2.session_id = e1.session_id - AND e2.id = ( - SELECT MIN(e3.id) FROM events e3 - WHERE e3.session_id = e1.session_id - AND e3.id > e1.id - AND e3.tool_name IS NOT NULL - AND e3.event_type = 'PreToolUse' - ) - WHERE e1.tool_name IS NOT NULL - AND e1.event_type = 'PreToolUse' - AND e2.tool_name IS NOT NULL - GROUP BY e1.tool_name, e2.tool_name`, - ) - .all(); - const out = {}; - for (const r of rows) out[`${r.source}||${r.target}`] = Number(r.n); - return out; -} - -/** workflow.concurrency.aggregateLanes.length — distinct agent types ("Main Agent" + subagent_types) appearing in ENDED sessions. */ -export function workflow_concurrency_lanes_length(db) { - // Mirrors the upstream key-building logic: lane.type === 'main' → 'Main Agent', - // else subagent_type || 'unknown'. But only for agents in ENDED sessions. - const rows = db - .prepare( - `SELECT DISTINCT - CASE WHEN a.type = 'main' THEN 'Main Agent' - ELSE COALESCE(a.subagent_type, 'unknown') - END AS lane_name - FROM agents a - JOIN sessions s ON a.session_id = s.id - WHERE s.ended_at IS NOT NULL`, - ) - .all(); - return rows.length; -} - -/** workflow.complexity (scatter rows) — length = number of sessions with at least 1 agent. */ -export function workflow_complexity_length(db) { - const row = db - .prepare( - `SELECT COUNT(DISTINCT s.id) AS n - FROM sessions s - JOIN agents a ON a.session_id = s.id`, - ) - .get(); - return Number(row.n); -} - -/** Per-pack stats: returns { installs, skills, associations } counts for a pack_id. */ -export function pack_detail_counts_by_id(db, opts) { - if (!opts?.packId) throw new Error("pack_detail_counts_by_id requires opts.packId"); - const pid = opts.packId; - const installs = Number( - db - .prepare( - `SELECT COUNT(*) AS n FROM agent_packs WHERE pack_id = ? AND uninstalled_at IS NULL`, - ) - .get(pid).n, - ); - const skills = Number( - db - .prepare(`SELECT COUNT(*) AS n FROM skills WHERE pack_id = ?`) - .get(pid).n, - ); - let associations = 0; - try { - associations = Number( - db - .prepare( - `SELECT COUNT(*) AS n FROM project_pack_associations WHERE pack_id = ?`, - ) - .get(pid).n, - ); - } catch { - // project_pack_associations may not exist in older schemas - } - return { installs, skills, associations }; -} - -/** Per-session stats: returns the full shape of /api/sessions/:id/stats agents+tokens block. */ -export function session_stats_by_id(db, opts) { - if (!opts?.sessionId) throw new Error("session_stats_by_id requires opts.sessionId"); - const sid = opts.sessionId; - const total_events = Number( - db.prepare(`SELECT COUNT(*) AS n FROM events WHERE session_id = ?`).get(sid).n, - ); - const error_count = Number( - db - .prepare( - `SELECT COUNT(*) AS n FROM events WHERE session_id = ? AND event_type = 'Error'`, - ) - .get(sid).n, - ); - const agentsTotal = Number( - db.prepare(`SELECT COUNT(*) AS n FROM agents WHERE session_id = ?`).get(sid).n, - ); - const agentsMain = Number( - db - .prepare( - `SELECT COUNT(*) AS n FROM agents WHERE session_id = ? AND type = 'main'`, - ) - .get(sid).n, - ); - const agentsSubagent = Number( - db - .prepare( - `SELECT COUNT(*) AS n FROM agents WHERE session_id = ? AND type = 'subagent'`, - ) - .get(sid).n, - ); - const agentsCompaction = Number( - db - .prepare( - `SELECT COUNT(*) AS n FROM agents WHERE session_id = ? AND subagent_type = 'compaction'`, - ) - .get(sid).n, - ); - const t = db - .prepare( - `SELECT - COALESCE(SUM(input_tokens + baseline_input), 0) AS input_tokens, - COALESCE(SUM(output_tokens + baseline_output), 0) AS output_tokens, - COALESCE(SUM(cache_read_tokens + baseline_cache_read), 0) AS cache_read_tokens, - COALESCE(SUM(cache_write_tokens + baseline_cache_write), 0) AS cache_write_tokens - FROM token_usage WHERE session_id = ?`, - ) - .get(sid); - return { - total_events, - error_count, - agents: { - total: agentsTotal, - main: agentsMain, - subagent: agentsSubagent, - compaction: agentsCompaction, - }, - tokens: { - input_tokens: Number(t.input_tokens), - output_tokens: Number(t.output_tokens), - cache_read_tokens: Number(t.cache_read_tokens), - cache_write_tokens: Number(t.cache_write_tokens), - }, - }; -} - -/** - * mainModels — per-session-model breakdown of MAIN-agent counts and - * sessions. Mirrors the upstream SQL: - * COUNT(DISTINCT a.id) AS agent_count, - * COUNT(DISTINCT s.id) AS session_count - * FROM agents a JOIN sessions s ON a.session_id = s.id - * WHERE a.type = 'main' AND s.model IS NOT NULL - * GROUP BY s.model - * Returns { [model]: { agent_count, session_count } }. - */ -export function workflow_main_models_map(db) { - const rows = db - .prepare( - `SELECT s.model, - COUNT(DISTINCT a.id) AS agent_count, - COUNT(DISTINCT s.id) AS session_count - FROM agents a - JOIN sessions s ON a.session_id = s.id - WHERE a.type = 'main' AND s.model IS NOT NULL - GROUP BY s.model`, - ) - .all(); - const out = {}; - for (const r of rows) { - out[r.model] = { - agent_count: Number(r.agent_count), - session_count: Number(r.session_count), - }; - } - return out; -} - -/** Per-model cost map. Returns { [model]: cost } using the SAME per-row pricing formula as dashboard_total_cost. */ -export function cost_breakdown_by_model_map(db) { - const rows = db - .prepare( - `SELECT - tu.model, - COALESCE(SUM( - (tu.input_tokens + tu.baseline_input) * mp.input_per_mtok / 1000000.0 - + (tu.output_tokens + tu.baseline_output) * mp.output_per_mtok / 1000000.0 - + (tu.cache_read_tokens + tu.baseline_cache_read) * mp.cache_read_per_mtok / 1000000.0 - + (tu.cache_write_tokens + tu.baseline_cache_write) * mp.cache_write_per_mtok / 1000000.0 - ), 0) AS cost - FROM token_usage tu - LEFT JOIN model_pricing mp ON mp.model_pattern = tu.model - WHERE tu.model IS NOT NULL - GROUP BY tu.model`, - ) - .all(); - const out = {}; - for (const r of rows) out[r.model] = Number(r.cost); - return out; -} - -/** Per-subagent-type effectiveness map. Returns { [subagent_type]: {total, completed, errors, sessions} } */ -export function subagent_effectiveness_map(db) { - const rows = db - .prepare( - `SELECT - subagent_type, - COUNT(*) AS total, - SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, - SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errors, - COUNT(DISTINCT session_id) AS sessions - FROM agents - WHERE type = 'subagent' AND subagent_type IS NOT NULL - GROUP BY subagent_type`, - ) - .all(); - const out = {}; - for (const r of rows) { - out[r.subagent_type] = { - total: Number(r.total), - completed: Number(r.completed), - errors: Number(r.errors), - sessions: Number(r.sessions), - }; - } - return out; -} - -/** Per-tool counts. Returns { [tool_name]: count } over PreToolUse events. */ -export function tool_counts_map(db) { - const rows = db - .prepare( - `SELECT tool_name, COUNT(*) AS n - FROM events - WHERE event_type = 'PreToolUse' AND tool_name IS NOT NULL - GROUP BY tool_name`, - ) - .all(); - const out = {}; - for (const r of rows) out[r.tool_name] = Number(r.n); - return out; -} - -/** /api/pricing list length (= row count in model_pricing). */ -export function pricing_rules_count(db) { - try { - const row = db.prepare(`SELECT COUNT(*) AS n FROM model_pricing`).get(); - return Number(row.n); - } catch { - return 0; - } -} - -/** /api/events/facets.event_types.length — distinct event_type values */ -export function events_facets_event_types_length(db) { - const row = db - .prepare( - `SELECT COUNT(DISTINCT event_type) AS n - FROM events - WHERE event_type IS NOT NULL`, - ) - .get(); - return Number(row.n); -} - -/** workflow.modelDelegation.tokensByModel.length — distinct models in token_usage */ -export function workflow_models_length(db) { - const row = db - .prepare( - `SELECT COUNT(DISTINCT model) AS n - FROM token_usage - WHERE model IS NOT NULL`, - ) - .get(); - return Number(row.n); -} - -/** workflow.compaction.tokensRecovered — sum of all baseline tokens across token_usage */ -export function workflow_tokens_recovered(db) { - const row = db - .prepare( - `SELECT COALESCE(SUM(baseline_input + baseline_output + baseline_cache_read + baseline_cache_write), 0) AS total - FROM token_usage`, - ) - .get(); - return Number(row.total); -} - -/** workflow.compaction.sessionsWithCompactions — distinct sessions with ≥1 compaction subagent */ -export function workflow_sessions_with_compactions(db) { - const row = db - .prepare( - `SELECT COUNT(DISTINCT session_id) AS n FROM agents WHERE subagent_type = 'compaction'`, - ) - .get(); - return Number(row.n); -} - -/** workflow.errorPropagation.errorRate — sessions_with_errors / total_sessions * 100 */ -export function workflow_error_rate(db) { - const row = db - .prepare( - `SELECT - (SELECT COUNT(*) FROM sessions) AS total_sessions, - (SELECT COUNT(*) FROM sessions WHERE status = 'error') AS error_sessions`, - ) - .get(); - const total = Number(row.total_sessions); - if (total === 0) return 0; - return Number(((Number(row.error_sessions) / total) * 100).toFixed(1)); -} - -// ============================================================================ -// Pull Requests screen oracles (route /pull-requests) -// ============================================================================ - -/** Count of pull_requests rows. Endpoint /api/pull-requests/stats.pull_requests */ -export function pull_requests_total(db) { - const row = db.prepare(`SELECT COUNT(*) AS n FROM pull_requests`).get(); - return Number(row.n); -} - -/** Distinct sessions that produced ≥1 PR. */ -export function pull_requests_sessions_with_pr(db) { - const row = db - .prepare( - `SELECT COUNT(DISTINCT session_id) AS n FROM pull_requests WHERE session_id IS NOT NULL`, - ) - .get(); - return Number(row.n); -} - -/** Distinct repos across all PRs. */ -export function pull_requests_repos(db) { - const row = db - .prepare(`SELECT COUNT(DISTINCT repo_full_name) AS n FROM pull_requests`) - .get(); - return Number(row.n); -} - -// ============================================================================ -// Packs / Skills oracles -// ============================================================================ - -/** Count of agent_packs rows that aren't tombstoned. */ -export function packs_installed_count(db) { - // The pack-scanner uses uninstalled_at to soft-delete; live rows have NULL. - // Some schemas may not have that column — fall back to plain COUNT(*). - try { - const row = db - .prepare(`SELECT COUNT(*) AS n FROM agent_packs WHERE uninstalled_at IS NULL`) - .get(); - return Number(row.n); - } catch { - const row = db.prepare(`SELECT COUNT(*) AS n FROM agent_packs`).get(); - return Number(row.n); - } -} - -/** Distinct pack_ids — what the user sees as "installed packs". */ -export function packs_distinct_pack_ids(db) { - const row = db - .prepare(`SELECT COUNT(DISTINCT pack_id) AS n FROM agent_packs`) - .get(); - return Number(row.n); -} - -/** Count of skills rows. */ -export function skills_total(db) { - try { - const row = db - .prepare(`SELECT COUNT(*) AS n FROM skills WHERE uninstalled_at IS NULL`) - .get(); - return Number(row.n); - } catch { - const row = db.prepare(`SELECT COUNT(*) AS n FROM skills`).get(); - return Number(row.n); - } -} - -// ============================================================================ -// Sessions screen oracles (route /sessions, endpoint /api/sessions) -// These are per-row aggregates — the audit calls the oracle for each -// session_id returned by the API. -// ============================================================================ - -/** - * Count of agents (any type) for a given session_id. - * Endpoint: /api/sessions returns rows with `agent_count` per session. - * @param {DatabaseSync} db - * @param {{ sessionId: string }} opts - */ -export function session_agent_count_by_id(db, opts) { - if (!opts?.sessionId) throw new Error("session_agent_count_by_id requires opts.sessionId"); - const row = db - .prepare(`SELECT COUNT(*) AS n FROM agents WHERE session_id = ?`) - .get(opts.sessionId); - return Number(row.n); -} - -/** - * Per-session cost using the SAME formula as dashboard_total_cost, but - * scoped to one session. Mirrors what the Sessions page renders in the cost - * column (and what /api/pricing/cost/:sessionId returns). - * @param {DatabaseSync} db - * @param {{ sessionId: string }} opts - */ -export function session_cost_by_id(db, opts) { - if (!opts?.sessionId) throw new Error("session_cost_by_id requires opts.sessionId"); - // Baselines included — consistent with dashboard_total_cost and - // cost_breakdown_by_model_map. See PR #246 review @ oracles.mjs:152. - const row = db - .prepare( - `SELECT COALESCE(SUM( - (tu.input_tokens + tu.baseline_input) * mp.input_per_mtok / 1000000.0 - + (tu.output_tokens + tu.baseline_output) * mp.output_per_mtok / 1000000.0 - + (tu.cache_read_tokens + tu.baseline_cache_read) * mp.cache_read_per_mtok / 1000000.0 - + (tu.cache_write_tokens + tu.baseline_cache_write) * mp.cache_write_per_mtok / 1000000.0 - ), 0) AS total - FROM token_usage tu - LEFT JOIN model_pricing mp ON mp.model_pattern = tu.model - WHERE tu.session_id = ?`, - ) - .get(opts.sessionId); - return Number(row.total); -} - -// ============================================================================ -// Analytics screen oracles (route /analytics, endpoint /api/analytics) -// ============================================================================ - -/** - * Sum of input tokens across all token_usage rows. - * Endpoint field: analytics.tokens.total_input - * @param {DatabaseSync} db - */ -export function analytics_tokens_total_input(db) { - const row = db - .prepare( - `SELECT COALESCE(SUM(input_tokens + baseline_input), 0) AS n - FROM token_usage`, - ) - .get(); - return Number(row.n); -} - -/** - * Sum of output tokens across all token_usage rows. - * Endpoint field: analytics.tokens.total_output - * @param {DatabaseSync} db - */ -export function analytics_tokens_total_output(db) { - const row = db - .prepare( - `SELECT COALESCE(SUM(output_tokens + baseline_output), 0) AS n - FROM token_usage`, - ) - .get(); - return Number(row.n); -} - -/** - * Sum of cache_read tokens across all token_usage rows. - * Endpoint field: analytics.tokens.total_cache_read - * @param {DatabaseSync} db - */ -export function analytics_tokens_total_cache_read(db) { - const row = db - .prepare( - `SELECT COALESCE(SUM(cache_read_tokens + baseline_cache_read), 0) AS n - FROM token_usage`, - ) - .get(); - return Number(row.n); -} - -/** - * Sum of cache_write tokens across all token_usage rows. - * Endpoint field: analytics.tokens.total_cache_write - * @param {DatabaseSync} db - */ -export function analytics_tokens_total_cache_write(db) { - const row = db - .prepare( - `SELECT COALESCE(SUM(cache_write_tokens + baseline_cache_write), 0) AS n - FROM token_usage`, - ) - .get(); - return Number(row.n); -} - -/** - * Average events per session = total_events / total_sessions (0 if no sessions). - * Endpoint field: analytics.avg_events_per_session - * @param {DatabaseSync} db - */ -export function analytics_avg_events_per_session(db) { - const row = db - .prepare( - `SELECT - (SELECT COUNT(*) FROM events) * 1.0 AS evt, - (SELECT COUNT(*) FROM sessions) * 1.0 AS sess`, - ) - .get(); - const sess = Number(row.sess); - if (sess === 0) return 0; - return Number(row.evt) / sess; -} - -/** - * Count of subagent rows. - * Endpoint field: analytics.total_subagents - * @param {DatabaseSync} db - */ -export function analytics_total_subagents(db) { - const row = db - .prepare( - `SELECT COUNT(*) AS n FROM agents WHERE type = 'subagent'`, - ) - .get(); - return Number(row.n); -} - -/** - * The Analytics overview block returns the SAME numbers as /api/stats: - * total_sessions / active_sessions / total_agents / active_agents / - * total_events. We expose oracles for each so cross-endpoint disagreements - * (which would be a bug — two endpoints, same DB, must agree) surface. - * - * For these, the existing dashboard_* oracles are correct — we just re-export - * with analytics_* names so manifest authors don't have to know about the - * cross-endpoint coupling. - */ -export function analytics_overview_total_sessions(db) { - return dashboard_total_sessions(db); -} -export function analytics_overview_active_sessions(db) { - return dashboard_active_sessions(db); -} -export function analytics_overview_total_agents(db) { - const row = db.prepare(`SELECT COUNT(*) AS n FROM agents`).get(); - return Number(row.n); -} -export function analytics_overview_active_agents(db) { - return dashboard_active_agents(db); -} -export function analytics_overview_total_events(db) { - return dashboard_total_events(db); -} - -/** - * Sessions grouped by status. Returns { active, completed, error, abandoned } - * with zeros for absent buckets. The API returns sessions_by_status as - * Record. - * @param {DatabaseSync} db - */ -export function analytics_sessions_by_status(db) { - const rows = db - .prepare( - `SELECT status, COUNT(*) AS n FROM sessions GROUP BY status`, - ) - .all(); - const buckets = { active: 0, completed: 0, error: 0, abandoned: 0 }; - for (const r of rows) buckets[r.status] = Number(r.n); - return buckets; -} - -/** - * Agents grouped by status. Returns { working, waiting, completed, error } - * with zeros for absent buckets. - * @param {DatabaseSync} db - */ -export function analytics_agents_by_status(db) { - const rows = db - .prepare( - `SELECT status, COUNT(*) AS n FROM agents GROUP BY status`, - ) - .all(); - const buckets = { working: 0, waiting: 0, completed: 0, error: 0 }; - for (const r of rows) buckets[r.status] = Number(r.n); - return buckets; -} - -/** - * Count of distinct tools used across event_type='PreToolUse' events. - * Used by the "tools used" tile and the tool_usage array length. - * @param {DatabaseSync} db - */ -export function analytics_distinct_tools_count(db) { - const row = db - .prepare( - `SELECT COUNT(DISTINCT tool_name) AS n - FROM events - WHERE event_type = 'PreToolUse' AND tool_name IS NOT NULL`, - ) - .get(); - return Number(row.n); -} - -/** - * Total count of tool invocations (PreToolUse events). - * @param {DatabaseSync} db - */ -export function analytics_total_tool_invocations(db) { - const row = db - .prepare( - `SELECT COUNT(*) AS n - FROM events - WHERE event_type = 'PreToolUse' AND tool_name IS NOT NULL`, - ) - .get(); - return Number(row.n); -} - -export const oracles = { - dashboard_total_sessions, - dashboard_active_sessions, - dashboard_active_agents, - dashboard_active_subagents, - dashboard_total_subagents_for_active, - dashboard_events_today, - dashboard_total_events, - dashboard_total_cost, - dashboard_active_main_agents, - dashboard_active_main_agents_working_only, - // Dashboard Health tab - db_counts_sessions, - db_counts_agents, - db_counts_events, - workflow_total_compactions, - workflow_success_rate, - workflow_error_rate, - workflow_tokens_recovered, - workflow_sessions_with_compactions, - // Tools (Tools page → /api/events/facets) - events_facets_tool_names_length, - events_facets_event_types_length, - // Plans / SubAgents / ActivityFeed / KanbanBoard / Settings - plans_total, - subagents_distinct_types, - activityfeed_total_events, - sessions_count_by_status, - agents_count_by_status, - agents_count_working, - agents_count_waiting, - agents_count_completed, - agents_count_error, - pricing_rules_count, - tokens_by_model_map, - tool_counts_map, - tool_transitions_map, - subagent_effectiveness_map, - cost_breakdown_by_model_map, - workflow_main_models_map, - session_stats_by_id, - pack_detail_counts_by_id, - workflow_concurrency_lanes_length, - workflow_complexity_length, - // Workflows - workflow_total_sessions, - workflow_total_agents, - workflow_total_subagents, - workflow_avg_subagents, - workflow_avg_compactions, - workflow_avg_duration_sec, - workflow_avg_depth, - workflow_tool_counts_length, - workflow_effectiveness_length, - workflow_models_length, - // Sessions - session_agent_count_by_id, - session_cost_by_id, - // Pull Requests - pull_requests_total, - pull_requests_sessions_with_pr, - pull_requests_repos, - // Packs / Skills - packs_installed_count, - packs_distinct_pack_ids, - skills_total, - // Analytics - analytics_tokens_total_input, - analytics_tokens_total_output, - analytics_tokens_total_cache_read, - analytics_tokens_total_cache_write, - analytics_avg_events_per_session, - analytics_total_subagents, - analytics_overview_total_sessions, - analytics_overview_active_sessions, - analytics_overview_total_agents, - analytics_overview_active_agents, - analytics_overview_total_events, - analytics_sessions_by_status, - analytics_agents_by_status, - analytics_distinct_tools_count, - analytics_total_tool_invocations, -}; diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/run-report.mjs b/apps/desktop/test-e2e/agent-monitor/inventory/run-report.mjs deleted file mode 100644 index 2fe83216..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/run-report.mjs +++ /dev/null @@ -1,329 +0,0 @@ -// Audit report generator — the "CEO one-pager" deliverable from PLN-738. -// -// Usage: -// pnpm --filter desktop audit:report -// -// What it does: -// 1. Boots the real sidecar against the same fixture DB the contract tests -// use. -// 2. For every manifest row whose endpoint exposes a single field, queries -// the API and compares to the oracle. -// 3. For derived/UI-only tiles, computes the oracle value so the report -// shows what the UI ought to be rendering (the UI audit itself is a -// separate Playwright run; this report links to its output). -// 4. Writes REPORT-DASHBOARD.md alongside the manifest. -// -// Output is plain markdown so it can be committed and reviewed without any -// additional tooling. - -import { writeFileSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../helpers/launch-sidecar.mjs"; -import { endpointUrlForRow, loadManifest } from "./manifest-loader.mjs"; -import { - computeOracle, - compareNumeric, - getField, - openDb, -} from "./audit-runner.mjs"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const REPORT_PATH = join(HERE, "REPORT-AUDIT.md"); - -function md(value) { - if (value == null) return "_n/a_"; - if (typeof value === "number") - return Number.isInteger(value) ? String(value) : value.toFixed(6); - return String(value); -} - -function classify(row, apiCmp) { - if (!row.endpoint || row.endpoint === "derived") return "ui-only"; - if (apiCmp?.ok) return "agree"; - return "disagree"; -} - -function suspicionScore(row, apiValue, oracleValue) { - // Order disagreements by relative or absolute delta, depending on tile_kind. - // Bigger score = more suspicious / more user-visible. - if (typeof apiValue !== "number" || typeof oracleValue !== "number") return Infinity; - if (row.tile_kind === "money") return Math.abs(apiValue - oracleValue); - if (oracleValue === 0) return Math.abs(apiValue); - return Math.abs(apiValue - oracleValue) / Math.max(1, Math.abs(oracleValue)); -} - -async function main() { - const manifest = loadManifest(); - const tiles = manifest.tiles; - - const tmp = makeTempDbPath(); - seedFixtureDb(tmp.dbPath); - let sidecar; - let rows = []; - try { - sidecar = await launchSidecar({ dbPath: tmp.dbPath }); - reseedPacksAndSkills(tmp.dbPath); - const db = openDb(tmp.dbPath); - try { - - // Cache so each endpoint is hit once even if multiple tiles use it. - const cache = new Map(); - async function fetchOnce(u) { - if (cache.has(u)) return cache.get(u); - const res = await fetch(`${sidecar.baseUrl}${u}`); - if (!res.ok) { - const txt = await res.text().catch(() => ""); - throw new Error(`GET ${u} -> ${res.status} ${res.statusText}\n${txt}`); - } - const body = await res.json(); - cache.set(u, body); - return body; - } - - for (const tile of tiles) { - const { expected, expectedFormatted } = computeOracle(tile, db, { - tzOffsetMinutes: 0, - }); - - let apiValue = undefined; - let apiCmp = null; - const url = endpointUrlForRow(tile); - if (url) { - try { - const body = await fetchOnce(url); - apiValue = getField(body, tile.endpoint_field); - if (apiValue !== undefined) { - apiCmp = compareNumeric(Number(apiValue), Number(expected)); - } else { - apiCmp = { - ok: false, - reason: `field "${tile.endpoint_field}" missing in ${url} response`, - actual: undefined, - expected, - }; - } - } catch (err) { - apiCmp = { - ok: false, - reason: `fetch error: ${err.message}`, - actual: undefined, - expected, - }; - } - } - - rows.push({ - tile, - expected, - expectedFormatted, - apiValue, - apiCmp, - verdict: classify(tile, apiCmp), - suspicion: suspicionScore(tile, Number(apiValue), Number(expected)), - url, - }); - } - - } finally { - db.close(); - } - } finally { - if (sidecar) await sidecar.stop(); - tmp.cleanup(); - } - - // Build the markdown report. - const total = rows.length; - const agree = rows.filter((r) => r.verdict === "agree").length; - const disagree = rows.filter((r) => r.verdict === "disagree").length; - const uiOnly = rows.filter((r) => r.verdict === "ui-only").length; - - const topSuspicious = rows - .filter((r) => r.verdict === "disagree") - .sort((a, b) => b.suspicion - a.suspicion) - .slice(0, 5); - - const screens = [...new Set(rows.map((r) => r.tile.screen))]; - const perScreen = screens.map((s) => { - const r = rows.filter((row) => row.tile.screen === s); - return { - screen: s, - total: r.length, - agree: r.filter((row) => row.verdict === "agree").length, - disagree: r.filter((row) => row.verdict === "disagree").length, - uiOnly: r.filter((row) => row.verdict === "ui-only").length, - }; - }); - - const lines = []; - lines.push("# UI Numbers Audit Report — all screens"); - lines.push(""); - lines.push( - `_Generated: ${new Date().toISOString()} · FEA-1415 / PLN-738_`, - ); - lines.push(""); - lines.push("## Headline"); - lines.push(""); - lines.push(`- Screens with manifest coverage: **${screens.length}**`); - lines.push(`- Tiles audited: **${total}**`); - lines.push(`- API agrees with oracle: **${agree}**`); - lines.push(`- API disagrees with oracle: **${disagree}**`); - lines.push(`- UI-only / derived tiles (no API field — see Playwright run): **${uiOnly}**`); - lines.push(""); - lines.push( - "> This report covers manifest-driven tiles only. Additional dedicated test files (`sessions.per-row`, `bucketed-counts`, `per-model-tokens`, `timezone-bucketing`, `pricing-breakdown`, `session-detail`) probe cross-cutting concerns and surface more bugs — run `pnpm --filter desktop test:audit` for the full picture.", - ); - lines.push(""); - lines.push("## Coverage by screen"); - lines.push(""); - lines.push("| screen | tiles | ✅ agree | ❌ disagree | 🟦 ui-only |"); - lines.push("|--------|-------|----------|-------------|------------|"); - for (const s of perScreen) { - lines.push( - `| ${s.screen} | ${s.total} | ${s.agree} | ${s.disagree} | ${s.uiOnly} |`, - ); - } - lines.push(""); - if (disagree === 0 && uiOnly === total) { - lines.push( - "> ⚠️ Zero disagreements detected at the API layer. Per PLN-738, a zero-disagreement run is a flag, not a celebration — verify the oracles are not just rubber-stamping the API. Look at the UI-audit Playwright run for layer-2 confirmation.", - ); - } else if (disagree === 0) { - lines.push( - "> ⚠️ Zero API↔oracle disagreements on this slice. Audit the oracles before scaling: a 0% disagreement rate after a real audit is suspicious. Cross-check with the UI-audit Playwright run.", - ); - } else { - lines.push( - `> Bugs found: ${disagree} tiles disagree between the API and the oracle on the fixture DB. See triage table below. Each disagreement should be filed as its own bug feature, linked RELATES_TO FEA-1415.`, - ); - } - lines.push(""); - - lines.push("## Top suspicious tiles"); - lines.push(""); - if (topSuspicious.length === 0) { - lines.push("_No disagreements on this run._"); - } else { - lines.push("| # | tile id | oracle (DB) | API returned | Δ | endpoint |"); - lines.push("|---|---------|-------------|--------------|----|----------|"); - topSuspicious.forEach((r, i) => { - lines.push( - `| ${i + 1} | \`${r.tile.id}\` | ${md(r.expected)} | ${md(r.apiValue)} | ${md(r.suspicion)} | \`${r.url ?? "—"}\` |`, - ); - }); - } - lines.push(""); - - lines.push("## Full table"); - lines.push(""); - lines.push( - "| tile id | label | oracle (DB) | rendered (expected) | API field | API returned | verdict |", - ); - lines.push( - "|---------|-------|-------------|---------------------|-----------|--------------|---------|", - ); - for (const r of rows) { - const apiCell = - r.tile.endpoint === "derived" - ? "_derived — see UI audit_" - : r.apiValue !== undefined - ? md(r.apiValue) - : `**MISSING** (${r.apiCmp?.reason ?? "?"})`; - const verdictEmoji = - r.verdict === "agree" - ? "✅ agree" - : r.verdict === "ui-only" - ? "🟦 ui-only" - : "❌ DISAGREE"; - lines.push( - `| \`${r.tile.id}\` | ${r.tile.label}${r.tile.trend_label ? ` (${r.tile.trend_label})` : ""} | ${md(r.expected)} | ${md(r.expectedFormatted)} | \`${r.tile.endpoint_field ?? "—"}\` | ${apiCell} | ${verdictEmoji} |`, - ); - } - lines.push(""); - - lines.push("## Triage notes per disagreement"); - lines.push(""); - const disagreements = rows.filter((r) => r.verdict === "disagree"); - if (disagreements.length === 0) { - lines.push("_None — see the headline warning about zero disagreements._"); - } else { - for (const r of disagreements) { - lines.push(`### \`${r.tile.id}\``); - lines.push(""); - lines.push(`- **Tile:** ${r.tile.label}${r.tile.trend_label ? ` (${r.tile.trend_label} trend)` : ""}`); - lines.push(`- **Oracle (DB ground truth):** \`${r.tile.oracle}\` → ${md(r.expected)}`); - lines.push(`- **API:** \`GET ${r.url}\` → \`${r.tile.endpoint_field}\` = ${md(r.apiValue)}`); - lines.push(`- **Δ:** ${md(r.suspicion)} (relative for counts, absolute for money)`); - lines.push(`- **Reason:** ${r.apiCmp?.reason ?? "(none)"}`); - lines.push(""); - lines.push( - `- **Triage layer:** API↔DB. Either the API aggregation is wrong, or the oracle is wrong. To distinguish: read the sidecar's implementation of the field, then re-derive by hand against the fixture data. If the sidecar matches your manual derivation, the oracle is wrong (rare — adjust \`oracles.mjs\`). If the sidecar doesn't match, file a bug feature linked RELATES_TO FEA-1415.`, - ); - if (r.tile.notes) { - lines.push(`- **Tile notes:** ${r.tile.notes}`); - } - lines.push(""); - } - } - - lines.push("## Methodology"); - lines.push(""); - lines.push( - "- **Fixture DB:** built by `helpers/seed-fixture-db.mjs` from the JSON fixtures in `fixtures/`. The same fixture DB that `dashboard.contract.test.mjs` uses.", - ); - lines.push( - "- **Oracle:** named SQL/JS function in `inventory/oracles.mjs`. Pure function of the DB. Does not call the parsers or the sidecar.", - ); - lines.push( - "- **API:** the real built agent-monitor sidecar (`scripts/build-agent-monitor.mjs`) booted against the fixture DB.", - ); - lines.push( - "- **Manifest:** `inventory/manifest.json`. Adding a tile = add a row + an oracle. No test code changes.", - ); - lines.push( - "- **UI audit:** runs as Playwright spec `specs/audit/dashboard.ui-audit.spec.ts`. Same oracle, asserted against the rendered text in the live sidecar iframe.", - ); - lines.push(""); - lines.push("## What this slice intentionally does not prove"); - lines.push(""); - lines.push( - "- **Parser correctness.** This Phase 0.5 slice seeds the DB directly from JSON. Phase 1 (parser-to-DB harness) wires raw-log fixtures through the real parsers so the chain `logs → parser → DB` is asserted. If the API agrees with the oracle here but parser output drifts from the same DB shape, only Phase 1 catches it.", - ); - lines.push( - "- **Health tab.** Out of scope for Phase 0.5; the Health tab is much larger (~15+ tiles, system telemetry) and gets its own slice.", - ); - lines.push( - "- **Other screens.** Analytics, Sessions, Session Detail, Workflows, Activity, Pull Requests, Packs, Skills, Tools, SubAgents, Plans, CC Config, Run, and host Electron panels are all out of scope for this slice. Phase 0 (full inventory) enumerates them; Phases 3–5 cover them.", - ); - lines.push(""); - lines.push("## Next steps"); - lines.push(""); - lines.push( - "1. **CEO review:** look at the disagreement triage table. Decide which disagreements to file as bugs and which need an oracle correction.", - ); - lines.push( - "2. **Sign-off:** if the slice approach holds, scale to Phase 0 (full app inventory) and Phase 1 (parser foundation).", - ); - lines.push( - "3. **UI audit:** run `pnpm --filter desktop test:audit:ui` to catch Layer-2 (UI↔API) bugs the API audit can't see.", - ); - - writeFileSync(REPORT_PATH, lines.join("\n") + "\n"); - console.log(`Report written: ${REPORT_PATH}`); - console.log( - `Headline: ${agree} agree · ${disagree} disagree · ${uiOnly} ui-only`, - ); -} - -main().catch((err) => { - console.error(err.stack ?? err); - process.exit(1); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/scan-tiles.mjs b/apps/desktop/test-e2e/agent-monitor/inventory/scan-tiles.mjs deleted file mode 100644 index b1e54e42..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/scan-tiles.mjs +++ /dev/null @@ -1,598 +0,0 @@ -// Data-summary inventory scanner — walks every page component (ClosedLoop -// overrides + upstream agent-dashboard pages NOT overridden) and emits a -// draft manifest for every UI surface that summarizes log-parsed DB data. -// -// "Data summary" is the right unit — not just "tile". It includes: -// - stat-card-style big numbers -// - per-row aggregates in tables (e.g., session.agent_count) -// - chart values, sparkline points, donut segments -// - sidebar/nav count badges -// - filter/scope captions ("Showing N of M") -// - status counters -// -// Detection is heuristic and intentionally over-emits. False positives are -// fine — humans dedupe. False negatives are dangerous, so the regex set is -// broad. Treat the output number as a SCOPE CEILING, not a final tile count. - -import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; -import { dirname, join, basename } from "node:path"; -import { fileURLToPath } from "node:url"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const REPO = join(HERE, "..", "..", "..", "..", ".."); -const DESKTOP = join(REPO, "apps", "desktop"); - -// ClosedLoop overrides (the source of truth — copied over upstream at build). -const OVERRIDE_GLOB_ROOTS = [ - join(DESKTOP, "scripts", "agent-monitor-client"), - join(DESKTOP, "scripts", "agent-monitor-packs", "client"), - join(DESKTOP, "scripts", "agent-monitor-plans", "client"), - join(DESKTOP, "scripts", "agent-monitor-pull-requests", "client"), -]; - -function locateUpstreamPagesDir() { - const pnpmDir = join(REPO, "node_modules", ".pnpm"); - const dirs = readdirSync(pnpmDir).filter((n) => - n.startsWith("agent-dashboard-client@"), - ); - if (dirs.length === 0) return null; - const candidate = join( - pnpmDir, - dirs[0], - "node_modules", - "agent-dashboard-client", - "src", - "pages", - ); - try { - statSync(candidate); - return candidate; - } catch { - return null; - } -} - -const UPSTREAM_PAGES = locateUpstreamPagesDir(); - -function walkTsx(root) { - const out = []; - let entries; - try { - entries = readdirSync(root); - } catch { - return out; - } - for (const e of entries) { - const p = join(root, e); - const s = statSync(p); - if (s.isDirectory()) out.push(...walkTsx(p)); - else if (e.endsWith(".tsx") || e.endsWith(".jsx")) out.push(p); - } - return out; -} - -const overrideFiles = OVERRIDE_GLOB_ROOTS.flatMap(walkTsx); -const overrideBasenames = new Set(overrideFiles.map((f) => basename(f))); -const upstreamFiles = UPSTREAM_PAGES - ? walkTsx(UPSTREAM_PAGES).filter((f) => !overrideBasenames.has(basename(f))) - : []; -const allFiles = [...overrideFiles, ...upstreamFiles].filter( - (f) => - !basename(f).endsWith(".test.tsx") && - !basename(f).endsWith(".test.jsx") && - !basename(f).startsWith("__"), -); - -// ------------------------------------------------------------------ DETECTORS - -// JSX `{...}` blocks. We approximate "JSX context" by requiring the `{` -// to be preceded by `>` or whitespace-only (an attribute or child position), -// not by `=` (assignment), `(`/`,` (function call/arg), `:` (object literal), -// `{` (block), or letters (function body). Without a real parser this is -// a heuristic — false positives are accepted, but skipping bare braces in -// function bodies eliminates the worst over-count. -function* iterJsxExpressions(source) { - let i = 0; - while (i < source.length) { - const open = source.indexOf("{", i); - if (open === -1) return; - - // Skip if the brace is clearly NOT a JSX expression slot. - // - // A `{` opens a JSX expression in these contexts: - // 1. Immediately after `>` (or whitespace after `>`) — child position - // 2. After `"` or `'` — end of an attribute string - // 3. After `=` IF preceded by a JSX attribute name — `attr={expr}` - // - // Everything else (function bodies, object literals, type generics, IIFE - // patterns) is rejected. JSX attribute slots were previously dropped - // because prevChar === '=' fell into the reject branch — see PR #246 - // codex-review finding [P2] #3. - const prevNonWs = (k) => { - for (; k >= 0; k--) { - const c = source[k]; - if (c === " " || c === "\t" || c === "\n" || c === "\r") continue; - return { ch: c, idx: k }; - } - return null; - }; - const prev = prevNonWs(open - 1); - const prevChar = prev ? prev.ch : ""; - - let accept = false; - if (!prevChar) { - // Start of file — treat as accept (defensive; rare in real files). - accept = true; - } else if ( - prevChar === ">" || - prevChar === '"' || - prevChar === "'" || - prevChar === "\n" - ) { - accept = true; - } else if (prevChar === "=") { - // JSX attribute slot only if the char before `=` is a valid attribute - // name terminator: letter, digit, underscore, or hyphen. Reserves the - // `x = {...}` assignment pattern for the reject branch (one space at - // minimum between `=` and `{`). - const before = prevNonWs(prev.idx - 1); - if (before && /[A-Za-z0-9_\-]/.test(before.ch)) { - accept = true; - } - } - - if (!accept) { - // Not a JSX expression slot — advance past this brace. - i = open + 1; - continue; - } - let depth = 1; - let j = open + 1; - while (j < source.length && depth > 0) { - const c = source[j]; - if (c === "{") depth++; - else if (c === "}") depth--; - j++; - } - if (depth === 0) { - yield { start: open, end: j, text: source.slice(open + 1, j - 1) }; - } - i = j; - } -} - -// Property paths anchored to "data-shape" identifiers commonly used for row -// objects, aggregates, and stats in this codebase. -const DATA_SHAPE_ID_RE = - /\b(stats|data|info|workflow|session|sessionStats|agent|agents|subagent|subagents|pack|packs|skill|skills|tool|tools|pr|prs|pull|plan|plans|row|item|entry|cell|seg|segment|bin|point|d|e|m|s|a|p|r|t|tu|costData|usage|tokens|model|modelStats|workflowData)\b\.[A-Za-z_$][A-Za-z0-9_$.]*/g; - -// Property suffixes that are almost always numeric (the noisy tail — caps -// false positives from generic identifiers). -const NUMERIC_SUFFIX_RE = - /\.(count|length|size|total|totals|sum|amount|avg|average|rate|pct|percent|percentage|score|tokens|input_tokens|output_tokens|cache_read_tokens|cache_write_tokens|cost|cost_today|cost_total|total_cost|cost_30d|duration|duration_ms|seconds|ms|elapsed|events|agents|sessions|subagents|errors|active|completed|working|waiting|pending|installed|enabled|disabled|hits|misses|peak|max|min|hours|minutes|days|kb|mb|gb|tps|qps)\b/; - -// Function-call patterns that turn a raw number into a rendered string. -const FORMATTER_CALL_RE = - /\b(fmt|fmtCost|fmtCostFull|formatBytes|formatUptime|formatDuration|formatMs|formatTime|formatDateTime|timeAgo)\s*\([^)]*\)/g; -const TO_LOCALE_RE = /([A-Za-z0-9_.\[\]?]+)\.toLocaleString\(\)/g; -const TO_FIXED_RE = /([A-Za-z0-9_.\[\]?]+)\.toFixed\(\s*\d+\s*\)/g; -const MATH_RE = /\bMath\.(round|floor|ceil|max|min|abs)\s*\(/g; - -// Stat-card-style named components (kept from prior scanner version). -const TILE_COMP_RE = - /<(StatCard|TrendBig|Tile|Metric|BigStat|CountTile)\b([\s\S]*?)\/?>/g; - -function lineOf(source, idx) { - let n = 1; - for (let i = 0; i < idx; i++) if (source[i] === "\n") n++; - return n; -} - -function classifyContext(source, idx) { - // Look back ~200 chars to spot what kind of UI element we're inside. - // Used to tag rows with surface=table_cell / chart / nav etc. - const before = source.slice(Math.max(0, idx - 200), idx); - if (/]*>$|]*>$|]*>\s*$/i.test(before)) - return "table_cell"; - if (/ ({ - id: `auto.${r.screen.toLowerCase()}.${i}`, - screen: r.screen, - route: routeForScreen(r.screen), - surface: r.surface, - detected_kind: r.kind, - value_expr: r.valueExpr, - file: r.file, - line: r.line, - label: null, - selector_kind: "label_slice", - endpoint: null, - endpoint_field: null, - oracle: null, - formatter: null, - tile_kind: "count", - priority: priorityForScreen(r.screen), - owner: null, - status: "needs_oracle", - bug_ref: null, - })), -}; - -function routeForScreen(name) { - const map = { - Dashboard: "/", - Sessions: "/sessions", - SessionDetail: "/sessions/:id", - Analytics: "/analytics", - Workflows: "/workflows", - CcConfig: "/cc-config", - KanbanBoard: "/kanban", - ActivityFeed: "/activity", - Run: "/run", - Plans: "/plans", - Skills: "/skills", - Tools: "/tools", - SubAgents: "/agents", - Packs: "/packs", - PacksLayout: "/packs", - PacksCatalog: "/packs", - PacksInstalled: "/packs", - PackDetail: "/packs/:packId", - CatalogDetail: "/packs", - CatalogCard: "/packs", - InstallModal: "/packs", - PullRequests: "/pull-requests", - Settings: "/settings", - Sparkline: "?", - StatusBadge: "?", - NotFound: "?", - }; - return map[name] ?? "?"; -} - -function priorityForScreen(name) { - const P0 = new Set([ - "Dashboard", - "Analytics", - "Sessions", - "SessionDetail", - "Workflows", - "ActivityFeed", - "PullRequests", - ]); - const P1 = new Set([ - "Packs", - "PacksLayout", - "PacksCatalog", - "PacksInstalled", - "PackDetail", - "Skills", - "Tools", - "SubAgents", - "Plans", - "CcConfig", - ]); - if (P0.has(name)) return "P0"; - if (P1.has(name)) return "P1"; - return "P2"; -} - -const DRAFT_MANIFEST_PATH = join(HERE, "manifest.scanned.json"); -writeFileSync(DRAFT_MANIFEST_PATH, JSON.stringify(draftManifest, null, 2)); - -// ------------------------------------------------------------ MARKDOWN REPORT - -const lines = []; -lines.push("# Full App Inventory — draft scan (v2 · data-summary aware)"); -lines.push(""); -lines.push( - `_Generated: ${new Date().toISOString()} · FEA-1415 / PLN-738 Phase 0 (draft)_`, -); -lines.push(""); -lines.push("## Headline"); -lines.push(""); -lines.push(`- Pages scanned: **${byScreen.size}**`); -lines.push(`- **Data-summary instances detected: ${grandTotal}**`); -lines.push(""); -lines.push(`### By kind`); -lines.push(""); -lines.push("| kind | count |"); -lines.push("|------|-------|"); -const kindOrder = [ - "stat_card", - "formatter_call", - "toLocaleString", - "toFixed", - "math", - "data_property", -]; -for (const k of kindOrder) { - const n = reportRows.filter((r) => r.kind === k).length; - lines.push(`| \`${k}\` | ${n} |`); -} -lines.push(""); -lines.push(`### By surface (where the value is rendered)`); -lines.push(""); -lines.push("| surface | count |"); -lines.push("|---------|-------|"); -for (const [k, v] of Object.entries(surfaceTotals).sort( - (a, b) => b[1] - a[1], -)) { - lines.push(`| \`${k}\` | ${v} |`); -} -lines.push(""); -lines.push( - "> Counts include false positives (the same value rendered in a tile and its tooltip; type-narrowing chains; expression sub-parts). Realistic *unique-tile* count is typically 40–60% of the total. The number is a **scope ceiling** for engineering planning, not a final tile count.", -); -lines.push(""); - -lines.push("## Per-screen breakdown"); -lines.push(""); -lines.push( - "| screen | origin | stat_card | formatter | toLocale | toFixed | math | data_prop | TOTAL |", -); -lines.push( - "|--------|--------|-----------|-----------|----------|---------|------|-----------|-------|", -); -const screens = [...byScreen.entries()].sort((a, b) => b[1].total - a[1].total); -for (const [name, e] of screens) { - lines.push( - `| ${name} | ${e.origin} | ${e.by_kind.stat_card} | ${e.by_kind.formatter_call} | ${e.by_kind.toLocaleString} | ${e.by_kind.toFixed} | ${e.by_kind.math} | ${e.by_kind.data_property} | **${e.total}** |`, - ); -} -lines.push(""); - -lines.push("## Detail — every detected data summary (grouped by screen)"); -lines.push(""); -for (const [name] of screens) { - const rows = reportRows.filter((r) => r.screen === name); - if (rows.length === 0) continue; - lines.push(`### ${name}`); - lines.push(""); - lines.push("| kind | surface | value expression | file:line |"); - lines.push("|------|---------|------------------|-----------|"); - for (const r of rows) { - lines.push( - `| ${r.kind} | ${r.surface} | \`${truncate(r.valueExpr, 80)}\` | ${r.file}:${r.line} |`, - ); - } - lines.push(""); -} - -lines.push("## Output files"); -lines.push(""); -lines.push( - `- \`INVENTORY-DRAFT.md\` — this markdown summary (committed: NO, gitignored)`, -); -lines.push( - `- \`manifest.scanned.json\` — machine-readable draft manifest with one row per detection, every row \`status: "needs_oracle"\``, -); -lines.push(""); -lines.push( - "Curated manifest stays at `manifest.json`. The draft is for scoping and bulk-import; humans copy rows from draft → curated as oracles are written.", -); -lines.push(""); -lines.push("## What this scanner still does NOT detect"); -lines.push(""); -lines.push( - "- Numbers rendered as SVG path geometry without a text node (bar heights, donut arcs). The underlying data accessor is usually caught via `data_property`; the geometric encoding itself is invisible to a regex scan.", -); -lines.push( - "- Numbers computed via i18n interpolation: `t('common:pagination.showing', {total})`. The number ends up rendered but the JSX expression sees only the i18n key.", -); -lines.push( - "- Numbers in components OUTSIDE pages/ that aren't named per-screen (e.g., a shared `` component used by multiple pages).", -); -lines.push( - "- Host Electron panels (Approvals, Requests, Diagnostics, host Settings) — separate codebase under `apps/desktop/src/renderer`, not yet scanned.", -); -lines.push(""); - -const OUT = join(HERE, "INVENTORY-DRAFT.md"); -writeFileSync(OUT, lines.join("\n") + "\n"); -console.log(`Wrote ${OUT}`); -console.log(`Wrote ${DRAFT_MANIFEST_PATH}`); -console.log( - `Screens: ${byScreen.size} · data-summary instances: ${grandTotal}`, -); - -function truncate(s, n) { - if (s == null) return ""; - if (s.length <= n) return s; - return s.slice(0, n - 1) + "…"; -} diff --git a/apps/desktop/test-e2e/agent-monitor/inventory/triage/01-total-cost-pattern-override.md b/apps/desktop/test-e2e/agent-monitor/inventory/triage/01-total-cost-pattern-override.md deleted file mode 100644 index a97c50d7..00000000 --- a/apps/desktop/test-e2e/agent-monitor/inventory/triage/01-total-cost-pattern-override.md +++ /dev/null @@ -1,79 +0,0 @@ -# Triage: `dashboard.monitor.total_cost` — pricing pattern silently overridden - -**Found by:** Phase 0.5 audit (FEA-1415 / PLN-738), first run -**Tile:** Total Cost on Dashboard Monitor tab -**Severity assessment (for CEO):** likely **product-relevant**, not just a test artifact — see "Why this matters in production" below - -## The disagreement - -| layer | value | -|---|---| -| Oracle on fixture DB (`dashboard_total_cost`) | **$0.169125** | -| Sidecar API (`GET /api/pricing/cost?tz_offset=0` → `total_cost`) | **$0.0604** | -| Delta | **$0.108725 (64% high) vs API** | - -The legacy `dashboard.contract.test.mjs` only schema-checks this endpoint (asserts a key exists), so this disagreement has been silently shippable. - -## Root cause (mechanically) - -In `node_modules/.../agent-dashboard/server/db.js` (line ~152, `DEFAULT_PRICING`): - -```js -const DEFAULT_PRICING = [ - ["claude-opus-4-7%", "Claude Opus 4.7", 5, 25, 0.5, 6.25], - ... -]; -``` - -On startup the sidecar runs: -```js -"INSERT OR IGNORE INTO model_pricing (model_pattern, ...) VALUES (?, ...)" -``` - -The fixture row uses **pattern `claude-opus-4-7`** (no trailing `%`). The default row uses **pattern `claude-opus-4-7%`** (different string, so `INSERT OR IGNORE` doesn't skip — both rows land in the DB). - -In `agent-dashboard/server/routes/pricing.js`: - -```js -const sortedRules = [...pricingRules].sort( - (a, b) => b.model_pattern.length - a.model_pattern.length -); -for (const row of tokenRows) { - const rule = sortedRules.find((p) => { - const pattern = p.model_pattern.replace(/%/g, ".*"); - return new RegExp("^" + pattern + "$").test(row.model); - }); - // … apply rule.input_per_mtok etc. -} -``` - -Patterns are sorted **by length descending** and the **first regex match wins**. Since `claude-opus-4-7%` (length 16) sorts before `claude-opus-4-7` (length 15), and the regex `^claude-opus-4-7.*$` matches the model `claude-opus-4-7`, the **wildcard pattern's rates** are applied — not the more specific exact match. - -Per-row math with the wildcard rates (5/25/0.5/6.25) matches the API's $0.0604; with the fixture rates (15/75/1.5/18.75) it matches the oracle's $0.169125. - -## Why this matters in production (not just in tests) - -This isn't only a test fixture quirk. It exposes a **user-visible misbehavior** in how the dashboard handles manual pricing edits: - -1. A user opens Settings → Pricing and adds a row for `claude-opus-4-7` with their negotiated/custom rate. -2. The dashboard reboots. The startup top-up sees no existing row with pattern `claude-opus-4-7%` (only `claude-opus-4-7` exists) and inserts the default at its rate. -3. From that point forward, **the wildcard default silently outranks the user's exact entry** because `calculateCost` sorts by length descending. - -That's a UX bug worth filing — silent override of explicit user pricing — independent of the test scenario. - -## Recommended fixes (ranked) - -1. **Best — fix the matcher to prefer exact matches over wildcard matches**, regardless of length. Sort key should be `(hasWildcard ? 0 : 1, -length)` so an exact-match rule wins over an equal-or-longer wildcard rule. -2. **Acceptable — change `INSERT OR IGNORE`'s uniqueness key** to also collide on the unwildcarded suffix (e.g., index on `replace(model_pattern, '%', '')`) so the user's specific row prevents the default from being inserted at all. -3. **Test-only workaround** — update fixture `model_pricing` patterns to include a trailing `%` so they exactly equal the default pattern and `INSERT OR IGNORE` skips the default. This silences the test but ships the bug. - -## What the CEO needs to decide - -- File a bug feature `RELATES_TO FEA-1415` against the **agent-dashboard upstream** (Claude-Code-Agent-Monitor on GitHub) for the matcher behavior. We patch it via the build patch chain in `scripts/build-agent-monitor.mjs` until upstream merges. -- For the Phase 0.5 audit: leave the test asserting the **oracle value** (`$0.169125`). Once the matcher is fixed, the API will start agreeing with the oracle and the test stays green. Until then, the test stays red and announces the bug — which is the point. - -## Linked artifacts - -- Manifest row: `apps/desktop/test-e2e/agent-monitor/inventory/manifest.json` → `dashboard.monitor.total_cost` -- Oracle: `apps/desktop/test-e2e/agent-monitor/inventory/oracles.mjs` → `dashboard_total_cost` -- Bug feature in closedloop: **[FEA-1418 — BUG: agent-monitor pricing matcher silently overrides exact-match user entries with wildcard defaults](https://app.closedloop.ai/closedloop-ai/features/FEA-1418)** (filed in Andrew's Backlog; linked RELATES_TO FEA-1415) diff --git a/apps/desktop/test-e2e/agent-monitor/playwright.audit.config.ts b/apps/desktop/test-e2e/agent-monitor/playwright.audit.config.ts deleted file mode 100644 index 89d151e3..00000000 --- a/apps/desktop/test-e2e/agent-monitor/playwright.audit.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Dedicated Playwright config for the manifest-driven UI-audit specs in -// ./specs/audit. Kept separate from playwright.config.ts so that running -// the default `test:e2e` command does NOT pick up audit specs that -// intentionally assert current (buggy) behavior — those specs are gated -// behind `test:audit:ui`. -// -// Everything else (workers, reporters, baseURL resolution, setup/teardown) -// inherits from the e2e config so the two stay in sync. - -import baseConfig from "./playwright.config.js"; -import { defineConfig } from "@playwright/test"; - -export default defineConfig({ - ...baseConfig, - testMatch: /audit\/.*\.spec\.ts$/, -}); diff --git a/apps/desktop/test-e2e/agent-monitor/playwright.config.ts b/apps/desktop/test-e2e/agent-monitor/playwright.config.ts deleted file mode 100644 index 85f6f1c3..00000000 --- a/apps/desktop/test-e2e/agent-monitor/playwright.config.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; -import { readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; - -// Resolve baseURL from the state file globalSetup writes. We can't rely on -// process.env.E2E_BASE_URL here: this config is evaluated at controller load -// time, BEFORE globalSetup runs. Workers re-evaluate the config after -// globalSetup, but if the env mutation didn't propagate cleanly the run -// silently degrades to relative-URL goto failures. The state file is the -// stable bridge — globalSetup writes it before the workers start. -function resolveBaseUrl(): string | undefined { - const statePath = join( - process.env.RUNNER_TEMP || "/tmp", - "closedloop-e2e-sidecar-state.json", - ); - if (existsSync(statePath)) { - try { - const { baseUrl } = JSON.parse(readFileSync(statePath, "utf8")); - if (baseUrl) return baseUrl; - } catch { - /* fall through to env */ - } - } - return process.env.E2E_BASE_URL; -} - -export default defineConfig({ - // Default e2e config covers ONLY ./specs/ui. Audit Playwright specs live - // in ./specs/audit and are run via a dedicated config - // (playwright.audit.config.ts) so a known-failing audit spec - // (e.g. dashboard.ui-audit.spec.ts asserting the FEA-1418 cost bug) does - // NOT block the default `test:e2e` command. - testDir: "./specs", - testMatch: /ui\/.*\.spec\.ts$/, - workers: 1, - fullyParallel: false, - reporter: [["list"]], - timeout: 30_000, - expect: { timeout: 5_000 }, - globalSetup: "./helpers/playwright-global-setup.ts", - globalTeardown: "./helpers/playwright-global-teardown.ts", - use: { - baseURL: resolveBaseUrl(), - trace: "retain-on-failure", - screenshot: "only-on-failure", - }, - projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - ], -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/api-contract/dashboard.contract.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/api-contract/dashboard.contract.test.mjs deleted file mode 100644 index 70404ebf..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/api-contract/dashboard.contract.test.mjs +++ /dev/null @@ -1,93 +0,0 @@ -// Layer-1 HTTP contract test for the dashboard-tile endpoints. These are -// served by the upstream agent-dashboard package, but they are the API surface -// the UI reads — and a behavior change there (or in the build patch chain) -// would silently break the Dashboard view. - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - seedFixtureDb(tmp.dbPath); - sidecar = await launchSidecar({ dbPath: tmp.dbPath }); - reseedPacksAndSkills(tmp.dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -test("GET /api/stats matches fixture session/agent counts", async () => { - const res = await fetch(`${baseUrl}/api/stats?tz_offset=0`); - assert.equal(res.status, 200); - const body = await res.json(); - assert.equal(body.total_sessions, 5); - assert.equal(body.active_sessions, 2); - assert.equal(body.total_agents, 6); - assert.equal(body.active_agents, 2); - assert.equal(body.sessions_by_status.active, 2); - assert.equal(body.sessions_by_status.completed, 2); - assert.equal(body.sessions_by_status.error, 1); - assert.equal(body.agents_by_status.working, 2); -}); - -test("GET /api/sessions returns all 5 fixture sessions", async () => { - const res = await fetch(`${baseUrl}/api/sessions?limit=50`); - assert.equal(res.status, 200); - const body = await res.json(); - const list = Array.isArray(body) ? body : body.sessions || body.items; - assert.ok(Array.isArray(list), "sessions list is an array"); - const fixtureIds = list - .map((s) => s.id) - .filter((id) => id.startsWith("fixture-sess-")) - .sort(); - assert.deepEqual(fixtureIds, [ - "fixture-sess-active-1", - "fixture-sess-active-2", - "fixture-sess-completed-1", - "fixture-sess-completed-2", - "fixture-sess-error-1", - ]); -}); - -test("GET /api/agents?status=working returns the 2 working fixture agents", async () => { - const res = await fetch(`${baseUrl}/api/agents?status=working&limit=20`); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body.agents)); - const fixtureWorking = body.agents.filter((a) => - a.session_id.startsWith("fixture-sess-"), - ); - assert.equal(fixtureWorking.length, 2); - const sessIds = fixtureWorking.map((a) => a.session_id).sort(); - assert.deepEqual(sessIds, [ - "fixture-sess-active-1", - "fixture-sess-active-2", - ]); -}); - -test("GET /api/pricing/cost?tz_offset=0 returns cost breakdown by model", async () => { - const res = await fetch(`${baseUrl}/api/pricing/cost?tz_offset=0`); - assert.equal(res.status, 200); - const body = await res.json(); - // Schema check — actual numbers depend on the dashboard's pricing math. - // What we care about: the response surface is stable. - assert.ok( - "total_cost" in body || "total" in body || "by_model" in body, - `cost response should expose total/by_model; got keys: ${Object.keys(body).join(", ")}`, - ); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/api-contract/packs.contract.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/api-contract/packs.contract.test.mjs deleted file mode 100644 index 61d1a727..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/api-contract/packs.contract.test.mjs +++ /dev/null @@ -1,97 +0,0 @@ -// Layer-1 HTTP contract test: spawns the real sidecar against the fixture DB -// and asserts the JSON shape the UI consumes. Mounts the actual generated -// routes, so a field rename in apps/desktop/scripts/agent-monitor-packs/*.js -// (or the upstream routes copied over them) will fail here before the -// matching Playwright UI test fails. - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - seedFixtureDb(tmp.dbPath); - sidecar = await launchSidecar({ dbPath: tmp.dbPath }); - reseedPacksAndSkills(tmp.dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -test("GET /api/packs returns one row per installed pack_id", async () => { - const res = await fetch(`${baseUrl}/api/packs`); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body.items), "items is an array"); - const ids = body.items.map((p) => p.pack_id).sort(); - assert.deepEqual(ids, [ - "fixture-pack-alpha", - "fixture-pack-beta", - "fixture-pack-gamma", - ]); -}); - -test("GET /api/packs surfaces install_count, skill_count, harnesses per pack", async () => { - const res = await fetch(`${baseUrl}/api/packs`); - const body = await res.json(); - const byId = Object.fromEntries(body.items.map((p) => [p.pack_id, p])); - - // alpha: 1 install (claude), 2 skills - assert.equal(byId["fixture-pack-alpha"].install_count, 1); - assert.equal(byId["fixture-pack-alpha"].skill_count, 2); - assert.equal(byId["fixture-pack-alpha"].harnesses, "claude"); - - // beta: 2 installs (claude + codex), 1 skill, multi-harness comma-joined - assert.equal(byId["fixture-pack-beta"].install_count, 2); - assert.equal(byId["fixture-pack-beta"].skill_count, 1); - const betaHarnesses = byId["fixture-pack-beta"].harnesses.split(",").sort(); - assert.deepEqual(betaHarnesses, ["claude", "codex"]); - - // gamma: 1 install, 0 skills (zero-skill rendering surface) - assert.equal(byId["fixture-pack-gamma"].install_count, 1); - assert.equal(byId["fixture-pack-gamma"].skill_count, 0); -}); - -test("GET /api/packs/:id returns pack detail with installs[] and skills[]", async () => { - const res = await fetch(`${baseUrl}/api/packs/fixture-pack-alpha`); - assert.equal(res.status, 200); - const body = await res.json(); - assert.equal(body.pack_id, "fixture-pack-alpha"); - assert.ok(Array.isArray(body.installs)); - assert.equal(body.installs.length, 1); - assert.equal(body.installs[0].harness, "claude"); - assert.ok(Array.isArray(body.skills)); - assert.equal(body.skills.length, 2); - const skillNames = body.skills.map((s) => s.name).sort(); - assert.deepEqual(skillNames, ["alpha-skill-one", "alpha-skill-two"]); -}); - -test("GET /api/packs/:id 404s for an unknown pack", async () => { - const res = await fetch(`${baseUrl}/api/packs/does-not-exist-anywhere`); - assert.equal(res.status, 404); - const body = await res.json(); - assert.match(body.error?.message || "", /not found/i); -}); - -test("GET /api/packs/:id/skills lists only that pack's skills", async () => { - const res = await fetch(`${baseUrl}/api/packs/fixture-pack-beta/skills`); - assert.equal(res.status, 200); - const body = await res.json(); - assert.equal(body.items.length, 1); - assert.equal(body.items[0].name, "beta-skill-one"); - assert.equal(body.items[0].pack_id, "fixture-pack-beta"); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/api-contract/pull-requests.contract.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/api-contract/pull-requests.contract.test.mjs deleted file mode 100644 index 16ac0274..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/api-contract/pull-requests.contract.test.mjs +++ /dev/null @@ -1,73 +0,0 @@ -// Layer-1 HTTP contract test for the pull-requests router copied into the -// sidecar at apps/desktop/scripts/agent-monitor-pull-requests/pull-requests-route.js - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - seedFixtureDb(tmp.dbPath); - sidecar = await launchSidecar({ dbPath: tmp.dbPath }); - reseedPacksAndSkills(tmp.dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -test("GET /api/pull-requests returns the captured PR list with total/limit/offset", async () => { - const res = await fetch(`${baseUrl}/api/pull-requests`); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body.pull_requests)); - assert.equal(body.total, 3); - const urls = body.pull_requests.map((p) => p.pr_url).sort(); - assert.deepEqual(urls, [ - "https://github.com/example/fixture-repo-a/pull/42", - "https://github.com/example/fixture-repo-a/pull/43", - "https://github.com/example/fixture-repo-c/pull/7", - ]); -}); - -test("each PR row exposes the fields the UI renders (repo, branch, harness, title)", async () => { - const res = await fetch(`${baseUrl}/api/pull-requests`); - const body = await res.json(); - const fortyTwo = body.pull_requests.find((p) => p.pr_number === 42); - assert.ok(fortyTwo, "PR #42 should be present"); - assert.equal(fortyTwo.repo_full_name, "example/fixture-repo-a"); - assert.equal(fortyTwo.branch_name, "fix/auth-bug"); - assert.equal(fortyTwo.harness, "claude"); - assert.match(fortyTwo.title, /Fix auth bug/); - assert.equal(fortyTwo.session_id, "fixture-sess-completed-1"); -}); - -test("PR list contains entries from multiple harnesses (claude + codex)", async () => { - const res = await fetch(`${baseUrl}/api/pull-requests`); - const body = await res.json(); - const harnesses = new Set(body.pull_requests.map((p) => p.harness)); - assert.ok(harnesses.has("claude")); - assert.ok(harnesses.has("codex")); -}); - -test("PR list contains entries from multiple repos (a + c)", async () => { - const res = await fetch(`${baseUrl}/api/pull-requests`); - const body = await res.json(); - const repos = new Set(body.pull_requests.map((p) => p.repo_full_name)); - assert.equal(repos.size, 2); - assert.ok(repos.has("example/fixture-repo-a")); - assert.ok(repos.has("example/fixture-repo-c")); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/api-contract/stale-on-restart.contract.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/api-contract/stale-on-restart.contract.test.mjs deleted file mode 100644 index 9caf3bf7..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/api-contract/stale-on-restart.contract.test.mjs +++ /dev/null @@ -1,135 +0,0 @@ -// Regression test for FEA-1390: the sidecar's startup-time stale-session -// cleanup must not reap an "active" session that was just paused (long Bash -// tool, awaiting input). Anchor on updated_at, use 180-min threshold, and -// mark stale sessions 'abandoned' not 'completed'. -// -// Pre-fix, an "active" session with started_at > 1 hour ago was unconditionally -// flipped to 'completed' on boot — even if its updated_at was 1 minute ago. - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; -import { DatabaseSync } from "node:sqlite"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { makeTempDbPath } from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const SCHEMA_PATH = join(HERE, "..", "..", "fixtures", "schema.sql"); - -// Build a DB containing exactly two sessions: -// - "recently-active": started 6 hours ago, updated 5 minutes ago, no events -// in last hour. Pre-fix this would flip to 'completed'. Post-fix it MUST -// stay 'active'. -// - "genuinely-stale": started 7 days ago, last updated 7 days ago, no -// recent events. Should flip to 'abandoned' (NOT 'completed'). -function buildBugFixDb(dbPath) { - const db = new DatabaseSync(dbPath); - db.exec(readFileSync(SCHEMA_PATH, "utf8")); - const now = new Date(); - const minutesAgo = (m) => new Date(now.getTime() - m * 60_000).toISOString(); - const daysAgo = (d) => new Date(now.getTime() - d * 86_400_000).toISOString(); - - // Recently-active session — paused on a long tool, but recently touched. - db.prepare( - "INSERT INTO sessions (id, name, status, cwd, model, started_at, updated_at, harness) VALUES (?, ?, 'active', ?, ?, ?, ?, 'claude')", - ).run( - "fea-1390-recent", - "Recently-active fixture", - "/tmp/fea-1390-recent", - "claude-opus-4-7", - minutesAgo(6 * 60), - minutesAgo(5), - ); - // An old "PreToolUse" event for the long-Bash case (no recent activity in - // events, but updated_at is fresh because the harness watcher pinged us). - db.prepare( - "INSERT INTO events (session_id, event_type, tool_name, data, created_at) VALUES (?, 'PreToolUse', 'Bash', '{\"command\":\"npm install\"}', ?)", - ).run("fea-1390-recent", minutesAgo(90)); - - // Genuinely-stale session — a week old, never closed. - db.prepare( - "INSERT INTO sessions (id, name, status, cwd, model, started_at, updated_at, harness) VALUES (?, ?, 'active', ?, ?, ?, ?, 'claude')", - ).run( - "fea-1390-stale", - "Genuinely-stale fixture", - "/tmp/fea-1390-stale", - "claude-opus-4-7", - daysAgo(7), - daysAgo(7), - ); - - db.close(); -} - -let sidecar; -let cleanupDb; -let dbPath; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - dbPath = tmp.dbPath; - buildBugFixDb(dbPath); - sidecar = await launchSidecar({ dbPath }); -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -test("recently-active session is NOT reaped at sidecar boot (regression: FEA-1390)", () => { - const db = new DatabaseSync(dbPath, { readOnly: true }); - const row = db - .prepare("SELECT status FROM sessions WHERE id = ?") - .get("fea-1390-recent"); - db.close(); - assert.equal( - row.status, - "active", - "session whose updated_at is < 180 min must remain active across sidecar boot", - ); -}); - -test("genuinely-stale session is marked 'abandoned' (not 'completed') at boot", () => { - const db = new DatabaseSync(dbPath, { readOnly: true }); - const row = db - .prepare("SELECT status FROM sessions WHERE id = ?") - .get("fea-1390-stale"); - db.close(); - assert.equal( - row.status, - "abandoned", - "stale sessions should be 'abandoned' on boot — we lost contact, we don't know they completed", - ); -}); - -test("the boot cleanup respects DASHBOARD_STALE_MINUTES override", async () => { - // Run a second sidecar with a 1-minute threshold and confirm the recently- - // active fixture (updated 5 min ago) flips to abandoned. This proves the - // env var actually reaches the SQL. - const tmp2 = makeTempDbPath(); - buildBugFixDb(tmp2.dbPath); - const sidecar2 = await launchSidecar({ - dbPath: tmp2.dbPath, - env: { DASHBOARD_STALE_MINUTES: "1" }, - }); - try { - const db = new DatabaseSync(tmp2.dbPath, { readOnly: true }); - const row = db - .prepare("SELECT status FROM sessions WHERE id = ?") - .get("fea-1390-recent"); - db.close(); - assert.equal( - row.status, - "abandoned", - "with DASHBOARD_STALE_MINUTES=1, the 5-min-old session should be abandoned", - ); - } finally { - await sidecar2.stop(); - tmp2.cleanup(); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/all-screens.api-audit.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/all-screens.api-audit.test.mjs deleted file mode 100644 index e8e0dbab..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/all-screens.api-audit.test.mjs +++ /dev/null @@ -1,142 +0,0 @@ -// Layer-1 audit: every manifest tile whose endpoint exposes a single numeric -// field is asserted against its oracle on the fixture DB. -// -// **Table-driven across ALL screens in the manifest.** Adding a tile means -// adding a manifest row + (if needed) a new oracle function. No new test -// code anywhere. - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; -import { endpointUrlForRow, loadManifest } from "../../inventory/manifest-loader.mjs"; -import { - computeOracle, - compareNumeric, - getField, - openDb, -} from "../../inventory/audit-runner.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; -let dbPath; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - dbPath = tmp.dbPath; - seedFixtureDb(dbPath); - sidecar = await launchSidecar({ dbPath }); - reseedPacksAndSkills(dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -const manifest = loadManifest(); -const endpointCache = new Map(); - -async function fetchOnce(endpointWithQuery) { - if (endpointCache.has(endpointWithQuery)) { - return endpointCache.get(endpointWithQuery); - } - const url = `${baseUrl}${endpointWithQuery}`; - const res = await fetch(url); - if (!res.ok) { - throw new Error(`GET ${endpointWithQuery} -> ${res.status}`); - } - const body = await res.json(); - endpointCache.set(endpointWithQuery, body); - return body; -} - -// One test per tile. -for (const row of manifest.tiles) { - const url = endpointUrlForRow(row); - if (!url) continue; // derived/UI-only tiles - - // Tiles with a filed bug fail expectedly — flag as `todo` so CI passes - // until the bug is fixed. When the bug is fixed the test will pass and - // node:test surfaces "todo passed" as a signal to drop the todo flag. - const testOpts = row.bug_ref - ? { todo: `expected failure — ${row.bug_ref}` } - : {}; - - test(`${row.screen} · ${row.id} (${row.endpoint}.${row.endpoint_field})`, testOpts, async () => { - const body = await fetchOnce(url); - const apiValue = getField(body, row.endpoint_field); - assert.notEqual( - apiValue, - undefined, - `field "${row.endpoint_field}" missing in response from ${url}`, - ); - - const db = openDb(dbPath); - try { - const { expected } = computeOracle(row, db, { tzOffsetMinutes: 0 }); - const result = compareNumeric(Number(apiValue), Number(expected)); - assert.ok( - result.ok, - `\n` + - ` manifest id: ${row.id}\n` + - ` screen: ${row.screen}${row.tab ? " · " + row.tab : ""}\n` + - ` endpoint: GET ${url}\n` + - ` field: ${row.endpoint_field}\n` + - ` api value: ${JSON.stringify(apiValue)}\n` + - ` oracle: ${row.oracle} -> ${expected}\n` + - ` reason: ${result.reason}\n` + - ` bug_ref: ${row.bug_ref ?? "(none yet — file one)"}\n`, - ); - } finally { - db.close(); - } - }); -} - -// Structural assertions (list-length checks) -for (const row of manifest.structural) { - if (!row.endpoint || row.endpoint === "derived") continue; - const testOpts = row.bug_ref - ? { todo: `expected failure — ${row.bug_ref}` } - : {}; - test(`structural · ${row.screen} · ${row.id} (${row.endpoint} length)`, testOpts, async () => { - const url = row.endpoint.startsWith("/") ? row.endpoint : `/${row.endpoint}`; - const body = await fetchOnce(url); - const list = Array.isArray(body) - ? body - : body.agents || body.sessions || body.events || body.items || - body.skills || body.packs || body.pricing; - assert.ok(Array.isArray(list), `expected a list response from ${url}`); - - const db = openDb(dbPath); - try { - const { expected } = computeOracle(row, db); - // Filter to fixture rows only. The DB shape uses different id columns - // depending on the table — id (sessions/agents), session_id (events, - // PRs), skill_id (skills), pack_id (agent_packs). Pull the first - // "fixture-"-prefixed value off any id-shaped field. - const fixtureRows = list.filter((r) => { - for (const k of ["id", "session_id", "skill_id", "pack_id"]) { - if (r[k] && String(r[k]).startsWith("fixture-")) return true; - } - return false; - }); - const result = compareNumeric(fixtureRows.length, Number(expected)); - assert.ok( - result.ok, - `\n ${row.id}: list length ${fixtureRows.length}, oracle ${expected}`, - ); - } finally { - db.close(); - } - }); -} diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/bucketed-counts.audit.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/bucketed-counts.audit.test.mjs deleted file mode 100644 index 1a970259..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/bucketed-counts.audit.test.mjs +++ /dev/null @@ -1,175 +0,0 @@ -// Per-bucket audit for `agents_by_status` and `sessions_by_status` -// (returned by /api/analytics). If any bucket count is wrong, the page -// shows a wrong number — the audit catches that even when the aggregate -// total agrees. - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; -import { - sessions_count_by_status, - agents_count_by_status, -} from "../../inventory/oracles.mjs"; -import { compareNumeric, openDb } from "../../inventory/audit-runner.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; -let dbPath; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - dbPath = tmp.dbPath; - seedFixtureDb(dbPath); - sidecar = await launchSidecar({ dbPath }); - reseedPacksAndSkills(dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -test("/api/analytics.sessions_by_status — per-bucket counts match oracle", async () => { - const res = await fetch(`${baseUrl}/api/analytics?tz_offset=0`); - const body = await res.json(); - const buckets = body.sessions_by_status || {}; - - const db = openDb(dbPath); - try { - const failures = []; - for (const [status, apiCount] of Object.entries(buckets)) { - const expected = sessions_count_by_status(db, { status }); - const cmp = compareNumeric(Number(apiCount), expected); - if (!cmp.ok) { - failures.push({ status, apiCount, expected, reason: cmp.reason }); - } - } - assert.deepEqual( - failures, - [], - `sessions_by_status disagreements:\n` + - failures - .map((f) => ` ${f.status}: api=${f.apiCount} oracle=${f.expected}`) - .join("\n"), - ); - } finally { - db.close(); - } -}); - -test("/api/analytics.agents_by_status — per-bucket counts match oracle", async () => { - const res = await fetch(`${baseUrl}/api/analytics?tz_offset=0`); - const body = await res.json(); - const buckets = body.agents_by_status || {}; - - const db = openDb(dbPath); - try { - const failures = []; - for (const [status, apiCount] of Object.entries(buckets)) { - const expected = agents_count_by_status(db, { status }); - const cmp = compareNumeric(Number(apiCount), expected); - if (!cmp.ok) { - failures.push({ status, apiCount, expected, reason: cmp.reason }); - } - } - assert.deepEqual( - failures, - [], - `agents_by_status disagreements:\n` + - failures - .map((f) => ` ${f.status}: api=${f.apiCount} oracle=${f.expected}`) - .join("\n"), - ); - } finally { - db.close(); - } -}); - -test("/api/analytics — total_subagents matches sum of agent_types per-bucket", async () => { - // Cross-check: total_subagents (a scalar) should equal SUM(agent_types[i].count). - // Disagreement → either the scalar or the array is wrong. - const res = await fetch(`${baseUrl}/api/analytics?tz_offset=0`); - const body = await res.json(); - const sumFromTypes = (body.agent_types || []).reduce( - (s, r) => s + Number(r.count || 0), - 0, - ); - const scalar = Number(body.total_subagents || 0); - // Note: agent_types may include main agents too — check the actual upstream. - // For now we just record disagreement; the audit narrative explains. - if (scalar !== sumFromTypes) { - console.log( - ` [info] cross-check: total_subagents (${scalar}) ≠ sum(agent_types.count) (${sumFromTypes}). May be intentional (agent_types includes main agents) or a bug.`, - ); - } - // Don't fail the test on this — it's a diagnostic, not an assertion. -}); - -test("/api/events?event_type=PreToolUse — total filters correctly", async () => { - const res = await fetch(`${baseUrl}/api/events?event_type=PreToolUse&limit=1`); - const body = await res.json(); - const apiTotal = Number(body.total ?? 0); - - const db = openDb(dbPath); - try { - const oracleTotal = Number( - db - .prepare( - `SELECT COUNT(*) AS n FROM events WHERE event_type = 'PreToolUse'`, - ) - .get().n, - ); - assert.equal( - apiTotal, - oracleTotal, - `/api/events?event_type=PreToolUse.total (${apiTotal}) ≠ DB count (${oracleTotal})`, - ); - } finally { - db.close(); - } -}); - -test("/api/events?tool_name=Bash — total filters correctly", async () => { - const res = await fetch(`${baseUrl}/api/events?tool_name=Bash&limit=1`); - const body = await res.json(); - const apiTotal = Number(body.total ?? 0); - - const db = openDb(dbPath); - try { - const oracleTotal = Number( - db - .prepare(`SELECT COUNT(*) AS n FROM events WHERE tool_name = 'Bash'`) - .get().n, - ); - assert.equal( - apiTotal, - oracleTotal, - `/api/events?tool_name=Bash.total (${apiTotal}) ≠ DB count (${oracleTotal})`, - ); - } finally { - db.close(); - } -}); - -test("/api/analytics — daily_events lengths and daily_sessions lengths are equal", async () => { - // Both arrays should cover the same date window. If they differ, one of the - // bucketing queries is off by a day or has an inconsistent date range. - const res = await fetch(`${baseUrl}/api/analytics?tz_offset=0`); - const body = await res.json(); - const de = (body.daily_events || []).length; - const ds = (body.daily_sessions || []).length; - assert.equal( - de, - ds, - `daily_events.length (${de}) ≠ daily_sessions.length (${ds}). One series is missing or extra days — date bucketing inconsistency.`, - ); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/claude-hooks.contract.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/claude-hooks.contract.test.mjs deleted file mode 100644 index 1dfc7a03..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/claude-hooks.contract.test.mjs +++ /dev/null @@ -1,214 +0,0 @@ -// Claude hook contract test. POSTs a representative hook event sequence -// to /api/hooks/event and asserts the resulting SQL DB rows match the -// fixture's expected shape. -// -// This is the "Claude" branch of the parser-equivalence story — Claude -// doesn't use a file parser; it ingests via the hooks endpoint. - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { DatabaseSync } from "node:sqlite"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const FIXTURE = JSON.parse( - readFileSync( - join( - HERE, - "..", - "..", - "fixtures", - "parsers", - "claude", - "hook-sequence.json", - ), - "utf8", - ), -); - -let sidecar; -let cleanupDb; -let baseUrl; -let dbPath; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - dbPath = tmp.dbPath; - seedFixtureDb(dbPath); - sidecar = await launchSidecar({ dbPath }); - reseedPacksAndSkills(dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -async function postHook(hookEvent) { - const res = await fetch(`${baseUrl}/api/hooks/event`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(hookEvent), - }); - if (!res.ok) { - throw new Error( - `POST /api/hooks/event -> ${res.status} ${await res.text()}`, - ); - } - return res.json(); -} - -// Poll-with-timeout for cross-connection DB visibility. The sidecar: -// 1. Writes via its own DatabaseSync handle (separate from the test's). -// 2. As of FEA-1407 (merged from main), responds 200 to POST /api/hooks/event -// synchronously and enqueues the actual write for drainHookQueue() to -// process out-of-band. So the row may not be visible for 1-2 seconds -// after the HTTP response returns. -// Up to ~5s of polling absorbs both windows without masking real bugs (a real -// bug never returns a row, so the poll just times out and the test still -// fails with the same message). -async function pollSync(check, { timeoutMs = 5000, intervalMs = 50 } = {}) { - const deadline = Date.now() + timeoutMs; - for (;;) { - const v = check(); - if (v) return v; - if (Date.now() >= deadline) return null; - await new Promise((r) => setTimeout(r, intervalMs)); - } -} - -// Same shape, but for async check functions (e.g. fetching via API). -async function pollSyncAsync(check, { timeoutMs = 5000, intervalMs = 50 } = {}) { - const deadline = Date.now() + timeoutMs; - for (;;) { - const v = await check(); - if (v) return v; - if (Date.now() >= deadline) return null; - await new Promise((r) => setTimeout(r, intervalMs)); - } -} - -async function fetchSession() { - const r = await fetch( - `${baseUrl}/api/sessions/${encodeURIComponent(FIXTURE.session_id)}`, - ); - if (r.status === 404) return null; - if (!r.ok) throw new Error(`GET /api/sessions/${FIXTURE.session_id} -> ${r.status}`); - const body = await r.json(); - return body.session ?? body; -} - -test("Claude hooks · UserPromptSubmit creates a session", async () => { - const userPrompt = FIXTURE.events.find( - (e) => e.hook_type === "UserPromptSubmit", - ); - await postHook(userPrompt); - - // Verify via API. node:sqlite cross-connection visibility is unreliable - // for async hook writes — see FEA-1407: POST returns 200 immediately, - // drainHookQueue() does the actual write. The API uses the sidecar's - // own DatabaseSync handle so it sees the queued+drained state correctly. - const session = await pollSyncAsync(async () => await fetchSession()); - assert.ok(session, "session row should exist after UserPromptSubmit"); - assert.equal(session.cwd, "/Users/dev/repo"); - assert.equal(session.status, "active"); -}); - -test("Claude hooks · PreToolUse + PostToolUse produce events with tool_name", async () => { - const toolEvents = FIXTURE.events.filter( - (e) => e.hook_type === "PreToolUse" || e.hook_type === "PostToolUse", - ); - for (const e of toolEvents) { - await postHook(e); - } - - // Verify via /api/events?session_id=... — single read filtered to the - // fixture session, sees the sidecar's own writes after drainHookQueue. - const evts = await pollSyncAsync(async () => { - const r = await fetch( - `${baseUrl}/api/events?session_id=${encodeURIComponent(FIXTURE.session_id)}&limit=100`, - ); - if (!r.ok) return null; - const body = await r.json(); - const list = body.events ?? body; - return list.length >= 4 ? list : null; // 2 Pre + 2 Post - }); - assert.ok(evts, "expected ≥4 tool events to be visible after drain"); - - const preCount = evts.filter((e) => e.event_type === "PreToolUse").length; - const postCount = evts.filter((e) => e.event_type === "PostToolUse").length; - assert.equal(preCount, 2, "expected 2 PreToolUse events"); - assert.equal(postCount, 2, "expected 2 PostToolUse events"); - - const byTool = {}; - for (const e of evts) { - if (e.tool_name) byTool[e.tool_name] = (byTool[e.tool_name] ?? 0) + 1; - } - // Each tool: Pre + Post = 2 events (the FEA-1420 double-count manifests here) - assert.equal(byTool.Read, 2); - assert.equal(byTool.Edit, 2); -}); - -test("Claude hooks · Stop moves main agent to 'waiting' but leaves session 'active'", async () => { - // Stop ends the *turn*, not the session. The user can still send more - // messages; until they do, the main agent is "waiting" but the session - // remains "active" with awaiting_input_since stamped. - const stop = FIXTURE.events.find((e) => e.hook_type === "Stop"); - await postHook(stop); - - // Use the API. Wait until awaiting_input_since is stamped — that's proof - // the Stop hook has drained. - const drilled = await pollSyncAsync(async () => { - const r = await fetch( - `${baseUrl}/api/sessions/${encodeURIComponent(FIXTURE.session_id)}`, - ); - if (!r.ok) return null; - const body = await r.json(); - const session = body.session ?? body; - if (!session?.awaiting_input_since) return null; - return { session, agents: body.agents ?? [] }; - }); - assert.ok(drilled, "session should still exist after Stop, with awaiting_input_since stamped"); - assert.equal( - drilled.session.status, - "active", - "session.status stays 'active' after Stop (user can still send more)", - ); - - const mainAgent = drilled.agents.find((a) => a.type === "main"); - assert.ok(mainAgent, "main agent should exist after a turn"); - assert.equal( - mainAgent.status, - "waiting", - "main agent moves to 'waiting' after Stop", - ); -}); - -test("Claude hooks · full sequence produces a coherent session-level summary", async () => { - // After the full sequence, /api/sessions/:id/stats should reflect: - // - 4 events (2 PreToolUse + 2 PostToolUse — Stop doesn't insert event in - // the per-session count) — actually Stop also creates an event, so 5 - // - 1 main agent - // - tools_used has Read + Edit - const res = await fetch( - `${baseUrl}/api/sessions/${encodeURIComponent(FIXTURE.session_id)}/stats`, - ); - assert.equal(res.status, 200); - const stats = await res.json(); - assert.ok(stats.total_events >= 4, `expected ≥4 events; got ${stats.total_events}`); - assert.ok(stats.agents.main >= 1, "expected at least one main agent"); - const toolNames = (stats.tools_used || []).map((t) => t.tool_name); - assert.ok(toolNames.includes("Read"), "Read tool should appear in tools_used"); - assert.ok(toolNames.includes("Edit"), "Edit tool should appear in tools_used"); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/codex-parser.contract.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/codex-parser.contract.test.mjs deleted file mode 100644 index 33844941..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/codex-parser.contract.test.mjs +++ /dev/null @@ -1,106 +0,0 @@ -// Parser-to-normalized-session contract test for the Codex rollout parser. -// -// This is the Phase 1 vertical-slice for parser-layer auditing: take a -// hand-crafted raw Codex JSONL, run the SAME `parseRolloutFile` function the -// sidecar uses at runtime, and assert the normalized session object matches -// the fixture contents. -// -// Phase 1 day-2 will add: parsed session → `importSession()` → temp SQLite, -// then re-run the Dashboard manifest oracles against the parser-built DB -// instead of seed-fixture-db.mjs's pre-cooked rows. Disagreements at that -// point are parser↔DB bugs. - -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const require_ = createRequire(import.meta.url); -const HERE = dirname(fileURLToPath(import.meta.url)); - -// The Codex parser is CommonJS but apps/desktop has `"type": "module"` in -// package.json — so loading scripts/agent-monitor-codex/codex-parser.js -// directly fails (Node treats .js+type:module as ESM and the `require()` at -// the top of the parser file is undefined). -// -// The build pipeline copies the parser to `.generated/agent-monitor/server/lib/` -// where there is no surrounding type:module, and that's the path the sidecar -// loads at runtime. Test the same file the sidecar runs — both for correctness -// (identical bytes) and to dodge the ESM/CJS confusion. -const GENERATED_PARSER = join( - HERE, - "..", - "..", - "..", - "..", - ".generated", - "agent-monitor", - "server", - "lib", - "codex-parser.js", -); -const { parseRolloutFile } = require_(GENERATED_PARSER); - -const FIXTURE = join( - HERE, - "..", - "..", - "fixtures", - "parsers", - "codex", - "rollout-a1b2c3d4-1111-2222-3333-444455556666.jsonl", -); - -test("Codex parser · minimal rollout produces the expected normalized session", async () => { - const parsed = await parseRolloutFile(FIXTURE); - - // Identity / metadata - assert.equal(parsed.sessionId, "a1b2c3d4-1111-2222-3333-444455556666"); - assert.equal(parsed.cwd, "/tmp/fixture-codex-repo"); - assert.equal(parsed.model, "gpt-5"); - assert.equal(parsed.version, "0.27.0"); - assert.equal(parsed.gitBranch, "main"); - assert.equal(parsed.entrypoint, "codex"); - - // Timestamps — start should be the earliest, end the latest. - assert.equal(parsed.startedAt, "2026-05-20T10:00:00.000Z"); - assert.equal(parsed.endedAt, "2026-05-20T10:00:14.000Z"); - - // Message counts - assert.equal(parsed.userMessages, 1); - assert.equal(parsed.assistantMessages, 1); - assert.equal(parsed.thinkingBlockCount, 1); - - // Tool uses: one local_shell_call (normalized to name="shell") - assert.equal(parsed.toolUses.length, 1); - assert.equal(parsed.toolUses[0].name, "shell"); - - // Tokens: cumulative; the single token_count event sets the totals. - // Expected per model "gpt-5": - // input=150, output=40+15(reasoning)=55, cacheRead=0 - const gpt5 = parsed.tokensByModel?.["gpt-5"]; - assert.ok(gpt5, `tokensByModel.gpt-5 missing; got: ${JSON.stringify(parsed.tokensByModel)}`); - assert.equal(gpt5.input, 150); - assert.equal(gpt5.output, 55); - assert.equal(gpt5.cacheRead, 0); - - // No plans in this minimal fixture - assert.equal(parsed.plans.length, 0); - - // No API errors / tool-result errors - assert.equal(parsed.apiErrors.length, 0); - assert.equal(parsed.toolResultErrors.length, 0); -}); - -test("Codex parser · idempotent: parsing twice returns equal shape", async () => { - const a = await parseRolloutFile(FIXTURE); - const b = await parseRolloutFile(FIXTURE); - // Strip mtime-dependent field for the comparison — the file's mtime can - // shift on copy or rsync but the parsed content must be stable. - const norm = (o) => { - const { fileModifiedAt, ...rest } = o; - return rest; - }; - assert.deepEqual(norm(a), norm(b)); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/copilot-parser.contract.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/copilot-parser.contract.test.mjs deleted file mode 100644 index a41fc200..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/copilot-parser.contract.test.mjs +++ /dev/null @@ -1,77 +0,0 @@ -// Copilot parser contract test. Asserts the Copilot chat-session parser -// produces the expected normalized session shape from a minimal VS Code -// workspaceStorage-style JSON file. - -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); - -const parserModule = require( - join( - HERE, - "..", - "..", - "..", - "..", - ".generated", - "agent-monitor", - "server", - "lib", - "copilot-parser.js", - ), -); - -const FIXTURE = join( - HERE, - "..", - "..", - "fixtures", - "parsers", - "copilot", - "chat-session-fixture-copilot-bb22.json", -); - -test("Copilot parser · minimal chat session produces normalized session", () => { - const result = parserModule.parseChatSessionFile(FIXTURE, "/workspace"); - assert.ok(result, "parser returned null — should produce a session"); - - assert.equal(result.entrypoint, "copilot", "entrypoint must be 'copilot'"); - assert.equal(result.sessionId, "copilot-chat-fixture-copilot-bb22"); - - // 2 requests = 2 user messages + 2 assistant messages - assert.equal(result.userMessages, 2); - assert.equal(result.assistantMessages, 2); - - // 2 tool calls in the second request - assert.equal(result.toolUses.length, 2); - assert.equal(result.toolUses[0].name, "read_file"); - assert.equal(result.toolUses[1].name, "edit_file"); - - // Token totals: sum across both requests - // Default model bucket key — parser uses "copilot" or model from data; check via - // tokensByModel object as the source of truth. - const bucketKey = Object.keys(result.tokensByModel)[0]; - assert.ok(bucketKey, "tokensByModel must have at least one bucket"); - const tokens = result.tokensByModel[bucketKey]; - assert.equal(tokens.input, 600 + 800); // 1400 - assert.equal(tokens.output, 220 + 350); // 570 - assert.equal(tokens.cacheRead, 2000 + 4500); // 6500 - assert.equal(tokens.cacheWrite, 300 + 200); // 500 - - // Time bounds - assert.equal(result.startedAt, "2026-05-20T11:00:05.000Z"); - assert.equal(result.endedAt, "2026-05-20T11:00:25.000Z"); -}); - -test("Copilot parser · idempotent — re-parsing returns the same shape", () => { - const a = parserModule.parseChatSessionFile(FIXTURE, "/workspace"); - const b = parserModule.parseChatSessionFile(FIXTURE, "/workspace"); - delete a.fileModifiedAt; - delete b.fileModifiedAt; - assert.deepEqual(a, b); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/coverage-validator.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/coverage-validator.test.mjs deleted file mode 100644 index ff908e0f..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/coverage-validator.test.mjs +++ /dev/null @@ -1,215 +0,0 @@ -// Coverage validator. Asserts every scanner detection in -// manifest.scanned.json has been explicitly classified in coverage.json. -// -// This is the "tests for every element in the inventory" guarantee — fails -// the moment a new tile appears without an assigned status (tested / -// cross_ref / bug_filed / out_of_scope). - -import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { test } from "node:test"; -import assert from "node:assert/strict"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const INVENTORY = join(HERE, "..", "..", "inventory"); -const SPEC_DIR = join(HERE); - -const coverage = JSON.parse( - readFileSync(join(INVENTORY, "coverage.json"), "utf8"), -); -const scanned = JSON.parse( - readFileSync(join(INVENTORY, "manifest.scanned.json"), "utf8"), -); - -test("coverage.json accounts for every scanner detection", () => { - // Every detection in manifest.scanned.json must appear in coverage.json - // with a non-`needs_review` status. - assert.equal( - coverage.total_detections, - scanned.tiles.length, - `coverage.total_detections (${coverage.total_detections}) ≠ scanned.tiles.length (${scanned.tiles.length}) — re-run coverage-classifier`, - ); - - const VALID_STATUSES = new Set([ - "tested", - "cross_ref", - "cross_ref_weak", - "bug_filed", - "out_of_scope", - ]); - - const unclassified = coverage.rows.filter( - (r) => !VALID_STATUSES.has(r.status), - ); - assert.deepEqual( - unclassified, - [], - `${unclassified.length} detections have no coverage decision — add classifier rules in coverage-classifier.mjs:\n` + - unclassified - .slice(0, 10) - .map((r) => ` ${r.screen} ${r.file}:${r.line} \`${r.value_expr}\``) - .join("\n"), - ); -}); - -test("every cross_ref points at a test file that exists", () => { - const referenced = new Set( - coverage.rows - .filter((r) => r.status === "cross_ref" && r.covered_by) - .map((r) => r.covered_by), - ); - const missing = []; - for (const f of referenced) { - if (!existsSync(join(SPEC_DIR, f))) { - missing.push(f); - } - } - assert.deepEqual( - missing, - [], - `cross_ref references to non-existent test files:\n${missing.join("\n")}`, - ); -}); - -test("every cross_ref covered_by test file contains assertions", () => { - // Sanity check: the referenced file should at least contain `assert.` calls. - const referenced = new Set( - coverage.rows - .filter((r) => r.status === "cross_ref" && r.covered_by) - .map((r) => r.covered_by), - ); - const empty = []; - for (const f of referenced) { - const body = readFileSync(join(SPEC_DIR, f), "utf8"); - if (!body.includes("assert.")) { - empty.push(f); - } - } - assert.deepEqual( - empty, - [], - `cross_ref references to test files with no assertions:\n${empty.join("\n")}`, - ); -}); - -test("every out_of_scope status has a non-empty reason", () => { - const missingReason = coverage.rows.filter( - (r) => r.status === "out_of_scope" && (!r.reason || r.reason.length < 10), - ); - assert.deepEqual( - missingReason, - [], - `${missingReason.length} out_of_scope detections lack a documented reason`, - ); -}); - -test("coverage summary — counts add up", () => { - const sum = - coverage.by_status.tested + - coverage.by_status.cross_ref + - (coverage.by_status.cross_ref_weak ?? 0) + - coverage.by_status.bug_filed + - coverage.by_status.out_of_scope + - coverage.by_status.needs_review; - assert.equal( - sum, - coverage.total_detections, - `by_status counts (${sum}) ≠ total_detections (${coverage.total_detections})`, - ); - assert.equal( - coverage.by_status.needs_review, - 0, - `${coverage.by_status.needs_review} detections still need review`, - ); -}); - -test("cross_ref_weak count is informational (Phase 3 will tighten)", () => { - // cross_ref_weak = detection in a manifest-covered screen that didn't - // bind to a specific tile via value_expr substring match. Today these - // pass; Phase 3 of PLN-738 drives the count toward 0 via explicit - // tile-to-renderer annotations. This test exists so the count is - // visible in CI output, not to fail the build. - const weak = coverage.by_status.cross_ref_weak ?? 0; - console.log(` cross_ref_weak: ${weak} of ${coverage.total_detections}`); -}); - -// --------------------------------------------------------------------- -// Parametrized: one test per scanner detection. -// -// Every detection in manifest.scanned.json gets its own `test(...)` call -// here. The assertion is light — confirm the coverage row has a valid -// status, a reason or covered_by, and (for cross_ref) that the referenced -// test file actually contains content. This guarantees test count ≥ -// detection count: re-run the scanner, add new manifest/coverage rows, -// new tests appear automatically. -// --------------------------------------------------------------------- - -function detectionLabel(row) { - const expr = row.value_expr.slice(0, 40); - return `${row.screen}:${row.line} \`${expr}\``; -} - -for (const row of coverage.rows) { - test(`detection · ${detectionLabel(row)}`, () => { - assert.ok( - ["tested", "cross_ref", "cross_ref_weak", "bug_filed", "out_of_scope"].includes( - row.status, - ), - `detection has no valid status: ${row.status}`, - ); - - if (row.status === "cross_ref" || row.status === "cross_ref_weak") { - assert.ok( - row.covered_by, - `${row.status} must specify covered_by`, - ); - assert.ok( - existsSync(join(SPEC_DIR, row.covered_by)), - `covered_by points at missing file: ${row.covered_by}`, - ); - } - - if ( - row.status === "out_of_scope" || - row.status === "cross_ref" || - row.status === "cross_ref_weak" - ) { - assert.ok( - row.reason && row.reason.length > 5, - "status requires a reason", - ); - } - - if (row.status === "bug_filed") { - assert.ok( - row.bug_ref && /^FEA-\d+$/.test(row.bug_ref), - "bug_filed requires bug_ref (FEA-NNN)", - ); - } - }); -} - -test("scanner output is current (re-run scan-tiles if this fails)", () => { - // Compare counts in scanned vs all .tsx pages on disk. If new pages were - // added without rescanning, this surfaces it. - const upstreamDir = (() => { - const pnpmDir = join(HERE, "..", "..", "..", "..", "..", "node_modules", ".pnpm"); - let entries; - try { - entries = readdirSync(pnpmDir); - } catch { - return null; - } - const dir = entries.find((n) => n.startsWith("agent-dashboard-client@")); - if (!dir) return null; - return join(pnpmDir, dir, "node_modules", "agent-dashboard-client", "src", "pages"); - })(); - - if (!upstreamDir) { - // Upstream not resolved — skip. - return; - } - const screensInScan = new Set(scanned.tiles.map((t) => t.screen)); - assert.ok(screensInScan.size >= 10, `expected ≥10 screens in scan; got ${screensInScan.size}`); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/cursor-parser.contract.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/cursor-parser.contract.test.mjs deleted file mode 100644 index eae21669..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/cursor-parser.contract.test.mjs +++ /dev/null @@ -1,80 +0,0 @@ -// Cursor parser contract test. Asserts the parser reads a minimal -// Cursor JSONL transcript and produces the expected normalized session -// shape consumed by importSession(). - -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); - -// Cursor parser is CommonJS but apps/desktop has type:module — same dodge -// as codex-parser.contract.test.mjs: load the build-pipeline-copied version -// in .generated/agent-monitor/server/lib/ where there's no surrounding -// type:module. -const GENERATED_PARSER = join( - HERE, - "..", - "..", - "..", - "..", - ".generated", - "agent-monitor", - "server", - "lib", - "cursor-parser.js", -); -const parserModule = require(GENERATED_PARSER); -const FIXTURE = join( - HERE, - "..", - "..", - "fixtures", - "parsers", - "cursor", - "transcript-fixture-cursor-session-aa11.jsonl", -); - -test("Cursor parser · minimal transcript produces normalized session", async () => { - const result = await parserModule.parseTranscriptFile(FIXTURE); - assert.ok(result, "parser returned null — should produce a session"); - - assert.equal(result.entrypoint, "cursor", "entrypoint must be 'cursor'"); - assert.equal(result.cwd, "/Users/dev/repo"); - assert.equal(result.model, "claude-sonnet-4"); - assert.equal(result.gitBranch, "main"); - assert.equal(result.version, "0.42.0"); - - // 1 user message + 2 assistant messages in fixture - assert.equal(result.userMessages, 1); - assert.equal(result.assistantMessages, 2); - - // 2 tool calls in fixture - assert.equal(result.toolUses.length, 2); - assert.equal(result.toolUses[0].name, "read_file"); - assert.equal(result.toolUses[1].name, "edit_file"); - - // Token usage from the `usage` record - const tokens = result.tokensByModel["claude-sonnet-4"]; - assert.ok(tokens, "tokensByModel should have an entry for claude-sonnet-4"); - assert.equal(tokens.input, 1200); - assert.equal(tokens.output, 340); - assert.equal(tokens.cacheRead, 5000); - assert.equal(tokens.cacheWrite, 800); - - // Time bounds derived from first/last timestamp - assert.equal(result.startedAt, "2026-05-20T10:00:00.000Z"); - assert.equal(result.endedAt, "2026-05-20T10:00:25.000Z"); -}); - -test("Cursor parser · idempotent — re-parsing returns the same shape", async () => { - const a = await parserModule.parseTranscriptFile(FIXTURE); - const b = await parserModule.parseTranscriptFile(FIXTURE); - // Strip fileModifiedAt (non-deterministic) before comparing. - delete a.fileModifiedAt; - delete b.fileModifiedAt; - assert.deepEqual(a, b); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/dashboard.ui-audit.spec.ts b/apps/desktop/test-e2e/agent-monitor/specs/audit/dashboard.ui-audit.spec.ts deleted file mode 100644 index 3b4c3bcc..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/dashboard.ui-audit.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Layer-2 audit: every Dashboard Monitor tile in the manifest has its rendered -// text asserted against the formatted oracle value. Zero magic numbers — every -// expectation flows from the manifest + oracle (shared logic in -// helpers/audit-tile.ts). -// -// Contrast with the legacy dashboard-tiles.spec.ts which hardcodes 5, 6, 8. -// That file stays as a dumb baseline; this file is the manifest-driven audit. - -import { expect, test } from "@playwright/test"; - -import { - loadManifest, - tilesForScreen, -} from "../../inventory/manifest-loader.mjs"; -// @ts-expect-error — .ts helper imported by Playwright's ts loader -import { assertTileMatchesOracle, tileSkip } from "../../helpers/audit-tile"; - -const manifest = loadManifest(); -const dashboardTiles = tilesForScreen(manifest, "Dashboard", "Monitor"); - -test.describe("Dashboard tiles · UI audit (manifest-driven)", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/"); - // Wait until the Total Sessions tile transitions from "0" placeholder to a - // real value before any row assertion runs. We can't compare against the - // oracle here (page isn't bound to a row yet); just wait for a non-zero - // digit so the sidecar has populated. - await expect - .poll( - async () => { - const text = await page.locator("main").innerText(); - const m = text.match(/Total Sessions\s*\n+\s*(\S+)/i); - return m ? m[1] : null; - }, - { timeout: 10_000 }, - ) - .toMatch(/[1-9]/); - }); - - for (const row of dashboardTiles) { - // Uniform skip: bug_ref tiles (until fixed) and unbound tiles (no - // data-testid yet) skip; only selector-bound tiles are asserted. See - // tileSkip in helpers/audit-tile.ts. - const { skip, suffix } = tileSkip(row); - const testFn = skip ? test.skip : test; - testFn( - `UI audit · ${row.id} matches oracle "${row.oracle}"${suffix}`, - async ({ page }) => { - await assertTileMatchesOracle(page, row); - }, - ); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/opencode-parser.contract.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/opencode-parser.contract.test.mjs deleted file mode 100644 index ebd9264c..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/opencode-parser.contract.test.mjs +++ /dev/null @@ -1,78 +0,0 @@ -// OpenCode parser contract test. OpenCode persists its session data in a -// SQLite DB (session / message / part tables). We construct a minimal -// fixture DB at runtime, run the parser, and assert the normalized session. - -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { buildOpenCodeFixtureDb } from "../../fixtures/parsers/opencode/build-fixture-db.mjs"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); - -const parserModule = require( - join( - HERE, - "..", - "..", - "..", - "..", - ".generated", - "agent-monitor", - "server", - "lib", - "opencode-parser.js", - ), -); - -test("OpenCode parser · minimal DB produces normalized session", () => { - const tmp = mkdtempSync(join(tmpdir(), "opencode-fixture-")); - const dbPath = join(tmp, "opencode.db"); - try { - buildOpenCodeFixtureDb(dbPath); - const results = parserModule.loadSessionsFromDb(dbPath); - assert.equal(results.length, 1, "should produce one session"); - - const result = results[0]; - assert.equal(result.entrypoint, "opencode"); - assert.equal(result.sessionId, "opencode-cc33"); - assert.equal(result.cwd, "/Users/dev/repo"); - assert.equal(result.model, "claude-sonnet-4"); - assert.equal(result.version, "0.3.0"); - assert.equal(result.slug, "fixture-opencode-slug"); - - assert.equal(result.userMessages, 1); - assert.equal(result.assistantMessages, 1); - assert.equal(result.toolUses.length, 1); - assert.equal(result.toolUses[0].name, "read_file"); - - const tokens = result.tokensByModel["claude-sonnet-4"]; - assert.ok(tokens, "tokensByModel must have a claude-sonnet-4 bucket"); - assert.equal(tokens.input, 900); - // OpenCode adds tokens_output + tokens_reasoning into the .output bucket - assert.equal(tokens.output, 280 + 50); - assert.equal(tokens.cacheRead, 3000); - assert.equal(tokens.cacheWrite, 400); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); - -test("OpenCode parser · idempotent — same DB twice returns same shape", () => { - const tmp = mkdtempSync(join(tmpdir(), "opencode-fixture-")); - const dbPath = join(tmp, "opencode.db"); - try { - buildOpenCodeFixtureDb(dbPath); - const a = parserModule.loadSessionsFromDb(dbPath)[0]; - const b = parserModule.loadSessionsFromDb(dbPath)[0]; - delete a.fileModifiedAt; - delete b.fileModifiedAt; - assert.deepEqual(a, b); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/pack-detail.audit.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/pack-detail.audit.test.mjs deleted file mode 100644 index defd5e55..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/pack-detail.audit.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -// PackDetail per-pack drill-in audit. For every fixture pack, call -// /api/packs/:packId and verify installs/skills/associations array lengths -// match the DB counts. - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; -import { pack_detail_counts_by_id } from "../../inventory/oracles.mjs"; -import { compareNumeric, openDb } from "../../inventory/audit-runner.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; -let dbPath; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - dbPath = tmp.dbPath; - seedFixtureDb(dbPath); - sidecar = await launchSidecar({ dbPath }); - reseedPacksAndSkills(dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -async function fixturePackIds() { - const res = await fetch(`${baseUrl}/api/packs`); - const body = await res.json(); - const list = body.items || (Array.isArray(body) ? body : []); - return [ - ...new Set( - list - .map((p) => p.pack_id) - .filter((id) => String(id).startsWith("fixture-")), - ), - ]; -} - -test("/api/packs/:packId — per-pack installs/skills/associations match oracle", async () => { - const ids = await fixturePackIds(); - assert.ok(ids.length > 0, "expected at least one fixture pack"); - - const db = openDb(dbPath); - try { - const failures = []; - for (const pid of ids) { - const res = await fetch(`${baseUrl}/api/packs/${encodeURIComponent(pid)}`); - assert.equal( - res.status, - 200, - `GET /api/packs/${pid} returned ${res.status}`, - ); - const body = await res.json(); - const expected = pack_detail_counts_by_id(db, { packId: pid }); - - const checks = [ - ["installs.length", (body.installs || []).length, expected.installs], - ["skills.length", (body.skills || []).length, expected.skills], - // associations may be undefined when the table doesn't exist; tolerate - ...(expected.associations > 0 || Array.isArray(body.associations) - ? [["associations.length", (body.associations || []).length, expected.associations]] - : []), - ]; - - for (const [field, apiVal, oracleVal] of checks) { - const cmp = compareNumeric(Number(apiVal), Number(oracleVal)); - if (!cmp.ok) { - failures.push({ - packId: pid, - field, - apiVal, - oracleVal, - reason: cmp.reason, - }); - } - } - } - - assert.deepEqual( - failures, - [], - `PackDetail per-pack disagreements:\n` + - failures - .map( - (f) => - ` ${f.packId} ${f.field}: api=${f.apiVal} oracle=${f.oracleVal}`, - ) - .join("\n"), - ); - } finally { - db.close(); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/per-model-tokens.audit.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/per-model-tokens.audit.test.mjs deleted file mode 100644 index eaebf6e2..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/per-model-tokens.audit.test.mjs +++ /dev/null @@ -1,335 +0,0 @@ -// Per-model token-total audit: for each model in -// /api/workflows.modelDelegation.tokensByModel, assert every numeric column -// matches the DB sum for that model. -// -// High bug-finding probability: any drift in baseline_* handling, missing -// model bucketing, or stale aggregation cache would surface here. - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; -import { - tokens_by_model_map, - tool_counts_map, - tool_transitions_map, - subagent_effectiveness_map, - workflow_main_models_map, -} from "../../inventory/oracles.mjs"; -import { compareNumeric, openDb } from "../../inventory/audit-runner.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; -let dbPath; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - dbPath = tmp.dbPath; - seedFixtureDb(dbPath); - sidecar = await launchSidecar({ dbPath }); - reseedPacksAndSkills(dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -test("/api/workflows.modelDelegation.tokensByModel — per-model token sums match oracle", async () => { - const res = await fetch(`${baseUrl}/api/workflows`); - const body = await res.json(); - const apiList = body.modelDelegation?.tokensByModel || []; - assert.ok(Array.isArray(apiList), "expected tokensByModel to be an array"); - - const db = openDb(dbPath); - try { - const oracleMap = tokens_by_model_map(db); - const failures = []; - - // Every API model row should match the oracle. - for (const apiRow of apiList) { - const model = apiRow.model; - const oracle = oracleMap[model]; - if (!oracle) { - failures.push({ - model, - reason: `API returned model "${model}" but DB has no token_usage rows for it`, - }); - continue; - } - for (const field of [ - "input_tokens", - "output_tokens", - "cache_read_tokens", - "cache_write_tokens", - ]) { - const apiVal = Number(apiRow[field] ?? 0); - const oracleVal = Number(oracle[field] ?? 0); - const cmp = compareNumeric(apiVal, oracleVal); - if (!cmp.ok) { - failures.push({ - model, - field, - apiVal, - oracleVal, - reason: cmp.reason, - }); - } - } - } - - // Every oracle model should appear in the API. - const apiModels = new Set(apiList.map((r) => r.model)); - for (const model of Object.keys(oracleMap)) { - if (!apiModels.has(model)) { - failures.push({ - model, - reason: `DB has token_usage for model "${model}" but API omits it from tokensByModel`, - }); - } - } - - assert.deepEqual( - failures, - [], - `tokensByModel disagreements:\n` + - failures - .map((f) => - f.field - ? ` ${f.model}.${f.field}: api=${f.apiVal} oracle=${f.oracleVal}` - : ` ${f.model}: ${f.reason}`, - ) - .join("\n"), - ); - } finally { - db.close(); - } -}); - -test("/api/workflows.modelDelegation.mainModels — per-model main-agent counts match oracle", async () => { - const res = await fetch(`${baseUrl}/api/workflows`); - const body = await res.json(); - const apiList = body.modelDelegation?.mainModels || []; - - const db = openDb(dbPath); - try { - const oracleMap = workflow_main_models_map(db); - const failures = []; - - for (const apiRow of apiList) { - const model = apiRow.model; - const oracle = oracleMap[model]; - if (!oracle) { - failures.push({ - model, - reason: `API returned mainModels row for "${model}" but DB has no main agents in sessions of that model`, - }); - continue; - } - for (const field of ["agent_count", "session_count"]) { - const apiVal = Number(apiRow[field] ?? 0); - const oracleVal = Number(oracle[field] ?? 0); - const cmp = compareNumeric(apiVal, oracleVal); - if (!cmp.ok) { - failures.push({ - model, - field, - apiVal, - oracleVal, - reason: cmp.reason, - }); - } - } - } - - const apiModels = new Set(apiList.map((r) => r.model)); - for (const model of Object.keys(oracleMap)) { - if (!apiModels.has(model)) { - failures.push({ - model, - reason: `DB has main agents in sessions of model "${model}" but API omits it`, - }); - } - } - - assert.deepEqual( - failures, - [], - `mainModels disagreements:\n` + - failures - .map((f) => - f.field - ? ` ${f.model}.${f.field}: api=${f.apiVal} oracle=${f.oracleVal}` - : ` ${f.model}: ${f.reason}`, - ) - .join("\n"), - ); - } finally { - db.close(); - } -}); - -test("/api/workflows.effectiveness — per-subagent-type counts match oracle", async () => { - const res = await fetch(`${baseUrl}/api/workflows`); - const body = await res.json(); - const apiList = body.effectiveness || []; - - const db = openDb(dbPath); - try { - const oracleMap = subagent_effectiveness_map(db); - const failures = []; - - for (const apiRow of apiList) { - const type = apiRow.subagent_type; - const oracle = oracleMap[type]; - if (!oracle) { - failures.push({ - type, - reason: `API returned effectiveness row for "${type}" but DB has no subagents of that type`, - }); - continue; - } - for (const field of ["total", "completed", "errors", "sessions"]) { - const apiVal = Number(apiRow[field] ?? 0); - const oracleVal = Number(oracle[field] ?? 0); - const cmp = compareNumeric(apiVal, oracleVal); - if (!cmp.ok) { - failures.push({ type, field, apiVal, oracleVal, reason: cmp.reason }); - } - } - } - - const apiTypes = new Set(apiList.map((r) => r.subagent_type)); - for (const type of Object.keys(oracleMap)) { - if (!apiTypes.has(type)) { - failures.push({ - type, - reason: `DB has subagents of type "${type}" but API omits it from effectiveness`, - }); - } - } - - assert.deepEqual( - failures, - [], - `subagent effectiveness disagreements:\n` + - failures - .map((f) => - f.field - ? ` ${f.type}.${f.field}: api=${f.apiVal} oracle=${f.oracleVal}` - : ` ${f.type}: ${f.reason}`, - ) - .join("\n"), - ); - } finally { - db.close(); - } -}); - -test("/api/workflows.toolFlow.transitions — tool transitions match oracle (PreToolUse → PreToolUse)", { todo: "expected failure — FEA-1421 (Pre→Post pairs counted as fake transitions)" }, async () => { - const res = await fetch(`${baseUrl}/api/workflows`); - const body = await res.json(); - const apiList = body.toolFlow?.transitions || []; - - const db = openDb(dbPath); - try { - const oracleMap = tool_transitions_map(db); - const failures = []; - - for (const apiRow of apiList) { - const key = `${apiRow.source}||${apiRow.target}`; - const apiVal = Number(apiRow.value ?? 0); - const oracleVal = Number(oracleMap[key] ?? 0); - const cmp = compareNumeric(apiVal, oracleVal); - if (!cmp.ok) { - failures.push({ - transition: key, - apiVal, - oracleVal, - reason: cmp.reason, - }); - } - } - - // Self-loop check: PreToolUse(X) immediately followed by PostToolUse(X) - // would create a (X, X) self-loop in the broken upstream query. Real - // transitions almost never have X→X without an intermediate. - const selfLoops = apiList.filter( - (r) => r.source === r.target && Number(r.value) > 0, - ); - - assert.deepEqual( - failures, - [], - `tool transitions disagreements:\n` + - failures - .map((f) => ` ${f.transition}: api=${f.apiVal} oracle=${f.oracleVal}`) - .join("\n") + - (selfLoops.length - ? `\n\n Note: API also returned ${selfLoops.length} (X, X) self-loops, which are usually the symptom — PostToolUse(X) being treated as the "next tool" after PreToolUse(X). Self-loops: ${selfLoops.map((r) => r.source).join(", ")}` - : ""), - ); - } finally { - db.close(); - } -}); - -test("/api/workflows.toolFlow.toolCounts — per-tool counts match oracle", { todo: "expected failure — FEA-1420 (Pre+Post double-counted)" }, async () => { - const res = await fetch(`${baseUrl}/api/workflows`); - const body = await res.json(); - const apiList = body.toolFlow?.toolCounts || []; - - const db = openDb(dbPath); - try { - const oracleMap = tool_counts_map(db); - const failures = []; - - for (const apiRow of apiList) { - const tool = apiRow.tool_name; - const apiCount = Number(apiRow.count ?? 0); - const oracleCount = Number(oracleMap[tool] ?? 0); - const cmp = compareNumeric(apiCount, oracleCount); - if (!cmp.ok) { - failures.push({ - tool, - apiCount, - oracleCount, - reason: cmp.reason, - }); - } - } - - const apiTools = new Set(apiList.map((r) => r.tool_name)); - for (const tool of Object.keys(oracleMap)) { - if (!apiTools.has(tool)) { - failures.push({ - tool, - reason: `DB has PreToolUse events for "${tool}" but API omits it from toolCounts`, - }); - } - } - - assert.deepEqual( - failures, - [], - `toolCounts disagreements:\n` + - failures - .map((f) => - f.apiCount !== undefined - ? ` ${f.tool}: api=${f.apiCount} oracle=${f.oracleCount}` - : ` ${f.tool}: ${f.reason}`, - ) - .join("\n"), - ); - } finally { - db.close(); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/pricing-breakdown.audit.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/pricing-breakdown.audit.test.mjs deleted file mode 100644 index 39f2606e..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/pricing-breakdown.audit.test.mjs +++ /dev/null @@ -1,122 +0,0 @@ -// Per-model cost-breakdown audit + daily_costs probe. -// -// Likely surfaces: -// - FEA-1418 propagating per-model (every opus-4-7 row shows 3x understated cost) -// - FEA-1422 in daily_costs (string-comparison date bucketing) -// - New bugs: matched_rule mis-attribution, missing model in breakdown - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; -import { cost_breakdown_by_model_map } from "../../inventory/oracles.mjs"; -import { compareNumeric, openDb } from "../../inventory/audit-runner.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; -let dbPath; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - dbPath = tmp.dbPath; - seedFixtureDb(dbPath); - sidecar = await launchSidecar({ dbPath }); - reseedPacksAndSkills(dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -test("/api/pricing/cost.breakdown — per-model cost matches oracle (will surface FEA-1418 per model)", { todo: "expected failure — FEA-1418 propagates per-model" }, async () => { - const res = await fetch(`${baseUrl}/api/pricing/cost?tz_offset=0`); - const body = await res.json(); - const breakdown = body.breakdown || []; - - const db = openDb(dbPath); - try { - const oracleMap = cost_breakdown_by_model_map(db); - const failures = []; - - for (const apiRow of breakdown) { - const model = apiRow.model; - const apiCost = Number(apiRow.cost ?? 0); - const oracleCost = Number(oracleMap[model] ?? 0); - const cmp = compareNumeric(apiCost, oracleCost, { eps: 0.005 }); - if (!cmp.ok) { - failures.push({ - model, - matched_rule: apiRow.matched_rule, - apiCost, - oracleCost, - reason: cmp.reason, - }); - } - } - - assert.deepEqual( - failures, - [], - `pricing breakdown disagreements:\n` + - failures - .map( - (f) => - ` ${f.model} (matched_rule=${f.matched_rule}): api=$${f.apiCost.toFixed(6)} oracle=$${f.oracleCost.toFixed(6)}`, - ) - .join("\n"), - ); - } finally { - db.close(); - } -}); - -test("/api/pricing/cost.daily_costs — every daily entry is positive and dates are unique", async () => { - // Sanity-check on daily_costs structure. Real bucketing audit needs an - // event-on-today fixture (see FEA-1422). For now we just sanity-check - // shape and uniqueness. - const res = await fetch(`${baseUrl}/api/pricing/cost?tz_offset=0`); - const body = await res.json(); - const dailyCosts = body.daily_costs || []; - - // Every entry should have a date and a cost - for (const row of dailyCosts) { - assert.ok(row.date, "daily_costs row missing date"); - assert.ok(typeof row.cost === "number", "daily_costs row missing numeric cost"); - assert.ok(row.cost >= 0, `daily_costs negative cost for ${row.date}: ${row.cost}`); - } - - // Dates should be unique - const dates = dailyCosts.map((r) => r.date); - const uniqueDates = new Set(dates); - assert.equal( - uniqueDates.size, - dates.length, - `daily_costs has duplicate dates: ${dates.join(", ")}`, - ); -}); - -test("/api/pricing/cost.total_cost equals SUM of breakdown costs (internal consistency)", async () => { - // The total_cost scalar should equal the sum of breakdown[i].cost. If they - // disagree, one of the two is computed differently than the other. - const res = await fetch(`${baseUrl}/api/pricing/cost?tz_offset=0`); - const body = await res.json(); - const total = Number(body.total_cost ?? 0); - const sumOfBreakdown = (body.breakdown || []).reduce( - (s, r) => s + Number(r.cost ?? 0), - 0, - ); - const cmp = compareNumeric(total, sumOfBreakdown, { eps: 0.001 }); - assert.ok( - cmp.ok, - `total_cost (${total}) ≠ SUM(breakdown.cost) (${sumOfBreakdown.toFixed(6)}). One of them is computed differently — internal consistency violated.`, - ); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/pull-requests.ui-audit.spec.ts b/apps/desktop/test-e2e/agent-monitor/specs/audit/pull-requests.ui-audit.spec.ts deleted file mode 100644 index 927fa919..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/pull-requests.ui-audit.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Layer-2 audit: every PullRequests tile in the manifest has its rendered text -// asserted against the formatted oracle value. Same table-driven shape as -// dashboard.ui-audit.spec.ts — shared logic in helpers/audit-tile.ts. The three -// PR summary tiles render via a data-testid (FEA-1437 Phase 3), so they bind by -// selector. - -import { expect, test } from "@playwright/test"; - -import { - loadManifest, - tilesForScreen, -} from "../../inventory/manifest-loader.mjs"; -// @ts-expect-error — .ts helper imported by Playwright's ts loader -import { assertTileMatchesOracle, tileSkip } from "../../helpers/audit-tile"; - -const manifest = loadManifest(); -const prTiles = tilesForScreen(manifest, "PullRequests"); - -test.describe("PullRequests tiles · UI audit (manifest-driven)", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/pull-requests"); - // Wait until the summary stats load (the value cells show "—" until the - // fetch resolves). Poll the Pull Requests tile until it is a digit. - await expect - .poll( - async () => { - const t = await page - .locator("[data-testid='audit-pr-stats-pull-requests']") - .innerText() - .catch(() => null); - return t; - }, - { timeout: 10_000 }, - ) - .toMatch(/\d/); - }); - - for (const row of prTiles) { - const { skip, suffix } = tileSkip(row); - const testFn = skip ? test.skip : test; - testFn( - `UI audit · ${row.id} matches oracle "${row.oracle}"${suffix}`, - async ({ page }) => { - await assertTileMatchesOracle(page, row); - }, - ); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/session-detail.audit.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/session-detail.audit.test.mjs deleted file mode 100644 index af3ba96c..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/session-detail.audit.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -// SessionDetail per-session drill-in audit. For every fixture session, call -// /api/sessions/:id/stats and check every numeric field against an oracle -// computed from the DB scoped to that session_id. - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; -import { session_stats_by_id } from "../../inventory/oracles.mjs"; -import { compareNumeric, openDb } from "../../inventory/audit-runner.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; -let dbPath; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - dbPath = tmp.dbPath; - seedFixtureDb(dbPath); - sidecar = await launchSidecar({ dbPath }); - reseedPacksAndSkills(dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -async function fixtureSessionIds() { - const res = await fetch(`${baseUrl}/api/sessions?limit=50`); - const body = await res.json(); - const list = Array.isArray(body) ? body : body.sessions || body.items || []; - return list - .map((s) => s.id) - .filter((id) => String(id).startsWith("fixture-")); -} - -test("/api/sessions/:id/stats — per-session numeric fields match oracle", async () => { - const ids = await fixtureSessionIds(); - assert.ok(ids.length > 0, "expected at least one fixture session"); - - const db = openDb(dbPath); - try { - const failures = []; - for (const sid of ids) { - const res = await fetch(`${baseUrl}/api/sessions/${sid}/stats`); - assert.equal( - res.status, - 200, - `GET /api/sessions/${sid}/stats returned ${res.status}`, - ); - const body = await res.json(); - const expected = session_stats_by_id(db, { sessionId: sid }); - - const checks = [ - ["total_events", body.total_events, expected.total_events], - ["error_count", body.error_count, expected.error_count], - ["agents.total", body.agents?.total, expected.agents.total], - ["agents.main", body.agents?.main, expected.agents.main], - ["agents.subagent", body.agents?.subagent, expected.agents.subagent], - ["agents.compaction", body.agents?.compaction, expected.agents.compaction], - ["tokens.input_tokens", body.tokens?.input_tokens, expected.tokens.input_tokens], - ["tokens.output_tokens", body.tokens?.output_tokens, expected.tokens.output_tokens], - ["tokens.cache_read_tokens", body.tokens?.cache_read_tokens, expected.tokens.cache_read_tokens], - ["tokens.cache_write_tokens", body.tokens?.cache_write_tokens, expected.tokens.cache_write_tokens], - ]; - - for (const [field, apiVal, oracleVal] of checks) { - const cmp = compareNumeric(Number(apiVal ?? 0), Number(oracleVal ?? 0)); - if (!cmp.ok) { - failures.push({ - sessionId: sid, - field, - apiVal, - oracleVal, - reason: cmp.reason, - }); - } - } - } - - assert.deepEqual( - failures, - [], - `SessionDetail per-session disagreements (${failures.length} across ${ids.length} sessions):\n` + - failures - .map( - (f) => - ` ${f.sessionId} ${f.field}: api=${f.apiVal} oracle=${f.oracleVal}`, - ) - .join("\n"), - ); - } finally { - db.close(); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/sessions.per-row.audit.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/sessions.per-row.audit.test.mjs deleted file mode 100644 index 0665c99c..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/sessions.per-row.audit.test.mjs +++ /dev/null @@ -1,129 +0,0 @@ -// Per-row audit for /api/sessions — for every fixture session returned by -// the API, assert that the row's agent_count and cost match the oracles. -// -// This is the test pattern for table-style screens where each row is itself -// a data summary. Adding a new per-row field means adding a new oracle in -// oracles.mjs and a new test case here. - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; -import { - session_agent_count_by_id, - session_cost_by_id, -} from "../../inventory/oracles.mjs"; -import { compareNumeric, openDb } from "../../inventory/audit-runner.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; -let dbPath; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - dbPath = tmp.dbPath; - seedFixtureDb(dbPath); - sidecar = await launchSidecar({ dbPath }); - reseedPacksAndSkills(dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -async function fetchSessions() { - const res = await fetch(`${baseUrl}/api/sessions?limit=50`); - if (!res.ok) throw new Error(`GET /api/sessions -> ${res.status}`); - const body = await res.json(); - const list = Array.isArray(body) ? body : body.sessions || body.items; - return list.filter((s) => String(s.id).startsWith("fixture-")); -} - -test("Sessions list · per-row agent_count matches oracle", async () => { - const rows = await fetchSessions(); - assert.ok(rows.length > 0, "expected at least one fixture session"); - - const db = openDb(dbPath); - try { - const failures = []; - for (const r of rows) { - const expected = session_agent_count_by_id(db, { sessionId: r.id }); - const cmp = compareNumeric(Number(r.agent_count), expected); - if (!cmp.ok) { - failures.push({ - sessionId: r.id, - apiAgentCount: r.agent_count, - oracleAgentCount: expected, - reason: cmp.reason, - }); - } - } - assert.deepEqual( - failures, - [], - `Per-row agent_count disagreements:\n` + - failures - .map( - (f) => - ` ${f.sessionId}: api=${f.apiAgentCount} oracle=${f.oracleAgentCount} (${f.reason})`, - ) - .join("\n"), - ); - } finally { - db.close(); - } -}); - -test("Sessions list · per-row cost matches oracle", { todo: "expected failure — FEA-1418 (pricing matcher) propagates per-session" }, async () => { - const rows = await fetchSessions(); - assert.ok(rows.length > 0, "expected at least one fixture session"); - - const db = openDb(dbPath); - try { - const failures = []; - for (const r of rows) { - // The default sort doesn't include cost — the field is only computed when - // sortBy=price OR when the response builds the cost column. /api/sessions - // base returns cost as a property regardless (see route code). If the - // API omits cost entirely, treat as a fail with a useful message. - const expected = session_cost_by_id(db, { sessionId: r.id }); - if (r.cost === undefined) { - // Skip sessions where the API didn't return a cost field at all. - // Don't fail — it's a different (missing-field) concern, not a - // numerical mismatch. - continue; - } - const cmp = compareNumeric(Number(r.cost), expected, { eps: 0.005 }); - if (!cmp.ok) { - failures.push({ - sessionId: r.id, - apiCost: r.cost, - oracleCost: expected, - reason: cmp.reason, - }); - } - } - assert.deepEqual( - failures, - [], - `Per-row cost disagreements (likely the same pricing-matcher bug — FEA-1418):\n` + - failures - .map( - (f) => - ` ${f.sessionId}: api=$${f.apiCost} oracle=$${f.oracleCost.toFixed(6)}`, - ) - .join("\n"), - ); - } finally { - db.close(); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/sessions.ui-audit.spec.ts b/apps/desktop/test-e2e/agent-monitor/specs/audit/sessions.ui-audit.spec.ts deleted file mode 100644 index 1ad367ee..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/sessions.ui-audit.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Layer-2 audit: Sessions screen tiles. Currently one tile — the total-sessions -// count rendered in the list subtitle ("N sessions"). Same table-driven shape as -// the other screens; shared logic in helpers/audit-tile.ts. - -import { expect, test } from "@playwright/test"; - -import { - loadManifest, - tilesForScreen, -} from "../../inventory/manifest-loader.mjs"; -// @ts-expect-error — .ts helper imported by Playwright's ts loader -import { assertTileMatchesOracle, tileSkip } from "../../helpers/audit-tile"; - -const manifest = loadManifest(); -const sessionTiles = tilesForScreen(manifest, "Sessions"); - -test.describe("Sessions tiles · UI audit (manifest-driven)", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/sessions"); - // The subtitle renders "0 sessions" as its initial placeholder BEFORE - // api.sessions.list() resolves, so polling for any digit (/\d/) would match - // that "0" and let the assertion read a stale zero. The fixture seeds a - // non-zero session count, so wait for a non-zero leading digit ([1-9]) — - // same anti-race trick the Dashboard audit uses for "Total Sessions". - await expect - .poll( - async () => { - return page - .locator("[data-testid='audit-sessions-list-total']") - .innerText() - .catch(() => null); - }, - { timeout: 10_000 }, - ) - .toMatch(/[1-9]/); - }); - - for (const row of sessionTiles) { - const { skip, suffix } = tileSkip(row); - const testFn = skip ? test.skip : test; - testFn( - `UI audit · ${row.id} matches oracle "${row.oracle}"${suffix}`, - async ({ page }) => { - await assertTileMatchesOracle(page, row); - }, - ); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/skills.ui-audit.spec.ts b/apps/desktop/test-e2e/agent-monitor/specs/audit/skills.ui-audit.spec.ts deleted file mode 100644 index 13e2bcc9..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/skills.ui-audit.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Layer-2 audit: Skills screen. `skills.list.length` is a structural-assertion, -// dom_count tile — one button per skill across pack groups, so the rendered row -// count must equal the skills_total oracle (= sum of the per-group counts). - -import { expect, test } from "@playwright/test"; - -import { loadManifest } from "../../inventory/manifest-loader.mjs"; -// @ts-expect-error — .ts helper imported by Playwright's ts loader -import { assertTileMatchesOracle, tileSkip } from "../../helpers/audit-tile"; - -const manifest = loadManifest(); -const skillTiles = manifest.structural.filter( - (r: { screen: string }) => r.screen === "Skills", -); - -test.describe("Skills tiles · UI audit (manifest-driven)", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/skills"); - await expect - .poll( - async () => page.locator("[data-testid='audit-skill-row']").count(), - { timeout: 10_000 }, - ) - .toBeGreaterThan(0); - }); - - for (const row of skillTiles) { - const { skip, suffix } = tileSkip(row); - const testFn = skip ? test.skip : test; - testFn( - `UI audit · ${row.id} matches oracle "${row.oracle}"${suffix}`, - async ({ page }) => { - await assertTileMatchesOracle(page, row); - }, - ); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/timezone-bucketing.audit.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/audit/timezone-bucketing.audit.test.mjs deleted file mode 100644 index 5577316b..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/timezone-bucketing.audit.test.mjs +++ /dev/null @@ -1,161 +0,0 @@ -// Timezone-bucketing probe. The default fixture has no events dated "today" -// (all are May 15-21; today is May 27+). That makes the events_today=0 audit -// trivially correct. This test SHOULD reveal bugs in the bucketing logic by -// injecting events with controlled timestamps and verifying counts under -// different tz_offsets. - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; -import { DatabaseSync } from "node:sqlite"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; -let dbPath; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - dbPath = tmp.dbPath; - seedFixtureDb(dbPath); - sidecar = await launchSidecar({ dbPath }); - reseedPacksAndSkills(dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -function insertEvent(dbPath, createdAtIso) { - const db = new DatabaseSync(dbPath); - try { - // Need a session_id — pick one from the fixture. - const session = db - .prepare(`SELECT id FROM sessions WHERE id LIKE 'fixture-%' LIMIT 1`) - .get(); - if (!session) throw new Error("no fixture session to attach the event to"); - db.prepare( - `INSERT INTO events (session_id, event_type, tool_name, created_at) - VALUES (?, 'PreToolUse', 'TzProbe', ?)`, - ).run(session.id, createdAtIso); - } finally { - db.close(); - } -} - -function deleteEvent(dbPath) { - const db = new DatabaseSync(dbPath); - try { - db.prepare(`DELETE FROM events WHERE tool_name = 'TzProbe'`).run(); - } finally { - db.close(); - } -} - -async function statsEventsToday(tzOffset) { - const res = await fetch(`${baseUrl}/api/stats?tz_offset=${tzOffset}`); - const body = await res.json(); - return Number(body.events_today ?? 0); -} - -test("events_today bucketing — event at UTC noon counts as today when tz_offset=0", async () => { - const baseline = await statsEventsToday(0); - // Use today at UTC noon — definitely within "today" in any tz that's - // within ±12h of UTC. - const today = new Date(); - const todayNoonUtc = new Date( - Date.UTC( - today.getUTCFullYear(), - today.getUTCMonth(), - today.getUTCDate(), - 12, - 0, - 0, - ), - ); - insertEvent(dbPath, todayNoonUtc.toISOString()); - - try { - const after = await statsEventsToday(0); - assert.equal( - after, - baseline + 1, - `Inserting an event at UTC noon today should bump events_today by 1 ` + - `when querying with tz_offset=0. Got baseline=${baseline}, after=${after}.`, - ); - } finally { - deleteEvent(dbPath); - } -}); - -test("events_today bucketing — event 1 minute before UTC midnight does NOT count as today when tz_offset=0", async () => { - // Edge case: an event at 23:59 UTC "yesterday" should NOT count as today. - const baseline = await statsEventsToday(0); - const today = new Date(); - const justBeforeMidnight = new Date( - Date.UTC( - today.getUTCFullYear(), - today.getUTCMonth(), - today.getUTCDate(), - 0, - -1, - 0, - ), - ); - insertEvent(dbPath, justBeforeMidnight.toISOString()); - - try { - const after = await statsEventsToday(0); - assert.equal( - after, - baseline, - `Inserting an event 1 minute BEFORE UTC midnight (i.e. yesterday in UTC) ` + - `should NOT bump events_today when tz_offset=0. Got baseline=${baseline}, after=${after}.`, - ); - } finally { - deleteEvent(dbPath); - } -}); - -test("events_today bucketing — same event counts differently under different tz_offset", { todo: "expected failure — FEA-1422 (datetime() string-comparison short-circuit)" }, async () => { - // Insert an event at UTC 06:00 today. In UTC, this is "today". In PDT - // (tz_offset=420, UTC-7), this is 23:00 of YESTERDAY's local date. So: - // tz_offset=0 → counts as today - // tz_offset=420 → does NOT count as today (it's yesterday in PDT) - const today = new Date(); - const at0600Utc = new Date( - Date.UTC( - today.getUTCFullYear(), - today.getUTCMonth(), - today.getUTCDate(), - 6, - 0, - 0, - ), - ); - insertEvent(dbPath, at0600Utc.toISOString()); - - try { - const utcCount = await statsEventsToday(0); - const pdtCount = await statsEventsToday(420); - // We can't assert exact deltas without a baseline pair, but we can assert - // utcCount > pdtCount: the same event is in today's UTC bucket but in - // yesterday's PDT bucket. - assert.ok( - utcCount > pdtCount, - `Event at UTC 06:00 should be in today's UTC bucket but yesterday's PDT bucket. ` + - `Got utc=${utcCount}, pdt=${pdtCount}. If equal, the bucketing logic isn't honoring tz_offset.`, - ); - } finally { - deleteEvent(dbPath); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/tools.ui-audit.spec.ts b/apps/desktop/test-e2e/agent-monitor/specs/audit/tools.ui-audit.spec.ts deleted file mode 100644 index 81539b71..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/tools.ui-audit.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Layer-2 audit: Tools screen. `tools.list.length` is a dom_count tile — one -// button per distinct tool from /api/events/facets, so the rendered row count -// must equal the events_facets_tool_names_length oracle. `tools.event_types.length` -// has no countable rendered element yet (event_type only shows per-event in the -// detail panel) so it is skipped until bound. Shared logic in audit-tile.ts. - -import { expect, test } from "@playwright/test"; - -import { - loadManifest, - tilesForScreen, -} from "../../inventory/manifest-loader.mjs"; -// @ts-expect-error — .ts helper imported by Playwright's ts loader -import { assertTileMatchesOracle, tileSkip } from "../../helpers/audit-tile"; - -const manifest = loadManifest(); -const toolTiles = tilesForScreen(manifest, "Tools"); - -test.describe("Tools tiles · UI audit (manifest-driven)", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/tools"); - await expect - .poll( - async () => page.locator("[data-testid='audit-tool-row']").count(), - { timeout: 10_000 }, - ) - .toBeGreaterThan(0); - }); - - for (const row of toolTiles) { - const { skip, suffix } = tileSkip(row); - const testFn = skip ? test.skip : test; - testFn( - `UI audit · ${row.id} matches oracle "${row.oracle}"${suffix}`, - async ({ page }) => { - await assertTileMatchesOracle(page, row); - }, - ); - } -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/ui/dashboard-tiles.spec.ts b/apps/desktop/test-e2e/agent-monitor/specs/ui/dashboard-tiles.spec.ts deleted file mode 100644 index 01cc79ac..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/ui/dashboard-tiles.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Layer-2 UI spec: Dashboard tiles (the home page). Asserts the big-number -// tiles match the fixture counts the API contract test already proved. - -import { expect, test } from "@playwright/test"; - -test.describe("Dashboard tiles", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/"); - // Dashboard renders zero-placeholders while /api/stats is in flight. - // Wait until the Total Sessions tile updates from "0" to the fixture - // count (5) so the per-test slice grabs real data, not the loading state. - await expect - .poll(async () => { - const text = await page.locator("main").innerText(); - const m = text.match(/Total Sessions\s*\n+\s*(\d+)/i); - return m ? parseInt(m[1], 10) : -1; - }, { timeout: 10_000 }) - .toBe(5); - }); - - // Tile labels render in caps via CSS but `innerText` reflects the - // CSS-transformed form (`TOTAL SESSIONS`), so we match case-insensitively - // and slice forward to capture the big number that follows. - async function tileText(page: import("@playwright/test").Page, label: string) { - const text = await page.locator("main").innerText(); - const idx = text.toLowerCase().indexOf(label.toLowerCase()); - expect(idx, `${label} must appear in main`).toBeGreaterThan(-1); - return text.slice(idx, idx + 200); - } - - test("Total Sessions tile shows fixture count (5) with 2 active", async ({ - page, - }) => { - const slice = await tileText(page, "Total Sessions"); - expect(slice).toMatch(/5\b/); - expect(slice.toLowerCase()).toMatch(/2\s+active/); - }); - - test("Total Agents tile shows fixture count (6) with 2 active", async ({ - page, - }) => { - const slice = await tileText(page, "Total Agents"); - expect(slice).toMatch(/6\b/); - expect(slice.toLowerCase()).toMatch(/2\s+active/); - }); - - test("Total Events tile shows fixture event count (8)", async ({ - page, - }) => { - const slice = await tileText(page, "Total Events"); - expect(slice).toMatch(/\b8\b/); - }); - - test("Active Agents section lists the working fixture sessions by name", async ({ - page, - }) => { - await expect(page.getByText(/Active Agents/i)).toBeVisible(); - // The active-agent cards on the dashboard show the agent's `name` field - // (e.g. "Main Agent — Fixture Active 1"). Assert both are visible. - await expect( - page.getByText("Main Agent — Fixture Active 1"), - ).toBeVisible(); - await expect( - page.getByText("Main Agent — Fixture Active 2"), - ).toBeVisible(); - }); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/ui/packs.spec.ts b/apps/desktop/test-e2e/agent-monitor/specs/ui/packs.spec.ts deleted file mode 100644 index c219955b..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/ui/packs.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Layer-2 UI spec: Packs page — verifies the rendered cards reflect the -// fixture (3 installed packs, with the right skill counts and multi-harness -// display for beta). - -import { expect, test } from "@playwright/test"; - -test.describe("Packs page", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/packs"); - // The list does a network fetch; wait for the catalog-or-installed text. - await expect(page.getByText(/Curated agent skill packs/i)).toBeVisible(); - }); - - test("renders all three installed fixture pack cards", async ({ page }) => { - // Match by github-url since display names go through pack_catalog. - await expect(page.getByText(/fixture-pack-alpha/).first()).toBeVisible(); - await expect(page.getByText(/fixture-pack-beta/).first()).toBeVisible(); - await expect(page.getByText(/fixture-pack-gamma/).first()).toBeVisible(); - }); - - test("alpha card shows '2 skills' (fixture has alpha-skill-one + alpha-skill-two)", async ({ - page, - }) => { - const alphaCard = page - .locator("div,article,section") - .filter({ hasText: /fixture-pack-alpha/ }) - .first(); - await expect(alphaCard).toContainText(/2\s+skills/i); - }); - - test("beta card shows '1 skill' and lists both claude & codex harnesses", async ({ - page, - }) => { - const betaCard = page - .locator("div,article,section") - .filter({ hasText: /fixture-pack-beta/ }) - .first(); - await expect(betaCard).toContainText(/1\s+skill/i); - // The card surfaces installed harnesses somewhere — either "claude, codex" - // or two separate badges. - await expect(betaCard).toContainText(/claude/i); - await expect(betaCard).toContainText(/codex/i); - }); - - test("gamma card shows zero skills (fixture pack has none)", async ({ - page, - }) => { - // The card layout renders "Installed (claude)" immediately followed by - // "GitHub →" when the pack has zero skills (no "· N skills" chip). For - // alpha/beta the chip appears between those two strings, so we target - // the gamma card by scoping a narrow container around its display name. - const gammaCard = page.locator(":scope", { - hasText: "fixture-pack-gamma", - }); - // Get the smallest matching ancestor that holds the full card by reading - // the page text and slicing between "fixture-pack-gamma" and the next pack - // heading. Avoids brittle CSS selectors tied to the dashboard's internal - // class names. - const bodyText = await page.locator("main").innerText(); - const idx = bodyText.indexOf("fixture-pack-gamma"); - expect(idx, "fixture-pack-gamma must be on the page").toBeGreaterThan(-1); - const slice = bodyText.slice(idx, idx + 400).toLowerCase(); - expect(slice).not.toMatch(/[1-9]\d*\s+skill/); - // Sanity: gamma's description is present in the same slice. - expect(slice).toContain("pack with no skills"); - // Mark the unused locator as referenced so eslint doesn't complain - // (we keep it documented as the original intent). - void gammaCard; - }); - - test("delta catalog-only pack is shown as not-installed", async ({ - page, - }) => { - // fixture-pack-delta-uninstalled exists in pack_catalog but not in - // agent_packs, so the UI's "available" section should include it. - await expect( - page.getByText(/fixture-pack-delta-uninstalled/).first(), - ).toBeVisible(); - }); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/ui/pull-requests.spec.ts b/apps/desktop/test-e2e/agent-monitor/specs/ui/pull-requests.spec.ts deleted file mode 100644 index e5b1d43f..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/ui/pull-requests.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Layer-2 UI spec: Pull Requests page — fixtures have 3 PRs across 2 repos -// and 2 harnesses. - -import { expect, test } from "@playwright/test"; - -test.describe("Pull Requests page", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/pull-requests"); - await expect(page.getByText(/Pull Requests/i).first()).toBeVisible(); - }); - - test("header counters reflect 3 PRs / 2 sessions / 2 repos", async ({ - page, - }) => { - // The page renders three big-number tiles labeled Pull Requests, - // Sessions w/ PRs, Repositories. We assert the rendered values. - const text = await page.locator("body").innerText(); - expect(text).toMatch(/\b3\b[\s\S]{0,60}Pull Requests/i); - // Sessions with PRs = 2 (both PRs from sess-completed-1 collapse to one - // session; sess-completed-2 contributes the third) — that's 2 distinct. - expect(text).toMatch(/\b2\b[\s\S]{0,60}Sessions w\/ PRs/i); - expect(text).toMatch(/\b2\b[\s\S]{0,60}Repositories/i); - }); - - test("renders each fixture PR with title, branch, and repo (By PR view)", async ({ - page, - }) => { - // PR titles and branch names are surfaced in the "By PR" view; the - // default "By session" view only shows PR numbers. The toggle is a - // button labeled "By PR" near the top. - await page.getByRole("button", { name: "By PR", exact: true }).click(); - - await expect( - page.getByText("Fix auth bug from fixture session 1"), - ).toBeVisible(); - await expect(page.getByText("Add landing page")).toBeVisible(); - await expect( - page.getByText("Lint cleanup from codex session"), - ).toBeVisible(); - - await expect(page.getByText("fix/auth-bug")).toBeVisible(); - await expect(page.getByText("feat/landing-page")).toBeVisible(); - await expect(page.getByText("chore/lint")).toBeVisible(); - }); - - test("shows both Claude and Codex harnesses among PR rows", async ({ - page, - }) => { - // The codex PR is the lint cleanup; the claude PRs are the two from - // fixture-sess-completed-1. - const text = await page.locator("body").innerText(); - expect(text.toLowerCase()).toContain("claude"); - expect(text.toLowerCase()).toContain("codex"); - }); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/ui/sessions.spec.ts b/apps/desktop/test-e2e/agent-monitor/specs/ui/sessions.spec.ts deleted file mode 100644 index 6cda5c9a..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/ui/sessions.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Layer-2 UI spec: drives the live Sessions page against the fixture-loaded -// sidecar and asserts the rendered text matches the fixture data. - -import { expect, test } from "@playwright/test"; - -const FIXTURE_SESSIONS = [ - "fixture-sess-active-1", - "fixture-sess-active-2", - "fixture-sess-completed-1", - "fixture-sess-completed-2", - "fixture-sess-error-1", -]; - -test.describe("Sessions page", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/sessions"); - // Page header lands quickly, but the list is data-driven — wait on the - // count text the route renders next to the page title. - await expect(page.getByText(/session recorded/i)).toBeVisible(); - }); - - test("page header reports the fixture session count", async ({ page }) => { - // The fixture has 5 sessions. The header reads " session recorded". - // We allow trailing whitespace / punctuation variance. - await expect(page.getByText(/5\s+session/i)).toBeVisible(); - }); - - test("renders every fixture session by name in the table", async ({ - page, - }) => { - // The table renders the session NAME (e.g. "Fixture Active Session 1"); - // the id column is truncated to 12 chars ("fixture-sess") so identity - // assertions should target the name field instead. - await expect(page.getByText("Fixture Active Session 1")).toBeVisible(); - await expect(page.getByText("Fixture Active Session 2")).toBeVisible(); - await expect(page.getByText("Fixture Completed Session 1")).toBeVisible(); - await expect(page.getByText("Fixture Completed Session 2")).toBeVisible(); - await expect(page.getByText("Fixture Error Session")).toBeVisible(); - }); - - test("the two active fixture sessions render an Active status cell", async ({ - page, - }) => { - const activeRows = page - .getByRole("row") - .filter({ hasText: /Fixture Active Session/ }); - await expect(activeRows).toHaveCount(2); - for (const row of await activeRows.all()) { - await expect(row.getByText("Active", { exact: true })).toBeVisible(); - } - }); - - test("the error fixture session row carries the Error status", async ({ - page, - }) => { - const errorRow = page - .getByRole("row") - .filter({ hasText: "Fixture Error Session" }); - await expect(errorRow).toHaveCount(1); - await expect(errorRow.getByText("Error", { exact: true })).toBeVisible(); - }); - - test("a Claude harness filter is offered (fixtures include claude + codex)", async ({ - page, - }) => { - // The harness filter strip is part of the toolbar above the table. - await expect(page.getByText(/Claude/i).first()).toBeVisible(); - await expect(page.getByText(/Codex/i).first()).toBeVisible(); - }); -}); diff --git a/apps/desktop/test/agent-dashboard-boundary.test.ts b/apps/desktop/test/agent-dashboard-boundary.test.ts index f83f6046..9d63a6f9 100644 --- a/apps/desktop/test/agent-dashboard-boundary.test.ts +++ b/apps/desktop/test/agent-dashboard-boundary.test.ts @@ -199,7 +199,7 @@ function toImportSpecifier(fromDir: string, targetJsPath: string): string { return specifier; } -test("design-system dashboard side effects stay behind explicit mode gates", () => { +test("PGlite dashboard side effects stay behind the Agent Dashboard runtime boundary", () => { const appSource = readSource("src/main/app.ts"); const indexSource = readSource("src/main/index.ts"); const preloadSource = readSource("src/main/preload.ts"); @@ -214,34 +214,60 @@ test("design-system dashboard side effects stay behind explicit mode gates", () ); assert.match( indexSource, - /if \(shouldRegisterDesignSystemScheme\(\)\) \{\s*protocol\.registerSchemesAsPrivileged/s, + /protocol\.registerSchemesAsPrivileged\(\[/, ); + assert.doesNotMatch(indexSource, /shouldRegisterDesignSystemScheme/); assert.doesNotMatch(preloadSource, /desktop:db:/); assert.doesNotMatch(preloadCommonSource, /desktop:db:/); assert.match(preloadSource, /exposeDesktopApi\(\)/); assert.match(preloadDesignSystemSource, /desktop:db:get-sessions/); assert.match(preloadDesignSystemSource, /desktop:db:changed/); - assert.match( - windowSource, - /agentDashboardMode === "design-system"[\s\S]*--closedloop-agent-dashboard-design-system/, + assert.match(preloadDesignSystemSource, /desktop:db:get-core-features/); + assert.match(preloadDesignSystemSource, /desktop:db:get-pull-requests/); + assert.match(designSystemRuntimeSource(), /"agent-dashboard-ingest"/); + assert.doesNotMatch( + designSystemRuntimeSource(), + /stateDir:[\s\S]*"agent-monitor"/, ); - assert.match(windowSource, /preload-design-system\.js/); - assert.match(windowSource, /preload\.js/); assert.match( windowSource, - /agentDashboardMode !== "design-system"[\s\S]*const rendererPath = resolveLegacyRendererPath\(\)[\s\S]*loadFile\(rendererPath\)/, + /--closedloop-agent-dashboard-design-system/, ); + assert.match(windowSource, /preload-design-system\.js/); + assert.match(windowSource, /DESIGN_RENDERER_URL/); + assert.match(windowSource, /loadURL\(DESIGN_RENDERER_URL\)/); + assert.doesNotMatch(windowSource, /agentDashboardMode/); + assert.doesNotMatch(windowSource, /resolveLegacyRendererPath/); + assert.doesNotMatch(windowSource, /loadFile\(rendererPath\)/); assert.match(designSystemRuntimeSource(), /ipcMain\.removeHandler\(channel\)/); + const designSystemSource = designSystemRuntimeSource(); + const handlerRegistrationIndex = designSystemSource.indexOf( + "registerIpcHandlers();", + ); + const databaseReadyIndex = designSystemSource.indexOf( + "const agentDatabase = await agentDatabasePromise;", + ); + assert.ok(handlerRegistrationIndex >= 0); + assert.ok(databaseReadyIndex >= 0); + assert.ok( + handlerRegistrationIndex < databaseReadyIndex, + "design-system DB IPC handlers must be registered before awaiting PGlite startup", + ); + assert.doesNotMatch( + designSystemSource, + /SELECT DISTINCT cwd[\s\S]*ORDER BY started_at/, + "recent-projects query must stay valid for Postgres/PGlite", + ); assert.match( - appSource, - /stopAgentCapture\(\{ closeDesignSystem: true \}\)/, + designSystemSource, + /GROUP BY cwd[\s\S]*ORDER BY MAX\(started_at\) DESC NULLS LAST/, ); assert.match( appSource, - /reloadForAgentDashboardMode\("disabled"\)/, + /stopAgentCapture\(\{ closeDesignSystem: true \}\)/, ); - assert.match(windowSource, /unhandle\?\: \(scheme: string\) => void/); - assert.match(windowSource, /protocolWithUnhandle\.unhandle\(APP_PROTOCOL\)/); + assert.doesNotMatch(appSource, /AgentMonitorSidecar/); + assert.doesNotMatch(appSource, /reloadForAgentDashboardMode/); }); function designSystemRuntimeSource(): string { diff --git a/apps/desktop/test/agent-monitor-catchup-cache.test.ts b/apps/desktop/test/agent-monitor-catchup-cache.test.ts deleted file mode 100644 index 4cfdd08a..00000000 --- a/apps/desktop/test/agent-monitor-catchup-cache.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -// Regression coverage for FEA-1316: the agent-monitor 5 s catchup poll must -// stay cheap once steady state is reached. Without the per-file (mtime, size) -// cache in scripts/agent-monitor-shared/catchup-cache.js, each tick reparses -// every rollout file, which on a dev with 1000+ historical sessions pinned -// CPU at ~98 % and grew RSS until the sidecar OOMed. -// -// Tunable soak (kept small by default so CI stays fast): -// -// CLOSEDLOOP_AGENT_MONITOR_SOAK_SESSIONS Synthetic sessions to populate. -// Default: 300. -// CLOSEDLOOP_AGENT_MONITOR_SOAK_TICKS `importAllCodexSessions()` calls -// the "steady-state" assertion is -// averaged over. Default: 2. -// -// To run a longer soak locally (e.g. when investigating a suspected -// regression): -// -// CLOSEDLOOP_AGENT_MONITOR_SOAK_SESSIONS=5000 \ -// CLOSEDLOOP_AGENT_MONITOR_SOAK_TICKS=60 \ -// pnpm -C apps/desktop test -- --test-name-pattern=FEA-1316 -// -// Env-var-tunable test parameters follow the same convention as -// `CLOSEDLOOP_TAILER_POLL_MS` / `CLOSEDLOOP_WATCHER_POLL_MS` used by -// test/boot-recovery.test.ts. - -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - mkdirSync, - mkdtempSync, - writeFileSync, - appendFileSync, - unlinkSync, - rmSync, - existsSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join, dirname } from "node:path"; -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const desktopRoot = join(__dirname, ".."); -const generatedDb = join(desktopRoot, ".generated", "agent-monitor", "server", "db.js"); - -const skipReason = - !existsSync(generatedDb) || !existsSync(join(dirname(generatedDb), "lib", "codex-import.js")) - ? "generated agent-monitor runtime not built — run pnpm build:agent-monitor" - : null; - -const N_SESSIONS = Number( - process.env.CLOSEDLOOP_AGENT_MONITOR_SOAK_SESSIONS ?? "300", -); -const N_TICKS = Math.max( - 2, - Number(process.env.CLOSEDLOOP_AGENT_MONITOR_SOAK_TICKS ?? "2"), -); - -type ImportResult = { imported: number; skipped: number; errors: number }; -type CodexImport = { - importAllCodexSessions: (db: unknown) => Promise; -}; - -const ts = "2026-05-20T08:00:00.000Z"; -const BODY = "synthetic assistant reply ".repeat(40); -function rollout(uuid: string, extraTurn = ""): string { - const lines = [ - JSON.stringify({ type: "session_meta", timestamp: ts, payload: { id: uuid, session_id: uuid, cwd: "/tmp", instructions: "x" } }), - JSON.stringify({ type: "turn_context", timestamp: ts, payload: { model: "gpt-5-codex" } }), - JSON.stringify({ type: "response_item", timestamp: ts, payload: { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] } }), - JSON.stringify({ type: "response_item", timestamp: ts, payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: BODY }] } }), - ]; - if (extraTurn) { - lines.push( - JSON.stringify({ type: "response_item", timestamp: ts, payload: { type: "message", role: "user", content: [{ type: "input_text", text: extraTurn }] } }), - ); - } - return lines.join("\n") + "\n"; -} - -/** - * Per-test sandbox: a fresh tmp CODEX_HOME, a fresh dashboard.db, and a - * fresh require of the importer module so its in-memory `catchupCache` is - * empty. Returns the importer + the live tmp paths so each test can mutate - * its sandbox. - */ -function makeSandbox(): { - importAll: CodexImport["importAllCodexSessions"]; - root: string; - codexSessions: string; - pathFor: (i: number) => string; -} { - const root = mkdtempSync(join(tmpdir(), "fea1316-test-")); - const codexHome = join(root, "codex"); - const codexSessions = join(codexHome, "sessions", "2026", "05", "20"); - mkdirSync(codexSessions, { recursive: true }); - process.env.CODEX_HOME = codexHome; - process.env.DASHBOARD_DB_PATH = join(root, "dashboard.db"); - // FEA-1407 sandbox scoping: importSession skips any session whose cwd falls - // outside SANDBOX_BASE_DIRECTORY (fail-closed — when unset it skips - // everything). The synthetic rollouts declare cwd "/tmp", so scope the - // sandbox there; otherwise every import returns 0 and the catchup-cache - // assertions below never observe a session. - process.env.SANDBOX_BASE_DIRECTORY = "/tmp"; - - // Fresh require each call so the importer's module-level catchupCache is - // empty for the new sandbox. - const requireServer = createRequire(generatedDb); - delete requireServer.cache[requireServer.resolve("./lib/codex-import.js")]; - delete requireServer.cache[requireServer.resolve("./db.js")]; - const dbModule = requireServer("./db.js") as Record; - const { importAllCodexSessions } = requireServer("./lib/codex-import.js") as CodexImport; - const importAll = (): Promise => importAllCodexSessions(dbModule); - - return { - importAll, - root, - codexSessions, - pathFor: (i: number) => { - const uuid = `00000000-0000-4000-8000-${String(i).padStart(12, "0")}`; - return join(codexSessions, `rollout-${uuid}.jsonl`); - }, - }; -} - -function cleanup(root: string): void { - try { - rmSync(root, { recursive: true, force: true }); - } catch { - /* ignore */ - } -} - -test( - "FEA-1316: codex catchup poll skips unchanged rollout files", - { skip: skipReason ?? false }, - async () => { - const { importAll, root, pathFor } = makeSandbox(); - for (let i = 0; i < N_SESSIONS; i++) writeFileSync(pathFor(i), rollout(`uuid-${i}`)); - - const t0 = performance.now(); - const first = await importAll(); - const firstMs = performance.now() - t0; - - // Average over N_TICKS calls so a longer soak (env-tuned) actually - // exercises more of the catchup loop. With the cache in place each call - // is near-instant; without it, each call costs ~270 µs per file. - let steadyTotalMs = 0; - let last: ImportResult = first; - for (let i = 0; i < N_TICKS; i++) { - const tStart = performance.now(); - last = await importAll(); - steadyTotalMs += performance.now() - tStart; - } - const steadyAvgMs = steadyTotalMs / N_TICKS; - - cleanup(root); - - assert.equal( - first.imported, - N_SESSIONS, - `first call must import all ${N_SESSIONS} sessions, got ${first.imported}`, - ); - assert.equal(last.imported, 0, `steady-state call must import nothing, got ${last.imported}`); - assert.equal( - last.skipped, - N_SESSIONS, - `steady-state call must skip all ${N_SESSIONS}, got ${last.skipped}`, - ); - - // The whole point of FEA-1316: steady-state catchup must be near-free. - // Locally a 300-file run averages <2 ms; CI runners can be slow, so the - // bound scales with N. Pre-fix this would be ~80 ms even at N=300. - const bound = Math.max(50, N_SESSIONS * 0.1); - assert.ok( - steadyAvgMs < bound, - `steady-state catchup must be <${bound.toFixed(0)} ms (was ${steadyAvgMs.toFixed(1)} ms over ${N_TICKS} tick(s); first call ${firstMs.toFixed(0)} ms)`, - ); - }, -); - -test( - "FEA-1316: mutating a rollout file re-imports just that one on the next tick", - { skip: skipReason ?? false }, - async () => { - const { importAll, root, pathFor } = makeSandbox(); - const N = 10; - for (let i = 0; i < N; i++) writeFileSync(pathFor(i), rollout(`uuid-${i}`)); - - const first = await importAll(); - assert.equal(first.imported, N); - - // Some filesystems have coarse mtime resolution; sleep then append so - // the (mtime, size) signature definitely changes. - await new Promise((r) => setTimeout(r, 10)); - appendFileSync(pathFor(3), rollout(`uuid-3`, "EXTRA TURN")); - - const after = await importAll(); - cleanup(root); - - assert.equal(after.imported, 1, `only the mutated file should re-import, got ${after.imported}`); - assert.equal(after.skipped, N - 1, `the other ${N - 1} files should still be skipped`); - }, -); - -test( - "FEA-1316: a newly-added rollout file is picked up on the next tick", - { skip: skipReason ?? false }, - async () => { - const { importAll, root, pathFor } = makeSandbox(); - const N = 10; - for (let i = 0; i < N; i++) writeFileSync(pathFor(i), rollout(`uuid-${i}`)); - - await importAll(); - writeFileSync(pathFor(100), rollout("uuid-100")); - - const after = await importAll(); - cleanup(root); - - assert.equal(after.imported, 1, `the new file should import, got ${after.imported}`); - assert.equal(after.skipped, N, `the original ${N} files should still be skipped`); - }, -); - -test( - "FEA-1316: deleting a rollout file does not error and is pruned from the cache", - { skip: skipReason ?? false }, - async () => { - const { importAll, root, pathFor } = makeSandbox(); - const N = 10; - for (let i = 0; i < N; i++) writeFileSync(pathFor(i), rollout(`uuid-${i}`)); - - await importAll(); - unlinkSync(pathFor(0)); - - const after = await importAll(); - cleanup(root); - - assert.equal(after.errors, 0, `deleted file must not error, got errors=${after.errors}`); - assert.equal(after.skipped, N - 1, `skipped should equal remaining files, got ${after.skipped}`); - }, -); diff --git a/apps/desktop/test/agent-monitor-import-session-utils.test.ts b/apps/desktop/test/agent-monitor-import-session-utils.test.ts deleted file mode 100644 index 2e96ead4..00000000 --- a/apps/desktop/test/agent-monitor-import-session-utils.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { test } from "node:test"; - -const require = createRequire(import.meta.url); - -const { - RECENT_SESSION_ACTIVITY_THRESHOLD_MS, - isSessionRecentlyActive, - reactivateImportedSession, -} = require("../scripts/agent-monitor-shared/import-session-utils.js") as { - RECENT_SESSION_ACTIVITY_THRESHOLD_MS: number; - isSessionRecentlyActive: ( - session: { fileModifiedAt?: number | null } | null | undefined, - now?: number, - ) => boolean; - reactivateImportedSession: ( - dbModule: { - stmts: { - getSession: { get: (id: string) => Record | undefined }; - reactivateSession: { run: (id: string) => void }; - clearSessionAwaitingInput: { run: (id: string) => void }; - getAgent: { get: (id: string) => Record | undefined }; - }; - db: { prepare: (_sql: string) => { run: (id: string) => void } }; - }, - session: { sessionId: string; fileModifiedAt?: number | null }, - now?: number, - ) => boolean; -}; - -test("isSessionRecentlyActive uses the shared recent-activity threshold", () => { - const now = 1_000_000; - - assert.equal( - isSessionRecentlyActive( - { fileModifiedAt: now - RECENT_SESSION_ACTIVITY_THRESHOLD_MS + 1 }, - now, - ), - true, - ); - assert.equal( - isSessionRecentlyActive( - { fileModifiedAt: now - RECENT_SESSION_ACTIVITY_THRESHOLD_MS - 1 }, - now, - ), - false, - ); - assert.equal(isSessionRecentlyActive({ fileModifiedAt: null }, now), false); -}); - -test("reactivateImportedSession revives recently-active imported sessions", () => { - const sessionRows = new Map([ - [ - "sess-1", - { - id: "sess-1", - status: "abandoned", - ended_at: "2026-05-20T15:12:08.860Z", - awaiting_input_since: "2026-05-20T15:12:08.860Z", - }, - ], - ]); - const agentRows = new Map([ - [ - "sess-1-main", - { - id: "sess-1-main", - status: "completed", - ended_at: "2026-05-20T15:12:08.860Z", - current_tool: "exec_command", - awaiting_input_since: "2026-05-20T15:12:08.860Z", - }, - ], - ]); - - const dbModule = { - stmts: { - getSession: { - get: (id: string) => sessionRows.get(id), - }, - reactivateSession: { - run: (id: string) => { - const row = sessionRows.get(id); - if (!row) return; - row.status = "active"; - row.ended_at = null; - }, - }, - clearSessionAwaitingInput: { - run: (id: string) => { - const row = sessionRows.get(id); - if (!row) return; - row.awaiting_input_since = null; - }, - }, - getAgent: { - get: (id: string) => agentRows.get(id), - }, - }, - db: { - prepare: (_sql: string) => ({ - run: (id: string) => { - const row = agentRows.get(id); - if (!row) return; - row.status = "waiting"; - row.ended_at = null; - row.current_tool = null; - row.awaiting_input_since = null; - }, - }), - }, - }; - - const now = 2_000_000; - const reactivated = reactivateImportedSession( - dbModule, - { - sessionId: "sess-1", - fileModifiedAt: now - 1_000, - }, - now, - ); - - assert.equal(reactivated, true); - assert.deepEqual(sessionRows.get("sess-1"), { - id: "sess-1", - status: "active", - ended_at: null, - awaiting_input_since: null, - }); - assert.deepEqual(agentRows.get("sess-1-main"), { - id: "sess-1-main", - status: "waiting", - ended_at: null, - current_tool: null, - awaiting_input_since: null, - }); -}); - -test("reactivateImportedSession ignores stale or already-live sessions", () => { - const dbModule = { - stmts: { - getSession: { - get: (_id: string) => ({ status: "active", ended_at: null }), - }, - reactivateSession: { - run: (_id: string) => { - throw new Error("should not reactivate"); - }, - }, - clearSessionAwaitingInput: { - run: (_id: string) => { - throw new Error("should not clear awaiting input"); - }, - }, - getAgent: { - get: (_id: string) => undefined, - }, - }, - db: { - prepare: (_sql: string) => ({ - run: (_id: string) => { - throw new Error("should not update main agent"); - }, - }), - }, - }; - - const now = 3_000_000; - assert.equal( - reactivateImportedSession( - dbModule, - { - sessionId: "sess-2", - fileModifiedAt: now - RECENT_SESSION_ACTIVITY_THRESHOLD_MS - 1, - }, - now, - ), - false, - ); - assert.equal( - reactivateImportedSession( - dbModule, - { - sessionId: "sess-2", - fileModifiedAt: now - 1_000, - }, - now, - ), - false, - ); -}); diff --git a/apps/desktop/test/agent-monitor-lifecycle.test.ts b/apps/desktop/test/agent-monitor-lifecycle.test.ts deleted file mode 100644 index d0e97cde..00000000 --- a/apps/desktop/test/agent-monitor-lifecycle.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { test } from "node:test"; -import { openAgentDatabase } from "../src/main/database/index.js"; -import { createLifecycle, type HookData } from "../src/main/database/lifecycle.js"; -import type { TranscriptExtract } from "../src/main/database/transcript.js"; - -function makeHarness(transcript?: TranscriptExtract | null) { - const dir = mkdtempSync(path.join(tmpdir(), "cl-lifecycle-")); - const db = openAgentDatabase(path.join(dir, "agent-dashboard.sqlite")); - const lifecycle = createLifecycle(db.connection, { - tokenUsage: db.tokenUsage, - detectBillingMode: () => "api", - extractTranscript: () => transcript ?? null, - }); - return { - db, - lifecycle, - sessionStatus(id: string): string | undefined { - return (db.connection.prepare("SELECT status FROM sessions WHERE id = ?").get(id) as { status: string } | undefined)?.status; - }, - agent(id: string) { - return db.connection.prepare("SELECT * FROM agents WHERE id = ?").get(id) as - | { status: string; current_tool: string | null; awaiting_input_since: string | null; type: string } - | undefined; - }, - cleanup() { - db.close(); - rmSync(dir, { recursive: true, force: true }); - }, - }; -} - -test("lifecycle: SessionStart creates session + main agent with harness and billing_mode", () => { - const h = makeHarness(); - try { - h.lifecycle.processEvent("SessionStart", { session_id: "s1", cwd: "/work" } as HookData, "claude"); - const session = h.db.sessions.getById("s1"); - assert.ok(session, "session created"); - assert.equal(session!.status, "active"); - assert.equal(session!.harness, "claude"); - assert.equal(session!.billingMode, "api"); - const main = h.agent("s1-main"); - assert.ok(main, "main agent created"); - assert.equal(main!.type, "main"); - assert.ok(main!.awaiting_input_since, "fresh session awaits the first prompt"); - } finally { - h.cleanup(); - } -}); - -test("lifecycle: SessionStart -> PreToolUse -> Stop -> SessionEnd status sequence", () => { - const h = makeHarness(); - try { - h.lifecycle.processEvent("SessionStart", { session_id: "s1", cwd: "/work" } as HookData, "claude"); - assert.equal(h.sessionStatus("s1"), "active"); - - h.lifecycle.processEvent("UserPromptSubmit", { session_id: "s1" } as HookData, "claude"); - let main = h.agent("s1-main")!; - assert.equal(main.status, "working", "prompt submit resumes work"); - assert.equal(main.awaiting_input_since, null, "awaiting cleared on user activity"); - - h.lifecycle.processEvent("PreToolUse", { session_id: "s1", tool_name: "Bash" } as HookData, "claude"); - main = h.agent("s1-main")!; - assert.equal(main.current_tool, "Bash"); - assert.equal(main.status, "working"); - - h.lifecycle.processEvent("PostToolUse", { session_id: "s1", tool_name: "Bash" } as HookData, "claude"); - main = h.agent("s1-main")!; - assert.equal(main.current_tool, null, "tool cleared after use"); - - h.lifecycle.processEvent("Stop", { session_id: "s1" } as HookData, "claude"); - main = h.agent("s1-main")!; - assert.equal(main.status, "waiting", "turn end -> waiting"); - assert.ok(main.awaiting_input_since, "awaiting stamped on turn end"); - assert.equal(h.sessionStatus("s1"), "active", "session stays active between turns"); - - h.lifecycle.processEvent("SessionEnd", { session_id: "s1" } as HookData, "claude"); - assert.equal(h.sessionStatus("s1"), "completed"); - assert.equal(h.agent("s1-main")!.status, "completed"); - } finally { - h.cleanup(); - } -}); - -test("lifecycle: Stop with error marks session and main as error", () => { - const h = makeHarness(); - try { - h.lifecycle.processEvent("SessionStart", { session_id: "s1", cwd: "/work" } as HookData, "claude"); - h.lifecycle.processEvent("Stop", { session_id: "s1", stop_reason: "error" } as HookData, "claude"); - assert.equal(h.sessionStatus("s1"), "error"); - assert.equal(h.agent("s1-main")!.status, "error"); - } finally { - h.cleanup(); - } -}); - -test("lifecycle: subagent spawn on Task tool, completed on SubagentStop", () => { - const h = makeHarness(); - try { - h.lifecycle.processEvent("SessionStart", { session_id: "s1", cwd: "/work" } as HookData, "claude"); - h.lifecycle.processEvent("UserPromptSubmit", { session_id: "s1" } as HookData, "claude"); - h.lifecycle.processEvent( - "PreToolUse", - { session_id: "s1", tool_name: "Task", tool_input: { subagent_type: "explorer", prompt: "explore the repo" } } as HookData, - "claude", - ); - const subs = h.db.agents.getBySession("s1").filter((a) => a.type === "subagent"); - assert.equal(subs.length, 1, "one subagent spawned"); - assert.equal(subs[0].subagentType, "explorer"); - assert.equal(subs[0].status, "working"); - - h.lifecycle.processEvent("SubagentStop", { session_id: "s1", agent_type: "explorer" } as HookData, "claude"); - const after = h.db.agents.getBySession("s1").filter((a) => a.type === "subagent"); - assert.equal(after[0].status, "completed", "matched subagent completed"); - } finally { - h.cleanup(); - } -}); - -test("lifecycle: transcript token usage is written and session model synced", () => { - const transcript: TranscriptExtract = { - tokensByModel: new Map([["claude-sonnet-4-6", { input: 1200, output: 340, cacheRead: 50, cacheWrite: 10 }]]), - latestModel: "claude-sonnet-4-6", - compactionCount: 0, - }; - const h = makeHarness(transcript); - try { - h.lifecycle.processEvent("SessionStart", { session_id: "s1", cwd: "/work" } as HookData, "claude"); - h.lifecycle.processEvent("Stop", { session_id: "s1", transcript_path: "/tmp/x.jsonl" } as HookData, "claude"); - const rows = h.db.tokenUsage.getBySession("s1"); - assert.equal(rows.length, 1); - assert.equal(rows[0].inputTokens, 1200); - assert.equal(rows[0].model, "claude-sonnet-4-6"); - assert.equal(h.db.sessions.getById("s1")!.model, "claude-sonnet-4-6", "session model synced from transcript"); - } finally { - h.cleanup(); - } -}); - -test("lifecycle: ignores events without a session_id and never throws", () => { - const h = makeHarness(); - try { - assert.equal(h.lifecycle.processEvent("Stop", {} as HookData, "claude"), false); - assert.equal(h.db.sessions.getAll().length, 0); - } finally { - h.cleanup(); - } -}); diff --git a/apps/desktop/test/agent-monitor-listener.test.ts b/apps/desktop/test/agent-monitor-listener.test.ts index 6f011a60..fd78cf77 100644 --- a/apps/desktop/test/agent-monitor-listener.test.ts +++ b/apps/desktop/test/agent-monitor-listener.test.ts @@ -1,12 +1,11 @@ import assert from "node:assert/strict"; import http from "node:http"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { test } from "node:test"; -import { openAgentDatabase } from "../src/main/database/index.js"; -import { createLifecycle } from "../src/main/database/lifecycle.js"; -import { AgentHookListener } from "../src/main/agent-monitor-listener.js"; +import { + AgentHookListener, + type AgentHookLifecycle, +} from "../src/main/agent-monitor-listener.js"; +import type { HookData } from "../src/main/agent-dashboard-db-types.js"; interface PostResult { status: number; @@ -63,23 +62,50 @@ interface ListenerDiagnostics { logs: string[]; } +interface CapturedSession { + id: string; + harness: string; + cwd: string | null; +} + +class InMemoryHookLifecycle implements AgentHookLifecycle { + readonly sessions = { + getAll: async (): Promise => [...this.sessionRows.values()], + getById: async (id: string): Promise => + this.sessionRows.get(id) ?? null, + }; + + private readonly sessionRows = new Map(); + + constructor(private readonly diagnostics: ListenerDiagnostics) {} + + processEvent(hookType: string, data: HookData, harness: string): boolean { + if (hookType !== "SessionStart") { + return false; + } + const sessionId = data.session_id; + if (typeof sessionId !== "string" || sessionId.length === 0) { + return false; + } + this.sessionRows.set(sessionId, { + id: sessionId, + harness, + cwd: typeof data.cwd === "string" ? data.cwd : null, + }); + this.diagnostics.emits.push(sessionId); + return true; + } +} + async function withListener( run: ( url: string, - db: ReturnType, + lifecycle: InMemoryHookLifecycle, diagnostics: ListenerDiagnostics, ) => Promise, ): Promise { - const dir = mkdtempSync(path.join(tmpdir(), "cl-listener-")); - const db = openAgentDatabase(path.join(dir, "agent-dashboard.sqlite")); const diagnostics: ListenerDiagnostics = { emits: [], logs: [] }; - const lifecycle = createLifecycle(db.connection, { - tokenUsage: db.tokenUsage, - detectBillingMode: () => "api", - extractTranscript: () => null, - emit: (sessionId) => diagnostics.emits.push(sessionId), - log: (message) => diagnostics.logs.push(message), - }); + const lifecycle = new InMemoryHookLifecycle(diagnostics); const listener = new AgentHookListener({ lifecycle, log: (message) => diagnostics.logs.push(message), @@ -89,19 +115,17 @@ async function withListener( const url = listener.getUrl(); assert.ok(url, "listener bound to an ephemeral port"); try { - await run(url!, db, diagnostics); + await run(url!, lifecycle, diagnostics); } finally { await listener.stop(); - db.close(); - rmSync(dir, { recursive: true, force: true }); } } -function assertNoWritesOrEmits( - db: ReturnType, +async function assertNoWritesOrEmits( + lifecycle: InMemoryHookLifecycle, diagnostics: ListenerDiagnostics, -): void { - assert.equal(db.sessions.getAll().length, 0, "no session rows written"); +): Promise { + assert.equal((await lifecycle.sessions.getAll()).length, 0, "no session rows written"); assert.deepEqual(diagnostics.emits, [], "no live DB-change emits"); } @@ -120,9 +144,9 @@ test("listener: SessionStart writes a session with harness=claude", async () => data: { session_id: "s1", cwd: "/work/project" }, }); assert.equal(res.status, 200); - const session = db.sessions.getById("s1"); + const session = await db.sessions.getById("s1"); assert.ok(session, "session written"); - assert.equal(session!.harness, "claude"); + assert.equal(session?.harness, "claude"); assert.deepEqual(diagnostics.emits, ["s1"]); }); }); @@ -134,7 +158,7 @@ test("listener: Codex route stamps harness=codex without payload provider hint", data: { session_id: "cx1", cwd: "/work/project" }, }); assert.equal(res.status, 200); - assert.equal(db.sessions.getById("cx1")!.harness, "codex"); + assert.equal((await db.sessions.getById("cx1"))?.harness, "codex"); assert.deepEqual(diagnostics.emits, ["cx1"]); }); }); @@ -148,7 +172,7 @@ test("listener: payload provider hints are rejected before writes on every hook }); assert.equal(res.status, 200); assert.deepEqual(res.body, { ok: true, skipped: "invalid-provider-hint" }); - assertNoWritesOrEmits(db, diagnostics); + await assertNoWritesOrEmits(db, diagnostics); } }); }); @@ -162,7 +186,7 @@ test("listener: malformed, invalid, and oversized payloads fail soft without wri ); assert.equal(malformed.status, 200); assert.deepEqual(malformed.body, { ok: false }); - assertNoWritesOrEmits(db, diagnostics); + await assertNoWritesOrEmits(db, diagnostics); assert.equal( diagnostics.logs.some((message) => message.includes("super-secret-value")), false, @@ -175,7 +199,7 @@ test("listener: malformed, invalid, and oversized payloads fail soft without wri }); assert.equal(invalidEnvelope.status, 200); assert.deepEqual(invalidEnvelope.body, { ok: true, skipped: "invalid" }); - assertNoWritesOrEmits(db, diagnostics); + await assertNoWritesOrEmits(db, diagnostics); const oversized = await requestRaw( `${url}/api/hooks/event`, @@ -187,7 +211,7 @@ test("listener: malformed, invalid, and oversized payloads fail soft without wri ); assert.equal(oversized.status, 200); assert.deepEqual(oversized.body, { ok: false }); - assertNoWritesOrEmits(db, diagnostics); + await assertNoWritesOrEmits(db, diagnostics); }); }); @@ -199,7 +223,7 @@ test("listener: sessions from any directory are captured (no sandbox gating)", a }); assert.equal(res.status, 200); assert.deepEqual(res.body, { ok: true }); - const session = db.sessions.getById("anywhere"); + const session = await db.sessions.getById("anywhere"); assert.ok(session, "session from any directory is imported"); assert.deepEqual(diagnostics.emits, ["anywhere"]); }); diff --git a/apps/desktop/test/agent-monitor-multi-harness-parsers.test.ts b/apps/desktop/test/agent-monitor-multi-harness-parsers.test.ts deleted file mode 100644 index 41854a89..00000000 --- a/apps/desktop/test/agent-monitor-multi-harness-parsers.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; -import { test } from "node:test"; - -const require = createRequire(import.meta.url); - -type ParsedTurnDuration = { - durationMs: number; - timestamp: string; -}; - -type ParsedToolUse = { - name: string; -}; - -type ParsedMultiHarnessSession = { - sessionId: string; - name: string; - cwd: string | null; - model: string | null; - version: string | null; - userMessages: number; - assistantMessages: number; - toolUses: ParsedToolUse[]; - thinkingBlockCount: number; - turnDurations: ParsedTurnDuration[]; - entrypoint: string; - startedAt: string; - endedAt: string; - tokensByModel: Record< - string, - { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - } - >; -}; - -const copilotHome = require("../scripts/agent-monitor-copilot/copilot-home.js") as { - workspacePathFromUri: (folder: string) => string | null; -}; -const copilotParser = require("../scripts/agent-monitor-copilot/copilot-parser.js") as { - parseChatSessionFile: ( - filePath: string, - workspacePath: string | null, - ) => ParsedMultiHarnessSession | null; -}; -const codexParser = require("../scripts/agent-monitor-codex/codex-parser.js") as Record; -const cursorParser = require("../scripts/agent-monitor-cursor/cursor-parser.js") as Record; -const cursorTranscriptParser = cursorParser as { - parseTranscriptFile: (filePath: string) => Promise; -}; -const opencodeParser = require("../scripts/agent-monitor-opencode/opencode-parser.js") as { - loadSessionsFromDb: (dbPath: string) => ParsedMultiHarnessSession[]; -}; - -test("Copilot workspace file URIs decode to filesystem paths", () => { - assert.equal( - copilotHome.workspacePathFromUri("file:///Users/dev/my%20project"), - "/Users/dev/my project", - ); -}); - -test("Copilot Chat parser supports request-based session files", () => { - const dir = mkdtempSync(path.join(tmpdir(), "copilot-chat-")); - const filePath = path.join(dir, "session.json"); - writeFileSync( - filePath, - JSON.stringify({ - sessionId: "copilot-session-1", - creationDate: 1710000000000, - lastMessageDate: 1710000060000, - requests: [ - { - id: "req-1", - timestamp: 1710000000000, - message: { text: "Summarize the repo" }, - response: { markdown: "Here is the summary." }, - toolCalls: [{ name: "search", arguments: '{"query":"repo"}' }], - reasoning: { summary: "think first" }, - }, - ], - }), - "utf8", - ); - - const parsed = copilotParser.parseChatSessionFile(filePath, "/Users/dev/my project"); - assert.ok(parsed, "expected a parsed Copilot chat session"); - assert.equal(parsed.sessionId, "copilot-chat-copilot-session-1"); - assert.equal(parsed.name, "my project"); - assert.equal(parsed.userMessages, 1); - assert.equal(parsed.assistantMessages, 1); - assert.equal(parsed.toolUses.length, 1); - assert.equal(parsed.toolUses[0].name, "search"); - assert.equal(parsed.thinkingBlockCount, 1); - assert.deepEqual(parsed.turnDurations, [ - { - durationMs: 60_000, - timestamp: "2024-03-09T16:01:00.000Z", - }, - ]); - assert.equal(parsed.entrypoint, "copilot"); - assert.equal(parsed.startedAt, "2024-03-09T16:00:00.000Z"); - assert.equal(parsed.endedAt, "2024-03-09T16:01:00.000Z"); -}); - -test("OpenCode parser loads sessions from opencode.db", () => { - const dir = mkdtempSync(path.join(tmpdir(), "opencode-db-")); - const dbPath = path.join(dir, "opencode.db"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE session ( - id TEXT PRIMARY KEY, - slug TEXT, - directory TEXT NOT NULL, - title TEXT NOT NULL, - version TEXT NOT NULL, - agent TEXT, - model TEXT, - permission TEXT, - time_created INTEGER NOT NULL, - time_updated INTEGER NOT NULL, - tokens_input INTEGER DEFAULT 0 NOT NULL, - tokens_output INTEGER DEFAULT 0 NOT NULL, - tokens_reasoning INTEGER DEFAULT 0 NOT NULL, - tokens_cache_read INTEGER DEFAULT 0 NOT NULL, - tokens_cache_write INTEGER DEFAULT 0 NOT NULL - ); - CREATE TABLE message ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - time_created INTEGER NOT NULL, - time_updated INTEGER NOT NULL, - data TEXT NOT NULL - ); - CREATE TABLE part ( - id TEXT PRIMARY KEY, - message_id TEXT NOT NULL, - session_id TEXT NOT NULL, - time_created INTEGER NOT NULL, - time_updated INTEGER NOT NULL, - data TEXT NOT NULL - ); - `); - - db.prepare(` - INSERT INTO session ( - id, slug, directory, title, version, agent, model, permission, - time_created, time_updated, tokens_input, tokens_output, - tokens_reasoning, tokens_cache_read, tokens_cache_write - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - "ses_1", - "quiet-orchid", - "/Users/dev/my project", - "Repo overview", - "1.15.5", - "build", - JSON.stringify({ id: "big-pickle", providerID: "opencode" }), - "", - 1710000000000, - 1710000060000, - 100, - 20, - 5, - 40, - 0, - ); - db.prepare(` - INSERT INTO message (id, session_id, time_created, time_updated, data) - VALUES (?, ?, ?, ?, ?) - `).run( - "msg_1", - "ses_1", - 1710000000000, - 1710000000000, - JSON.stringify({ - role: "user", - time: { created: 1710000000000 }, - }), - ); - db.prepare(` - INSERT INTO message (id, session_id, time_created, time_updated, data) - VALUES (?, ?, ?, ?, ?) - `).run( - "msg_2", - "ses_1", - 1710000030000, - 1710000030000, - JSON.stringify({ - role: "assistant", - path: { cwd: "/Users/dev/my project", root: "/Users/dev/my project" }, - time: { created: 1710000030000 }, - }), - ); - db.prepare(` - INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) - VALUES (?, ?, ?, ?, ?, ?) - `).run( - "part_1", - "msg_2", - "ses_1", - 1710000020000, - 1710000021000, - JSON.stringify({ - type: "reasoning", - text: "Think first", - time: { start: 1710000020000, end: 1710000021000 }, - }), - ); - db.prepare(` - INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) - VALUES (?, ?, ?, ?, ?, ?) - `).run( - "part_2", - "msg_2", - "ses_1", - 1710000025000, - 1710000026000, - JSON.stringify({ - type: "tool", - tool: "read", - state: { - status: "completed", - input: { filePath: "/Users/dev/my project/README.md" }, - }, - time: { start: 1710000025000, end: 1710000026000 }, - }), - ); - db.close(); - - const sessions = opencodeParser.loadSessionsFromDb(dbPath); - assert.equal(sessions.length, 1); - const parsed = sessions[0]; - assert.equal(parsed.sessionId, "opencode-ses_1"); - assert.equal(parsed.cwd, "/Users/dev/my project"); - assert.equal(parsed.name, "my project"); - assert.equal(parsed.model, "big-pickle"); - assert.equal(parsed.version, "1.15.5"); - assert.equal(parsed.userMessages, 1); - assert.equal(parsed.assistantMessages, 1); - assert.equal(parsed.toolUses.length, 1); - assert.equal(parsed.toolUses[0].name, "read"); - assert.equal(parsed.thinkingBlockCount, 1); - assert.deepEqual(parsed.turnDurations, [ - { - durationMs: 30_000, - timestamp: "2024-03-09T16:00:30.000Z", - }, - ]); - assert.deepEqual(parsed.tokensByModel["big-pickle"], { - input: 100, - output: 25, - cacheRead: 40, - cacheWrite: 0, - }); -}); - -test("Cursor parser derives turn durations from user/assistant timestamps", async () => { - const dir = mkdtempSync(path.join(tmpdir(), "cursor-transcript-")); - const sessionDir = path.join(dir, "session-123"); - mkdirSync(sessionDir, { recursive: true }); - const filePath = path.join(sessionDir, "rollout.jsonl"); - writeFileSync( - filePath, - [ - { - timestamp: "2024-03-09T16:00:00.000Z", - type: "session_meta", - payload: { - cwd: "/Users/dev/cursor project", - model: "claude-3-7-sonnet", - }, - }, - { - timestamp: "2024-03-09T16:00:05.000Z", - type: "user_message", - payload: { message: "Investigate failing test" }, - }, - { - timestamp: "2024-03-09T16:00:11.500Z", - type: "assistant_message", - payload: { message: "Looking now" }, - }, - ].map((line) => JSON.stringify(line)).join("\n"), - "utf8", - ); - - const parsed = await cursorTranscriptParser.parseTranscriptFile(filePath); - assert.ok(parsed, "expected a parsed Cursor transcript"); - assert.deepEqual(parsed.turnDurations, [ - { - durationMs: 6_500, - timestamp: "2024-03-09T16:00:11.500Z", - }, - ]); -}); - -test("multi-harness parsers no longer re-export shared timestamp helpers", () => { - assert.equal("toIso" in codexParser, false); - assert.equal("toIso" in cursorParser, false); - assert.equal("toIso" in copilotParser, false); - assert.equal("toIso" in opencodeParser, false); - assert.equal("pushTurnDuration" in codexParser, false); - assert.equal("pushTurnDuration" in cursorParser, false); - assert.equal("pushTurnDuration" in copilotParser, false); - assert.equal("pushTurnDuration" in opencodeParser, false); -}); diff --git a/apps/desktop/test/agent-monitor-sidecar.test.ts b/apps/desktop/test/agent-monitor-sidecar.test.ts deleted file mode 100644 index eeba19e4..00000000 --- a/apps/desktop/test/agent-monitor-sidecar.test.ts +++ /dev/null @@ -1,592 +0,0 @@ -/** - * Tests for agent-monitor-sidecar.ts PID persistence, orphan reclamation, - * foreign process safety, and stale log suppression. - * - * AC-011: foreign process holds port 4820 — counter advances 1-5, no PID - * killed, no false-positive ready log, terminal "giving up" log fires. - * AC-012: orphan recovery — spawn, persist PID, force-kill, restart, orphan - * SIGKILLed, new spawn binds port 4820 successfully and reaches ready. - * AC-013: stale log suppression — prev-launch resolves after new-launch race; - * misleading "did not become healthy" log does not fire. - * - * Because agent-monitor-sidecar.ts imports `app` from "electron" directly, - * the class cannot be imported under the Node.js test runner (tsx --test). - * These tests follow the same structural-verification approach used in - * agent-monitor-wiring-static.test.ts: they read the source as text and assert - * the implementation invariants that make each AC hold at runtime. - * - */ - -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { describe, test } from "node:test"; - -// --------------------------------------------------------------------------- -// Source text fixture (read once at module evaluation time) -// --------------------------------------------------------------------------- - -const sidecarSource = readFileSync( - new URL("../src/main/agent-monitor-sidecar.ts", import.meta.url), - "utf-8", -); - -// --------------------------------------------------------------------------- -// Pre-computed method body slices (avoids repeating indexOf + slice in each test) -// --------------------------------------------------------------------------- - -/** - * Extract a method body from the sidecar source by its signature prefix. - * Returns the slice starting at the method signature up to `windowChars` chars. - * Throws if the signature is not found (fail-fast for stale tests). - */ -function methodBody(signature: string, windowChars: number): string { - const idx = sidecarSource.indexOf(signature); - assert.ok(idx >= 0, `${signature} not found in sidecar source`); - return sidecarSource.slice(idx, idx + windowChars); -} - -// Windows are sized to comfortably contain the full method body so a -// boundary-straddling assertion target (e.g. a string near the method's end) -// is never silently truncated out of the slice. Pad generously; the cost is a -// few extra chars of unrelated source, the failure mode of being too small is a -// misleading "not found" that blames production code for a test-window bug. -const reclaimOrphanBody = methodBody("private async reclaimOrphan()", 4000); -const handleExitBody = methodBody("private handleExit(", 2000); -const launchBody = methodBody("private async launch()", 4000); - -// --------------------------------------------------------------------------- -// Static verification tests (AC-006 through AC-010 source-level invariants) -// --------------------------------------------------------------------------- - -describe("agent-monitor-sidecar.ts source-level invariants", () => { - // ------------------------------------------------------------------------- - // AC-006: PID file lifecycle — write after spawn, delete on stop() - // ------------------------------------------------------------------------- - - test("AC-006a: writePidFile uses atomic rename (write .tmp then rename)", () => { - assert.match( - sidecarSource, - /await fs\.writeFile\(tmpFile, payload, "utf-8"\);\s*await fs\.rename\(tmpFile, pidFile\)/, - ); - }); - - test("AC-006b: writePidFile persists { pid, sessionToken, startTime, recordedAt } JSON", () => { - // startTime (OS process start-time captured at spawn) is part of the - // ownership identity reclaimOrphan re-verifies against the live process. - assert.match( - sidecarSource, - /pid,\s*sessionToken: this\.sessionToken,\s*startTime: await getProcessStartTime\(pid\),\s*recordedAt:/, - ); - }); - - test("AC-006c: writePidFile ensures agent-monitor directory exists with mkdir recursive", () => { - assert.match( - sidecarSource, - /await fs\.mkdir\(this\.dataDir, \{ recursive: true \}\);[\s\S]{0,100}await fs\.writeFile\(tmpFile/, - ); - }); - - test("AC-006d: deletePidFile is called in stop() after killing the child", () => { - // The finally block in stop() must contain deletePidFile() - assert.match( - sidecarSource, - /async stop\(\): Promise[\s\S]{0,600}await this\.deletePidFile\(\)/, - ); - }); - - test("AC-006e: deletePidFile suppresses ENOENT (file absent on first run)", () => { - // The deletePidFile method body catches errors and only logs when code is - // NOT ENOENT — meaning ENOENT (file absent on first run) is silently swallowed. - assert.match( - sidecarSource, - /deletePidFile[\s\S]{0,400}code !== "ENOENT"/, - ); - }); - - test("AC-006f: writePidFile is called after spawn before health waits", () => { - // The PID file must be written as soon as a child pid exists, before - // waitForHealth() and the stability window, so a force-quit during startup - // leaves enough metadata for the next launch to reclaim the orphan. - const pidGuardPos = launchBody.indexOf("if (!child.pid)"); - const writePidPos = launchBody.indexOf("await this.writePidFile(child.pid)"); - const waitForHealthPos = launchBody.indexOf("const healthy = await this.waitForHealth(child)"); - assert.ok(pidGuardPos >= 0, "child.pid guard not found in launch()"); - assert.ok(writePidPos >= 0, "writePidFile(child.pid) not found in launch()"); - assert.ok(waitForHealthPos >= 0, "waitForHealth(child) not found in launch()"); - assert.ok( - pidGuardPos < writePidPos && writePidPos < waitForHealthPos, - "writePidFile(child.pid) must run after the pid guard and before waitForHealth(child)", - ); - }); - - // ------------------------------------------------------------------------- - // AC-007: Pre-bind orphan reclamation - // ------------------------------------------------------------------------- - - test("AC-007a: reclaimOrphan is called before spawn in launch()", () => { - const reclaimPos = launchBody.indexOf("await this.reclaimOrphan()"); - const spawnPos = launchBody.indexOf("const child = spawn("); - assert.ok(reclaimPos >= 0, "reclaimOrphan() call not found in launch()"); - assert.ok(spawnPos >= 0, "spawn() call not found in launch()"); - assert.ok( - reclaimPos < spawnPos, - "reclaimOrphan() must be called before spawn()", - ); - }); - - test("AC-007b: reclaimOrphan SIGKILLs a running orphan before the final deletePidFile call", () => { - assert.match(reclaimOrphanBody, /isRunning\(pid\)/); - assert.match(reclaimOrphanBody, /killGroup\(pid, "SIGKILL"\)/); - // Verify the unconditional deletePidFile at the end of reclaimOrphan comes - // after the SIGKILL inside the isRunning guard. - const sigkillPos = reclaimOrphanBody.indexOf('killGroup(pid, "SIGKILL")'); - assert.ok(sigkillPos >= 0, 'killGroup(pid, "SIGKILL") not found in reclaimOrphan body'); - // The last deletePidFile() call in the body is the unconditional one that - // runs after the kill (all other deletePidFile calls are in early-return paths). - const lastDeletePos = reclaimOrphanBody.lastIndexOf("await this.deletePidFile()"); - assert.ok(lastDeletePos >= 0, "await this.deletePidFile() not found in reclaimOrphan body"); - assert.ok( - sigkillPos < lastDeletePos, - `Expected SIGKILL (pos ${sigkillPos}) to precede final deletePidFile (pos ${lastDeletePos})`, - ); - }); - - test("AC-007c: reclaimOrphan reads sidecar.pid from the dataDir directory", () => { - assert.match( - reclaimOrphanBody, - /path\.join\(this\.dataDir, "sidecar\.pid"\)/, - ); - }); - - // ------------------------------------------------------------------------- - // AC-008: Foreign process safety - // ------------------------------------------------------------------------- - - test("AC-008a: reclaimOrphan skips kill when PID file is absent (ENOENT returns early)", () => { - assert.match(reclaimOrphanBody, /code === "ENOENT"[\s\S]{0,60}return;/); - }); - - test("AC-008b: reclaimOrphan skips kill when sessionToken is missing", () => { - assert.match( - sidecarSource, - /!sessionToken[\s\S]{0,200}skipping kill[\s\S]{0,200}await this\.deletePidFile/, - ); - }); - - test("AC-008c: reclaimOrphan only kills via SIGKILL — no SIGTERM path", () => { - assert.match(reclaimOrphanBody, /SIGKILL/); - assert.doesNotMatch(reclaimOrphanBody, /SIGTERM/); - }); - - test("AC-008d: reclaimOrphan verifies live-process ownership (command + start-time) before SIGKILL", () => { - // A live pid is only SIGKILLed when BOTH independent, PID-file-independent - // signals confirm it is still our sidecar: its command line runs our entry - // file, and its OS start-time matches the value recorded at spawn. This is - // the guard that prevents killing a recycled/foreign process holding the - // fixed port — sessionToken presence alone is insufficient (it has no - // independent witness). - assert.match(reclaimOrphanBody, /const runsOurEntry =\s*command !== null && command\.includes\(entryFile\)/); - assert.match(reclaimOrphanBody, /liveStartTime !== null && liveStartTime === recordedStartTime/); - - // The ownership check must precede the SIGKILL — the kill is gated on it. - const ownershipPos = reclaimOrphanBody.indexOf("runsOurEntry && startTimeMatches"); - const killGroupPos = reclaimOrphanBody.indexOf('killGroup(pid, "SIGKILL")'); - assert.ok(ownershipPos >= 0, "ownership check (runsOurEntry && startTimeMatches) not found in reclaimOrphan"); - assert.ok(killGroupPos >= 0, 'killGroup(pid, "SIGKILL") not found in reclaimOrphan'); - assert.ok( - ownershipPos < killGroupPos, - `ownership check (pos ${ownershipPos}) must gate SIGKILL (pos ${killGroupPos})`, - ); - }); - - test("AC-008e: reclaimOrphan logs and skips kill when the live process is not our sidecar", () => { - // The else-branch of the ownership check must warn and fall through to the - // unconditional deletePidFile WITHOUT calling killGroup, so a recycled or - // foreign pid is never signalled. - assert.match( - reclaimOrphanBody, - /recycled or foreign process[\s\S]{0,80}skipping kill/, - ); - }); - - test("AC-008f: reclaimOrphan waits (bounded) for the SIGKILLed orphan to exit before returning", () => { - // SIGKILL is not synchronous with the orphan releasing the fixed port, so - // reclaimOrphan must poll isRunning(pid) on a bounded deadline after the kill - // before launch() respawns — otherwise the first respawn can race a - // not-yet-released socket and hit EADDRINUSE. Assert the exact bounded-wait - // invariant: a deadline built from the named timeout constant, gating a - // delay()-spaced isRunning(pid) poll loop, placed AFTER the SIGKILL. - assert.match( - reclaimOrphanBody, - /killGroup\(pid, "SIGKILL"\);[\s\S]{0,600}const deadline = Date\.now\(\) \+ RECLAIM_WAIT_TIMEOUT_MS;\s*while \(isRunning\(pid\) && Date\.now\(\) < deadline\) \{\s*await delay\(READY_POLL_INTERVAL_MS\);\s*\}/, - ); - // The timeout constant must be defined so the wait is genuinely bounded. - assert.match(sidecarSource, /const RECLAIM_WAIT_TIMEOUT_MS = [\d_]+;/); - }); - - // ------------------------------------------------------------------------- - // AC-009: Terminal failure callback - // ------------------------------------------------------------------------- - - test("AC-009a: onTerminalFailure callback is accepted in constructor options", () => { - assert.match( - sidecarSource, - /constructor\(options\?: \{ onTerminalFailure\?: \(reason: string\) => void \}\)/, - ); - }); - - test("AC-009b: onTerminalFailure is invoked when restartAttempts >= MAX_RESTART_ATTEMPTS", () => { - assert.match( - sidecarSource, - /this\.restartAttempts >= MAX_RESTART_ATTEMPTS[\s\S]{0,500}this\.onTerminalFailure\?\.\(reason\)/, - ); - }); - - test("AC-009c: EADDRINUSE stderr sets lastExitWasPortConflict flag", () => { - assert.match(sidecarSource, /EADDRINUSE[\s\S]{0,60}lastExitWasPortConflict = true/); - }); - - test("AC-009d: terminal failure message includes port-in-use detail when lastExitWasPortConflict", () => { - assert.match( - sidecarSource, - /lastExitWasPortConflict[\s\S]{0,300}port.*is in use by another process/, - ); - }); - - // ------------------------------------------------------------------------- - // AC-010: Stale log suppression - // ------------------------------------------------------------------------- - - test("AC-010: stale waitForHealth log is gated by this.child === child check", () => { - // The warn log must be inside a guard that checks whether the child is - // still the active one. The guard must appear BEFORE the warn log. - assert.match( - sidecarSource, - /this\.child !== child[\s\S]{0,200}return;[\s\S]{0,400}agent monitor did not become healthy/, - ); - }); -}); - -// --------------------------------------------------------------------------- -// T-3.2: Foreign-process guard scenario (AC-011) — source-level invariants -// -// These tests verify the behavioral invariants that make the foreign-process -// scenario correct at runtime by reading the source as text and asserting -// the presence and ordering of critical logic patterns. -// -// AC-011: foreign process holds port 4820 — counter advances 1-5, no PID -// killed, no false-positive ready log, terminal "giving up" log fires. -// --------------------------------------------------------------------------- - -describe("T-3.2: foreign-process guard scenario source-level invariants (AC-011)", () => { - // ------------------------------------------------------------------------- - // Invariant 1: restartAttempts increments up to MAX_RESTART_ATTEMPTS - // ------------------------------------------------------------------------- - - test("restart counter increments on each exit before reaching the cap", () => { - // handleExit() must increment restartAttempts (++this.restartAttempts) when - // the attempt count is below the cap. - assert.match( - sidecarSource, - /const attempt = \+\+this\.restartAttempts/, - ); - }); - - test("restart counter is bounded by MAX_RESTART_ATTEMPTS check before increment", () => { - // The guard `this.restartAttempts >= MAX_RESTART_ATTEMPTS` must appear in - // handleExit() before the increment, so the cap is enforced correctly. - assert.match(handleExitBody, /this\.restartAttempts >= MAX_RESTART_ATTEMPTS/); - const capCheckPos = handleExitBody.indexOf("this.restartAttempts >= MAX_RESTART_ATTEMPTS"); - const incrementPos = handleExitBody.indexOf("const attempt = ++this.restartAttempts"); - assert.ok(capCheckPos >= 0, "cap check not found in handleExit"); - assert.ok(incrementPos >= 0, "restart counter increment (const attempt = ++this.restartAttempts) not found in handleExit"); - assert.ok( - capCheckPos < incrementPos, - `cap check (pos ${capCheckPos}) must precede increment (pos ${incrementPos})`, - ); - }); - - test("restart attempt number is logged with MAX_RESTART_ATTEMPTS denominator", () => { - // The log line `attempt N/MAX_RESTART_ATTEMPTS` must appear so the user can - // see progress toward the cap (attempt 1/5 through 5/5). - assert.match( - sidecarSource, - /attempt \$\{attempt\}\/\$\{MAX_RESTART_ATTEMPTS\}/, - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 2: "giving up" log fires when restartAttempts >= MAX_RESTART_ATTEMPTS - // ------------------------------------------------------------------------- - - test('"giving up" log message fires inside the MAX_RESTART_ATTEMPTS guard', () => { - // The "giving up" error log must be inside the restartAttempts >= cap guard - // so it fires exactly when the supervisor exhausts all attempts. - assert.match( - sidecarSource, - /this\.restartAttempts >= MAX_RESTART_ATTEMPTS[\s\S]{0,300}giving up after \$\{this\.restartAttempts\} restart attempts/, - ); - }); - - test('"giving up" log uses gatewayLog.error (not warn or info)', () => { - // Giving up is a fatal event — it must be logged at error level. - const giveUpIdx = sidecarSource.indexOf("giving up after"); - assert.ok(giveUpIdx >= 0, '"giving up after" string not found in source'); - // Look back up to 50 chars for the log method name. - const context = sidecarSource.slice(Math.max(0, giveUpIdx - 50), giveUpIdx); - assert.match(context, /gatewayLog\.error/); - }); - - // ------------------------------------------------------------------------- - // Invariant 3: No process.kill/killGroup when sessionToken is missing from PID file - // ------------------------------------------------------------------------- - - test("reclaimOrphan returns without calling killGroup when sessionToken is missing", () => { - // When the PID file exists but has no sessionToken, the code must log a - // warning and return early (via deletePidFile then return) WITHOUT calling - // killGroup. This is the foreign-process safety guard. - assert.match(reclaimOrphanBody, /!sessionToken/); - - // After the !sessionToken check there must be a return; before any killGroup. - const noTokenIdx = reclaimOrphanBody.indexOf("!sessionToken"); - const returnAfterNoToken = reclaimOrphanBody.indexOf("return;", noTokenIdx); - const killGroupIdx = reclaimOrphanBody.indexOf("killGroup("); - assert.ok(noTokenIdx >= 0, "!sessionToken guard not found"); - assert.ok(returnAfterNoToken >= 0, "return after !sessionToken not found"); - assert.ok(killGroupIdx >= 0, "killGroup call not found in reclaimOrphan"); - assert.ok( - returnAfterNoToken < killGroupIdx, - `return; after !sessionToken (pos ${returnAfterNoToken}) must precede killGroup (pos ${killGroupIdx}) so missing sessionToken exits before kill`, - ); - }); - - test("reclaimOrphan logs a warning when sessionToken is missing (not silently skipped)", () => { - // The foreign-process safety warning must be explicit so operators can - // diagnose why a port-holding process was not reclaimed. - assert.match( - sidecarSource, - /PID file missing sessionToken[\s\S]{0,100}skipping kill/, - ); - }); - - test("killGroup is only called inside the isRunning(pid) guard in reclaimOrphan", () => { - // SIGKILL must only be sent if the recorded PID is alive. This prevents - // killing a reused PID that belongs to a different process. - const isRunningPos = reclaimOrphanBody.indexOf("isRunning(pid)"); - const killGroupPos = reclaimOrphanBody.indexOf("killGroup("); - assert.ok(isRunningPos >= 0, "isRunning(pid) guard not found in reclaimOrphan"); - assert.ok(killGroupPos >= 0, "killGroup call not found in reclaimOrphan"); - assert.ok( - isRunningPos < killGroupPos, - `isRunning guard (pos ${isRunningPos}) must precede killGroup call (pos ${killGroupPos})`, - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 4: onTerminalFailure callback is invoked when giving up - // ------------------------------------------------------------------------- - - test("onTerminalFailure callback is invoked inside the giving-up branch", () => { - // The callback must be called with an actionable reason string when the - // supervisor exhausts all restart attempts. - assert.match( - sidecarSource, - /this\.restartAttempts >= MAX_RESTART_ATTEMPTS[\s\S]{0,500}this\.onTerminalFailure\?\.\(reason\)/, - ); - }); - - test("onTerminalFailure receives reason string built from lastExitWasPortConflict", () => { - // The reason passed to the callback must differ based on whether the exit - // was caused by EADDRINUSE, providing an actionable message in both cases. - assert.match( - sidecarSource, - /lastExitWasPortConflict[\s\S]{0,100}port.*is in use by another process/, - ); - // Fallback reason for non-port-conflict terminal failures. - assert.match( - sidecarSource, - /Agent monitor failed after \$\{this\.restartAttempts\} restart attempts/, - ); - }); - - test("onTerminalFailure is called with the built reason, not a hardcoded string", () => { - // The `reason` variable must be constructed and then passed directly to - // the callback — not an inline string literal. - assert.match( - sidecarSource, - /const reason = [\s\S]{0,300}this\.onTerminalFailure\?\.\(reason\)/, - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 5: "did not become healthy" log is present with the port number - // ------------------------------------------------------------------------- - - test('"did not become healthy" log includes the port number', () => { - // The warn log must include `this.port` so the operator knows which port - // failed, especially when running non-default configurations. - assert.match( - sidecarSource, - /agent monitor did not become healthy on port \$\{this\.port\}/, - ); - }); - - test('"did not become healthy" log uses gatewayLog.warn', () => { - // This is a recoverable failure (supervisor will retry), so warn is correct. - const didNotIdx = sidecarSource.indexOf("agent monitor did not become healthy on port"); - assert.ok(didNotIdx >= 0, '"did not become healthy" log not found'); - const context = sidecarSource.slice(Math.max(0, didNotIdx - 60), didNotIdx); - assert.match(context, /gatewayLog\.warn/); - }); - - test('"did not become healthy" log is only reached when this.child === child (stale-guard)', () => { - // The stale-guard check `this.child !== child` with an early return must - // precede the warn log so a superseded launch cannot emit this message. - // (Shared with AC-010 but validated here as part of the foreign-process - // behavioral invariant set.) - assert.match( - sidecarSource, - /this\.child !== child[\s\S]{0,200}return;[\s\S]{0,400}agent monitor did not become healthy/, - ); - }); -}); - -// --------------------------------------------------------------------------- -// T-3.4: Stale log suppression scenario (AC-013) — source-level invariants -// -// These tests verify the behavioral invariants that prevent a previous launch's -// stale waitForHealth resolution from emitting misleading "did not become -// healthy" logs after a new launch has already started. -// -// AC-013: stale log suppression — prev-launch resolves after new-launch race; -// misleading "did not become healthy" log does not fire for the stale -// context. -// -// The race condition: when launch() is called twice in rapid succession (e.g. -// because handleExit fires a restart while a prior waitForHealth is still -// polling), the first launch's waitForHealth eventually resolves false after -// the new child has already been set on this.child. Without the stale guard, -// the first launch would emit a misleading warn log and call flushReady(false), -// potentially overwriting the second launch's ready state. -// --------------------------------------------------------------------------- - -describe("T-3.4: stale log suppression scenario source-level invariants (AC-013)", () => { - // ------------------------------------------------------------------------- - // Invariant 1: this.child !== child early-return guard is present in launch() - // ------------------------------------------------------------------------- - - test("launch() contains the this.child !== child stale guard before the warn log", () => { - // The stale guard must be present so that when a second launch() has already - // replaced this.child, the first launch's continuation returns immediately - // without logging the misleading "did not become healthy" message. - assert.match( - launchBody, - /this\.child !== child/, - "launch() must contain the this.child !== child stale guard", - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 2: the stale guard must result in an early return - // ------------------------------------------------------------------------- - - test("the this.child !== child guard has an early return that precedes the warn log", () => { - // The return statement must immediately follow the stale guard check so - // the warn log and flushReady(false) are completely skipped for stale launches. - assert.match( - sidecarSource, - /this\.child !== child[\s\S]{0,50}return;/, - "this.child !== child guard must be followed by a return statement", - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 3: the stale guard early return precedes the warn log in source - // ------------------------------------------------------------------------- - - test("the stale guard early return appears before the warn log in launch() body", () => { - // Position-based assertion: the early return in the stale guard must come - // before the warn log so the warn is unreachable for superseded launches. - const staleGuardPos = launchBody.indexOf("this.child !== child"); - const warnLogPos = launchBody.indexOf( - "agent monitor did not become healthy on port", - ); - assert.ok(staleGuardPos >= 0, "this.child !== child not found in launch()"); - assert.ok( - warnLogPos >= 0, - "\"agent monitor did not become healthy\" log not found in launch()", - ); - assert.ok( - staleGuardPos < warnLogPos, - `stale guard (pos ${staleGuardPos}) must precede the warn log (pos ${warnLogPos})`, - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 4: flushReady(false) is skipped when the launch is stale - // ------------------------------------------------------------------------- - - test("flushReady(false) is only reachable after the stale guard in launch()", () => { - // When this.child !== child, the code returns before the flushReady(false) - // call, ensuring the newer launch's ready state is not overwritten. - // We verify this by asserting the stale guard return precedes flushReady(false). - const staleGuardPos = launchBody.indexOf("this.child !== child"); - // Find the flushReady(false) call that follows the warn log (there may be - // earlier flushReady(false) calls in the early-exit paths at the top of launch()). - const warnLogPos = launchBody.indexOf("agent monitor did not become healthy"); - const flushReadyAfterWarn = launchBody.indexOf("this.flushReady(false)", warnLogPos); - assert.ok(staleGuardPos >= 0, "stale guard not found in launch() body"); - assert.ok(warnLogPos >= 0, "warn log not found in launch() body"); - assert.ok( - flushReadyAfterWarn >= 0, - "flushReady(false) after warn log not found in launch() body", - ); - // The stale guard must come before flushReady(false), confirming that when the - // guard fires and returns early, flushReady(false) is bypassed. - assert.ok( - staleGuardPos < flushReadyAfterWarn, - `stale guard (pos ${staleGuardPos}) must precede flushReady(false) (pos ${flushReadyAfterWarn}) so the call is skipped for stale launches`, - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 5: the guard compares local child against this.child (not this.child against this.child) - // ------------------------------------------------------------------------- - - test("the stale guard compares the local child variable against this.child", () => { - // The guard must reference the closure-captured local `child` variable from - // the spawn call — not a stale snapshot of `this.child`. This ensures that - // the comparison correctly detects when a newer launch has replaced this.child - // after the current launch captured its local reference. - - // The guard must be expressed as `this.child !== child` (this.child on the - // left, local child on the right) — not `child !== child` or any other form. - assert.match( - launchBody, - /if \(this\.child !== child\)/, - "stale guard must use the exact form `if (this.child !== child)`", - ); - - // The local `child` variable must be defined in launch() via the spawn() call. - assert.match( - launchBody, - /const child = spawn\(/, - "local `child` must be set via spawn() in launch()", - ); - }); - - // ------------------------------------------------------------------------- - // Invariant 6: the stale guard comment explains the race condition - // ------------------------------------------------------------------------- - - test("the stale guard has an explanatory comment about the superseded launch", () => { - // A comment documenting the race condition makes the invariant auditable - // and prevents future maintainers from inadvertently removing the guard. - // The comment must appear near the stale guard. - assert.match( - launchBody, - /superseded[\s\S]{0,200}this\.child !== child/, - "a comment mentioning \"superseded\" must appear before the stale guard in launch()", - ); - }); -}); diff --git a/apps/desktop/test/agent-monitor-sqlite-contention.test.ts b/apps/desktop/test/agent-monitor-sqlite-contention.test.ts deleted file mode 100644 index c76fba56..00000000 --- a/apps/desktop/test/agent-monitor-sqlite-contention.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import assert from "node:assert/strict"; -import { existsSync, readFileSync } from "node:fs"; -import { test } from "node:test"; - -const read = (relative: string): string => - readFileSync(new URL(relative, import.meta.url), "utf8"); - -const buildScriptSource = read("../scripts/build-agent-monitor.mjs"); -const syncServiceSource = read("../src/main/agent-session-sync-service.ts"); - -const generatedCompatSqliteUrl = new URL( - "../.generated/agent-monitor/server/compat-sqlite.js", - import.meta.url, -); -const generatedCompatSqliteSource = existsSync(generatedCompatSqliteUrl) - ? readFileSync(generatedCompatSqliteUrl, "utf8") - : null; - -const generatedHooksUrl = new URL( - "../.generated/agent-monitor/server/routes/hooks.js", - import.meta.url, -); -const generatedHooksSource = existsSync(generatedHooksUrl) - ? readFileSync(generatedHooksUrl, "utf8") - : null; - -const skipGenerated = generatedCompatSqliteSource === null - ? "Generated agent-monitor tree not built — run `pnpm -C apps/desktop build:agent-monitor` first" - : false; - -// ── Build script: patch functions exist ────────────────────────────────────── - -test("FEA-1363: build script contains patchCompatSqliteBeginImmediate", () => { - assert.match( - buildScriptSource, - /function patchCompatSqliteBeginImmediate\(/, - ); -}); - -test("FEA-1363: build script contains patchHooksTranscriptOutsideTx", () => { - assert.match( - buildScriptSource, - /function patchHooksTranscriptOutsideTx\(/, - ); -}); - -test("FEA-1363: build script contains patchHooksWriteQueueAndWatchdog", () => { - assert.match( - buildScriptSource, - /function patchHooksWriteQueueAndWatchdog\(/, - ); -}); - -test("FEA-1363: build script wires FEA-1363 patches in materializeRuntimeTree", () => { - assert.match(buildScriptSource, /patchCompatSqliteBeginImmediate\(generatedCompatSqlite\)/); - assert.match(buildScriptSource, /patchHooksTranscriptOutsideTx\(generatedHooksRoute\)/); - assert.match(buildScriptSource, /patchHooksWriteQueueAndWatchdog\(generatedHooksRoute\)/); -}); - -test("FEA-1363: build script asserts BEGIN IMMEDIATE in assertGeneratedTree", () => { - assert.match(buildScriptSource, /BEGIN IMMEDIATE.*FEA-1363/); -}); - -test("FEA-1363: build script includes sourceCompatSqlite in stamp hash", () => { - const stampStart = buildScriptSource.indexOf("function currentStamp()"); - const stampEnd = buildScriptSource.indexOf("function materializeRuntimeTree()"); - assert.ok(stampStart > 0, "currentStamp function found"); - assert.ok(stampEnd > stampStart, "materializeRuntimeTree found after currentStamp"); - const stampSection = buildScriptSource.slice(stampStart, stampEnd); - assert.ok( - stampSection.includes("sourceCompatSqlite"), - "sourceCompatSqlite must be in currentStamp() hash inputs", - ); -}); - -// ── Generated compat-sqlite.js ─────────────────────────────────────────────── - -test("FEA-1363: compat-sqlite uses BEGIN IMMEDIATE", { skip: skipGenerated }, () => { - assert.ok(generatedCompatSqliteSource); - assert.match(generatedCompatSqliteSource!, /"BEGIN IMMEDIATE"/); - assert.doesNotMatch( - generatedCompatSqliteSource!, - /db\.exec\("BEGIN"\)/, - "bare BEGIN (deferred) must not remain", - ); -}); - -// ── Generated hooks.js ─────────────────────────────────────────────────────── - -test("FEA-1363: hooks.js extracts processEventCore outside transaction wrapper", { skip: skipGenerated }, () => { - assert.ok(generatedHooksSource); - assert.match(generatedHooksSource!, /function processEventCore\(hookType, data, transcriptData\)/); - assert.match(generatedHooksSource!, /const processEvent = db\.transaction\(processEventCore\)/); -}); - -test("FEA-1363: hooks.js processEvent does not call transcriptCache.extract inside tx", { skip: skipGenerated }, () => { - assert.ok(generatedHooksSource); - const coreStart = generatedHooksSource!.indexOf("function processEventCore("); - const coreEnd = generatedHooksSource!.indexOf("const processEvent = db.transaction(processEventCore)"); - assert.ok(coreStart > 0 && coreEnd > coreStart, "processEventCore bounds found"); - const coreBody = generatedHooksSource!.slice(coreStart, coreEnd); - assert.doesNotMatch( - coreBody, - /transcriptCache\.extract\(/, - "transcriptCache.extract must not appear inside processEventCore", - ); -}); - -test("FEA-1363: hooks.js has write queue with setImmediate batching", { skip: skipGenerated }, () => { - assert.ok(generatedHooksSource); - assert.match(generatedHooksSource!, /hookWriteQueue/); - assert.match(generatedHooksSource!, /drainHookQueue/); - assert.match(generatedHooksSource!, /setImmediate\(drainHookQueue\)/); - assert.match(generatedHooksSource!, /enqueueHookEvent/); -}); - -test("FEA-1363: hooks.js write queue has per-event error isolation via savepoints", { skip: skipGenerated }, () => { - assert.ok(generatedHooksSource); - const drainStart = generatedHooksSource!.indexOf("function drainHookQueue()"); - const drainEnd = generatedHooksSource!.indexOf("router.post("); - assert.ok(drainStart > 0 && drainEnd > drainStart, "drainHookQueue bounds found"); - const drainBody = generatedHooksSource!.slice(drainStart, drainEnd); - assert.match(drainBody, /try\s*\{[\s\S]*?processEventCore/, "per-event try/catch wraps processEventCore"); - assert.match(drainBody, /catch\s*\(err\)/, "catch block exists for per-event isolation"); - assert.match(drainBody, /SAVEPOINT hook_event/, "savepoint created before each event"); - assert.match(drainBody, /RELEASE hook_event/, "savepoint released on success"); - assert.match(drainBody, /ROLLBACK TO hook_event/, "savepoint rolled back on failure"); -}); - -test("FEA-1363: hooks.js write queue has retry backoff with limit", { skip: skipGenerated }, () => { - assert.ok(generatedHooksSource); - const drainStart = generatedHooksSource!.indexOf("function drainHookQueue()"); - const drainEnd = generatedHooksSource!.indexOf("router.post("); - assert.ok(drainStart > 0 && drainEnd > drainStart, "drainHookQueue bounds found"); - const drainBody = generatedHooksSource!.slice(drainStart, drainEnd); - assert.match(drainBody, /MAX_HOOK_DRAIN_RETRIES/, "retry limit constant referenced"); - assert.match(drainBody, /hookDrainRetries/, "retry counter tracked"); - assert.match(drainBody, /setTimeout\(drainHookQueue/, "uses setTimeout for backoff instead of setImmediate"); -}); - -test("FEA-1363: hooks.js POST handler validates session_id before enqueue", { skip: skipGenerated }, () => { - assert.ok(generatedHooksSource); - const postStart = generatedHooksSource!.indexOf('router.post("/event"'); - assert.ok(postStart > 0, "POST handler found"); - // Slice to end-of-handler rather than a fixed window: the FEA-1407 sandbox - // guard is injected ahead of the session_id check and pushes the enqueue - // call past any small fixed offset. indexOf returns first occurrences and - // postStart is past the helper functions, so this stays scoped to the - // POST /event body. - const postBody = generatedHooksSource!.slice(postStart); - const sessionCheck = postBody.indexOf("data.session_id"); - const enqueue = postBody.indexOf("enqueueHookEvent"); - assert.ok(sessionCheck > 0 && enqueue > sessionCheck, "session_id check before enqueue"); -}); - -test("FEA-1363: hooks.js POST handler responds with { ok: true } without event", { skip: skipGenerated }, () => { - assert.ok(generatedHooksSource); - const postStart = generatedHooksSource!.indexOf('router.post("/event"'); - const postBody = generatedHooksSource!.slice(postStart, postStart + 1200); - assert.match(postBody, /res\.json\(\{ ok: true \}\)/); - assert.doesNotMatch(postBody, /res\.json\(\{ ok: true, event:/); -}); - -test("FEA-1363: hooks.js watchdog wraps reads+writes in a transaction", { skip: skipGenerated }, () => { - assert.ok(generatedHooksSource); - const watchdogStart = generatedHooksSource!.indexOf("function watchdogCheck()"); - assert.ok(watchdogStart > 0, "watchdogCheck found"); - const watchdogBody = generatedHooksSource!.slice(watchdogStart, watchdogStart + 5000); - assert.match(watchdogBody, /pendingBroadcasts/, "uses pendingBroadcasts for deferred broadcasts"); - assert.match(watchdogBody, /db\.transaction\(\(\) =>/, "wraps work in db.transaction"); - assert.match(watchdogBody, /for \(const \[event, data\] of pendingBroadcasts\)/, "broadcasts after commit"); -}); - -// ── agent-session-sync-service.ts ──────────────────────────────────────────── - -test("FEA-1363: agent-session-sync-service uses busy_timeout = 5000", () => { - assert.match(syncServiceSource, /busy_timeout = 5000/); - assert.doesNotMatch( - syncServiceSource, - /busy_timeout = 1000/, - "old 1000ms timeout must not remain", - ); -}); diff --git a/apps/desktop/test/agent-monitor-token-reconciliation.test.ts b/apps/desktop/test/agent-monitor-token-reconciliation.test.ts deleted file mode 100644 index bb24ceae..00000000 --- a/apps/desktop/test/agent-monitor-token-reconciliation.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { test } from "node:test"; -import { openAgentDatabase } from "../src/main/database/index.js"; - -function makeDb() { - const dir = mkdtempSync(path.join(tmpdir(), "cl-tokens-")); - const db = openAgentDatabase(path.join(dir, "agent-dashboard.sqlite")); - return { - db, - cleanup() { - db.close(); - rmSync(dir, { recursive: true, force: true }); - }, - }; -} - -const NOW = "2026-06-02T00:00:00.000Z"; - -test("token reconciliation: cumulative growth adds the delta", () => { - const { db, cleanup } = makeDb(); - try { - db.tokenUsage.replace("s1", "m1", { input: 100, output: 50, cacheRead: 10, cacheWrite: 5 }, NOW); - db.tokenUsage.replace("s1", "m1", { input: 150, output: 70, cacheRead: 20, cacheWrite: 5 }, NOW); - const [row] = db.tokenUsage.getBySession("s1"); - assert.equal(row.inputTokens, 150, "effective input follows the cumulative"); - assert.equal(row.outputTokens, 70); - assert.equal(row.cacheReadTokens, 20); - assert.equal(row.cacheWriteTokens, 5); - } finally { - cleanup(); - } -}); - -test("token reconciliation: compaction drop adds the new segment on top of prior effective", () => { - const { db, cleanup } = makeDb(); - try { - // First segment reaches 150 input. - db.tokenUsage.replace("s1", "m1", { input: 150, output: 80, cacheRead: 0, cacheWrite: 0 }, NOW); - // Transcript compacted: cumulative drops to 30 — the prior 150 is already - // counted, so effective becomes 150 + 30 = 180. - db.tokenUsage.replace("s1", "m1", { input: 30, output: 10, cacheRead: 0, cacheWrite: 0 }, NOW); - const [row] = db.tokenUsage.getBySession("s1"); - assert.equal(row.inputTokens, 180, "compaction drop accumulates the new segment"); - assert.equal(row.outputTokens, 90); - } finally { - cleanup(); - } -}); - -test("token reconciliation: tracks each model independently and the read carries no baseline columns", () => { - const { db, cleanup } = makeDb(); - try { - db.tokenUsage.replace("s1", "m1", { input: 100, output: 0, cacheRead: 0, cacheWrite: 0 }, NOW); - db.tokenUsage.replace("s1", "m2", { input: 200, output: 0, cacheRead: 0, cacheWrite: 0 }, NOW); - const rows = db.tokenUsage.getBySession("s1"); - assert.equal(rows.length, 2); - const byModel = Object.fromEntries(rows.map((r) => [r.model, r.inputTokens])); - assert.deepEqual(byModel, { m1: 100, m2: 200 }); - - // The relay/cost-reconciliation read plain columns — assert they are the - // effective totals (no `+ baseline_*` needed at read time). - const raw = db.connection - .prepare("SELECT input_tokens, raw_input FROM token_usage WHERE session_id = ? AND model = ?") - .get("s1", "m1") as { input_tokens: number; raw_input: number }; - assert.equal(raw.input_tokens, 100); - assert.equal(raw.raw_input, 100, "raw_* is the last segment cumulative"); - } finally { - cleanup(); - } -}); - -test("token reconciliation: all-zero counts are a no-op", () => { - const { db, cleanup } = makeDb(); - try { - db.tokenUsage.replace("s1", "m1", { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, NOW); - assert.equal(db.tokenUsage.getBySession("s1").length, 0); - } finally { - cleanup(); - } -}); diff --git a/apps/desktop/test/agent-monitor-wiring-static.test.ts b/apps/desktop/test/agent-monitor-wiring-static.test.ts deleted file mode 100644 index 15ad5547..00000000 --- a/apps/desktop/test/agent-monitor-wiring-static.test.ts +++ /dev/null @@ -1,1137 +0,0 @@ -import assert from "node:assert/strict"; -import { existsSync, readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { test } from "node:test"; - -const read = (relative: string): string => - readFileSync(new URL(relative, import.meta.url), "utf8"); - -const appSource = read("../src/main/app.ts"); -const agentMonitorPathSource = read("../src/main/agent-monitor-path.ts"); -const buildScriptSource = read("../scripts/build-agent-monitor.mjs"); -const generatedDbUrl = new URL("../.generated/agent-monitor/server/db.js", import.meta.url); -const generatedDbSource = existsSync(generatedDbUrl) - ? readFileSync(generatedDbUrl, "utf8") - : null; -const generatedImportHistoryUrl = new URL( - "../.generated/agent-monitor/scripts/import-history.js", - import.meta.url, -); -const generatedImportHistorySource = existsSync(generatedImportHistoryUrl) - ? readFileSync(generatedImportHistoryUrl, "utf8") - : null; -const generatedHooksRouteUrl = new URL( - "../.generated/agent-monitor/server/routes/hooks.js", - import.meta.url, -); -const generatedHooksRouteSource = existsSync(generatedHooksRouteUrl) - ? readFileSync(generatedHooksRouteUrl, "utf8") - : null; -const generatedPricingRouteUrl = new URL( - "../.generated/agent-monitor/server/routes/pricing.js", - import.meta.url, -); -const generatedPricingRouteSource = existsSync(generatedPricingRouteUrl) - ? readFileSync(generatedPricingRouteUrl, "utf8") - : null; -const generatedAnalyticsRouteUrl = new URL( - "../.generated/agent-monitor/server/routes/analytics.js", - import.meta.url, -); -const generatedAnalyticsRouteSource = existsSync(generatedAnalyticsRouteUrl) - ? readFileSync(generatedAnalyticsRouteUrl, "utf8") - : null; -// Resolve the pinned upstream agent-dashboard source the same way the build -// script does (createRequire from apps/desktop/package.json) so we can assert -// the build-script patch anchors still match the source they patch. -const requireFromApp = createRequire(new URL("../package.json", import.meta.url)); -const upstreamImportHistorySource = ((): string => { - const pkgRoot = path.dirname( - requireFromApp.resolve("agent-dashboard/package.json"), - ); - return readFileSync( - path.join(pkgRoot, "scripts", "import-history.js"), - "utf8", - ); -})(); -const plansRouteSource = read("../scripts/agent-monitor-plans/plans-route.js"); -const claudeDocSource = read("../CLAUDE.md"); -const shutdownSource = read("../src/main/shutdown.ts"); -const stagePackagingSource = read("../scripts/stage-packaging-app.mjs"); -const thirdPartyNoticesSource = read("../../../THIRD_PARTY_NOTICES.md"); -const traySource = read("../src/main/tray.ts"); -const preloadSource = read("../src/main/preload-common.ts"); -const sidecarSource = read("../src/main/agent-monitor-sidecar.ts"); -const hooksSource = read("../src/main/agent-monitor-hooks.ts"); -const hooksCoreSource = read("../src/main/agent-monitor-hooks-core.ts"); -const embedAppSource = read("../scripts/agent-monitor-embed/App.tsx"); -const embedLayoutSource = read("../scripts/agent-monitor-embed/Layout.tsx"); -const contractsSource = read("../src/shared/contracts.ts"); -const settingsStoreSource = read("../src/main/settings-store.ts"); -const indexHtml = read("../src/renderer/index.html"); -const electronBuilder = read("../electron-builder.yml"); -const gitignoreSource = read("../../../.gitignore"); -const loadTopSnippet = read( - "../scripts/agent-monitor-codex/client/sessions.loadtop.replace.txt", -); -const loadRowsSnippet = read( - "../scripts/agent-monitor-codex/client/sessions.loadrows.replace.txt", -); -const hostFlagsSource = read( - "../scripts/agent-monitor-plans/client/closedloop-host-flags.ts", -); -const sessionsOverlaySource = read("../scripts/agent-monitor-client/Sessions.tsx"); -const dashboardOverlaySource = read("../scripts/agent-monitor-client/Dashboard.tsx"); -const settingsOverlaySource = read("../scripts/agent-monitor-client/Settings.tsx"); -const statusBadgeOverlaySource = read( - "../scripts/agent-monitor-client/StatusBadge.tsx", -); -const ledgerHelperSource = read( - "../scripts/agent-monitor-client/lib/closedloop-ledger.ts", -); -const desktopPkg = JSON.parse(read("../package.json")) as { - version: string; - scripts: Record; - dependencies: Record; - devDependencies: Record; -}; - -function parseHostAgentNavRoutes(source: string): string[] { - return [...source.matchAll(/kind:\s*"agent",\s*route:\s*"([^"]+)"/g)].map( - (match) => match[1], - ); -} - -function parseEmbeddedMonitorNavRoutes(source: string): string[] { - const routes = ["/"]; - for (const match of source.matchAll(/ { - assert.equal( - desktopPkg.scripts["build:agent-monitor"], - "node scripts/build-agent-monitor.mjs", - ); - assert.match(desktopPkg.scripts.build ?? "", /pnpm build:agent-monitor/); - assert.match( - desktopPkg.scripts.start ?? "", - /pnpm build:agent-monitor/, - ); - assert.equal( - desktopPkg.dependencies["agent-dashboard"], - "github:hoangsonww/Claude-Code-Agent-Monitor#840c518d7fa69231de049e41b893938228b67e40", - ); - assert.equal( - desktopPkg.devDependencies["agent-dashboard-client"], - "github:hoangsonww/Claude-Code-Agent-Monitor#840c518d7fa69231de049e41b893938228b67e40&path:/client", - ); - for (const dep of [ - "@vitejs/plugin-react", - "autoprefixer", - "postcss", - "tailwindcss", - "vite", - ]) { - assert.ok(desktopPkg.devDependencies[dep], `${dep} should be installed for build:agent-monitor`); - } - // Any apps/desktop change requires a version bump (CI-enforced). origin/main is 0.15.25. - assert.notEqual(desktopPkg.version, "0.15.25"); -}); - -test("build script materializes a generated runtime tree with the host patches", () => { - assert.match(buildScriptSource, /SOURCE_ROOT_PACKAGE = "agent-dashboard"/); - assert.match(buildScriptSource, /SOURCE_CLIENT_PACKAGE = "agent-dashboard-client"/); - assert.match(buildScriptSource, /\.generated", "agent-monitor"/); - assert.match(buildScriptSource, /vite build/); - assert.match(buildScriptSource, /CLIENT_FULL_FILE_OVERRIDES/); - assert.match(buildScriptSource, /CLIENT_SNIPPET_FILES/); - assert.match(buildScriptSource, /server\.listen\(port, "127\.0\.0\.1", \(\) => \{/); - assert.match(buildScriptSource, /isAllowedDashboardOrigin/); - assert.match(buildScriptSource, /CCAM_ENABLE_RUN === "1"/); - assert.match(buildScriptSource, /CCAM_AUTO_INSTALL_HOOKS === "1"/); - assert.match(buildScriptSource, /Database = require\("\.\/compat-sqlite"\);/); - assert.match(buildScriptSource, /function patchHooksRoute/); - assert.match(buildScriptSource, /function patchHooksSandboxFilter/); - assert.match(buildScriptSource, /function patchImportHistorySandboxFilter/); - assert.match(buildScriptSource, /FEA-1407 sandbox scoping/); - assert.match(buildScriptSource, /extractPlanFromHookEvent/); - assert.match(buildScriptSource, /upsertPlanCapture\(db, capture\)/); - assert.match(buildScriptSource, /req\.query\.harness/); - assert.match(buildScriptSource, /CCAM_VAPID_KEYS_PATH/); - assert.match(buildScriptSource, /closedloop-host-flags\.ts/); - assert.match(buildScriptSource, /isPlanExtractionEnabled/); - assert.match(buildScriptSource, /module\.exports = \{ uninstallHooks \};/); - // Watcher shutdown cleanup must be patched into the sidecar shutdown handler - assert.match(buildScriptSource, /stopCodexWatcher/); - assert.match(buildScriptSource, /stopCursorWatcher/); - assert.match(buildScriptSource, /stopCopilotWatcher/); - assert.match(buildScriptSource, /stopOpenCodeWatcher/); - assert.match(buildScriptSource, /stopCcWatcher/); - assert.match(buildScriptSource, /agent-monitor-client/); - assert.match(buildScriptSource, /StatusBadge\.tsx/); - assert.match(buildScriptSource, /Sessions\.tsx/); -}); - -// Regression for the FEA-1407 clean-build failure: the sandbox-filter patch -// anchored on the old inline `path.join(os.homedir(), ".claude", "projects")` -// form of PROJECTS_DIR, but the pinned upstream derives it via getProjectsDir(). -// The mismatch threw "expected PROJECTS_DIR anchor" on every clean build -// (cleared .generated, fresh clone, CI). Assert the patch anchor still matches -// the source it patches — not merely that the build script mentions the patch. -test("patchImportHistorySandboxFilter anchor matches the pinned upstream import-history", () => { - const fnMatch = buildScriptSource.match( - /function patchImportHistorySandboxFilter[\s\S]*?const requireAnchor = (["'])((?:\\.|(?!\1).)*)\1;/, - ); - assert.ok( - fnMatch, - "expected a requireAnchor string literal in patchImportHistorySandboxFilter", - ); - const anchor = fnMatch[2]; - assert.ok( - upstreamImportHistorySource.includes(anchor), - `patchImportHistorySandboxFilter anchor ${JSON.stringify(anchor)} is not present in the pinned upstream import-history.js — a clean build would throw. Update the anchor to match upstream.`, - ); -}); - -// The build script hard-throws if a patch anchor is missing, but that only -// fires on a clean materialize. Assert the generated tree actually carries the -// applied FEA-1407 sandbox guard (helper + importSession guard), not just that -// the build script defines the patch function. -test("generated import-history applies the FEA-1407 sandbox guard", () => { - if (generatedImportHistorySource === null) return; - assert.match(generatedImportHistorySource, /FEA-1407 sandbox scoping/); - assert.match( - generatedImportHistorySource, - /function isSessionInSandbox\(cwd, sandboxBase\)/, - ); - assert.match( - generatedImportHistorySource, - /if \(!isSessionInSandbox\(session\.cwd, process\.env\.SANDBOX_BASE_DIRECTORY\)\)/, - ); -}); - -test("session overview token totals include compaction baselines", () => { - assert.match( - buildScriptSource, - /COALESCE\(SUM\(input_tokens \+ baseline_input\), 0\) as input_tokens/, - ); - if (generatedDbSource !== null) { - assert.match( - generatedDbSource, - /COALESCE\(SUM\(input_tokens \+ baseline_input\), 0\) as input_tokens/, - ); - assert.match( - generatedDbSource, - /COALESCE\(SUM\(cache_write_tokens \+ baseline_cache_write\), 0\) as cache_write_tokens/, - ); - } -}); - -test("re-import metadata refresh is not gated only on message-count changes", () => { - assert.match(buildScriptSource, /function patchImportHistoryMetadataRefresh/); - assert.match(buildScriptSource, /CLOSEDLOOP metadata refresh parity/); - assert.match(buildScriptSource, /const nextEntryPoint = session\.entrypoint \|\| meta\.entrypoint \|\| null;/); - assert.match(buildScriptSource, /const nextPermissionMode = session\.permissionMode \|\| meta\.permission_mode \|\| null;/); - assert.match(buildScriptSource, /JSON\.stringify\(meta\.usage_extras \|\| null\) !== JSON\.stringify\(nextUsageExtras\)/); - if (generatedImportHistorySource !== null) { - assert.match(generatedImportHistorySource, /CLOSEDLOOP metadata refresh parity/); - assert.match(generatedImportHistorySource, /meta\.entrypoint !== nextEntryPoint/); - assert.match(generatedImportHistorySource, /\(meta\.turn_count \|\| 0\) !== nextTurnCount/); - } -}); - -test("electron-builder ships the generated agent-monitor runtime tree unpacked", () => { - assert.match( - electronBuilder, - /from:\s*\.generated\/agent-monitor[\s\S]*to:\s*agent-monitor/, - ); - // Must ship client/dist (built), not client source. - assert.match(electronBuilder, /client\/dist\/\*\*\/\*/); - assert.doesNotMatch(electronBuilder, /node_modules\/\*\*\/\*/); - assert.match(stagePackagingSource, /node_modules", "better-sqlite3"/); - assert.match( - stagePackagingSource, - /dependency\.resolved[\s\S]*packageJson\.dependencies\?\.\[dependencyName\][\s\S]*dependency\.version/, - ); - assert.match(stagePackagingSource, /\.generated", "agent-monitor"/); - assert.match( - stagePackagingSource, - /await cp\(generatedAgentMonitorDir, stageGeneratedAgentMonitorDir, \{\s*recursive: true,\s*\}\);/, - ); -}); - -test("runtime resolves the generated tree and sidecar wiring still uses the fixed port", () => { - assert.match(agentMonitorPathSource, /\.generated", "agent-monitor"/); - assert.doesNotMatch(agentMonitorPathSource, /vendor\/agent-monitor/); - assert.match(agentMonitorPathSource, /gatewayLog\.warn/); - assert.match(contractsSource, /export const AGENT_MONITOR_PORT = 4820/); - assert.match(sidecarSource, /AGENT_MONITOR_PORT/); - // Fixed port: must NOT pick a free port like the gateway sidecar did. - assert.doesNotMatch(sidecarSource, /pickPort|freePort/); - // Spawn the server entry with no CLI port/host flags (server reads env). - assert.match(sidecarSource, /spawn\(process\.execPath,\s*\[entryFile\]/); - assert.match(sidecarSource, /ELECTRON_RUN_AS_NODE:\s*"1"/); - assert.match(sidecarSource, /DASHBOARD_PORT:\s*String\(this\.port\)/); - assert.match(sidecarSource, /DASHBOARD_DB_PATH/); - assert.match(sidecarSource, /CCAM_VAPID_KEYS_PATH/); - assert.match(sidecarSource, /CCAM_ENABLE_RUN:\s*"0"/); - assert.match(sidecarSource, /CCAM_AUTO_INSTALL_HOOKS:\s*"0"/); - assert.match(sidecarSource, /SANDBOX_BASE_DIRECTORY/); - assert.match(sidecarSource, /setSandboxBaseDirectory/); - assert.match(sidecarSource, /NODE_PATH/); - assert.match(sidecarSource, /resolveRuntimeSupportNodePaths\("agent-dashboard"\)/); - assert.match(sidecarSource, /path\.dirname\(packageRoot\)/); - assert.match(sidecarSource, /process\.resourcesPath,\s*"app\.asar",\s*"app",\s*"node_modules"/); - assert.match(sidecarSource, /const healthy = await this\.waitForHealth\(child\);/); - assert.match(sidecarSource, /\/api\/health/); - assert.doesNotMatch(sidecarSource, /spawnSync\(\s*"lsof"/); - assert.doesNotMatch(sidecarSource, /spawnSync\(\s*"ps"/); - assert.match( - sidecarSource, - /async stop\(\): Promise \{[\s\S]*this\.started = false;[\s\S]*this\.stopping = true;[\s\S]*this\.restartAttempts = 0;[\s\S]*this\.stopping = false;/, - ); - assert.match(sidecarSource, /const shouldRestart = this\.started && !this\.stopping;/); - assert.match(buildScriptSource, /function patchWebSocketFile/); - assert.match(buildScriptSource, /updateScheduler = startUpdateScheduler\(\{ broadcast \}\);/); - assert.match(buildScriptSource, /catalogFetchTimer = require\("\.\/lib\/catalog-fetcher"\)\.scheduleCatalogFetch\(dbModule\.db\);/); - assert.match(buildScriptSource, /require\("\.\/websocket"\)\.closeWebSocket\(\);/); - assert.match(buildScriptSource, /httpServer\.closeAllConnections\(\)/); - assert.match(buildScriptSource, /httpServer\.__closedloopDestroyConnections\(\)/); -}); - -// FEA-1403: when port 4820 is held by a foreign process (orphaned dev sidecar, -// stale standalone build, etc.), /api/health answers 200 OK before OUR -// just-spawned child has even hit listen(). Readiness must be scoped to the -// child we spawned — not to "anyone on the port" — otherwise the supervisor's -// restartAttempts=0 reset fires every cycle and the documented 5-attempt cap -// is never reached. The supervisor loops forever at "attempt 1/5". -test("FEA-1403: agent monitor readiness is scoped to the spawned child, not to any process on the port", () => { - // The stability window must outlast the observed EADDRINUSE crash latency. - // Live testing on a dev build with port 4820 held by a foreign process - // showed the child reaching listen() (and crashing) up to ~2.5s after - // spawn — slower than the original ~300ms estimate, because SQLite init + - // migrations + Express boot run before listen(). Parse the constant - // numerically so a future change shortening it below the safety margin - // fails this test. - const stabilityMatch = sidecarSource.match( - /const READY_STABILITY_WINDOW_MS = ([\d_]+)/, - ); - assert.ok( - stabilityMatch, - "READY_STABILITY_WINDOW_MS constant must be defined in agent-monitor-sidecar.ts", - ); - const stabilityMs = Number(stabilityMatch[1].replaceAll("_", "")); - assert.ok( - stabilityMs >= 3_000, - `READY_STABILITY_WINDOW_MS must be >= 3000ms to outlast the observed ~2500ms EADDRINUSE crash window, got ${stabilityMs}ms`, - ); - - // waitForHealth takes the spawned child as a parameter so it can verify - // identity, not just the port answering. - assert.match( - sidecarSource, - /private async waitForHealth\(child: ChildProcess\): Promise/, - ); - - // Single source of truth for the identity-and-alive predicate. Three - // call sites share this guard (waitForHealth poll, post-health gate, - // post-stability gate); keeping them in one method means a future change - // cannot quietly drop half the check at one site. - assert.match( - sidecarSource, - /private isChildAliveAndCurrent\(child: ChildProcess\): boolean \{\s*return this\.child === child && child\.exitCode === null;\s*\}/, - ); - - // waitForHealth bails when our child is no longer the active one or has - // already exited — a 200 OK from a foreign process must NOT be credited. - assert.match( - sidecarSource, - /this\.stopping[\s\S]{0,100}!this\.isChildAliveAndCurrent\(child\)/, - ); - - // The "agent monitor ready" log + restartAttempts = 0 reset only fire - // after the stability window AND after re-verifying our child is still - // the active live one via the shared predicate. The reset is GUARDED — - // not unconditional. - assert.match( - sidecarSource, - /await delay\(READY_STABILITY_WINDOW_MS\);[\s\S]{0,400}this\.isChildAliveAndCurrent\(child\)[\s\S]{0,400}this\.restartAttempts = 0;/, - ); - - // Guard: there must NOT be an ungated `restartAttempts = 0` immediately - // following `await this.waitForHealth(...)` — that was the original bug. - // The post-waitForHealth success path must check child identity first. - assert.doesNotMatch( - sidecarSource, - /const healthy = await this\.waitForHealth\(child\);\s*if \(healthy\) \{\s*this\.restartAttempts = 0;/, - ); -}); - -test("docs and ignores describe generated pnpm-managed inputs, not vendor source", () => { - assert.match(gitignoreSource, /apps\/desktop\/\.generated\//); - assert.doesNotMatch(gitignoreSource, /vendor\/agent-monitor/); - - assert.match(thirdPartyNoticesSource, /Claude-Code-Agent-Monitor/); - assert.match(thirdPartyNoticesSource, /pinned in\s+`apps\/desktop\/package\.json`/); - assert.match(thirdPartyNoticesSource, /MIT License/); - assert.match(thirdPartyNoticesSource, /Son Nguyen/); - assert.doesNotMatch(thirdPartyNoticesSource, /vendor\/agent-monitor/); - - assert.match(claudeDocSource, /pnpm-managed\s+upstream packages/); - assert.match(claudeDocSource, /\.generated\/agent-monitor/); - assert.doesNotMatch(claudeDocSource, /vendor\/agent-monitor/); -}); - -test("agent monitor defaults on; plan extraction is feature-gated and defaults off in desktop settings", () => { - assert.match(contractsSource, /agentMonitorEnabled: boolean/); - // The Agent Dashboard now powers the primary Dashboard + agent nav, so the - // sidecar defaults ON (it can still be turned off in Settings). - assert.match(contractsSource, /agentMonitorEnabled: true/); - assert.match(contractsSource, /agentDashboardDesignSystemEnabled: boolean/); - assert.match(contractsSource, /agentDashboardDesignSystemEnabled: false/); - assert.match(contractsSource, /planExtractionEnabled: boolean/); - assert.match(contractsSource, /planExtractionEnabled: false/); - assert.match(settingsStoreSource, /getAgentMonitorEnabled\(\)/); - assert.match(settingsStoreSource, /setAgentMonitorEnabled\(agentMonitorEnabled: boolean\)/); - assert.match(settingsStoreSource, /getPlanExtractionEnabled\(\)/); - assert.match(settingsStoreSource, /setPlanExtractionEnabled\(planExtractionEnabled: boolean\)/); - // update() handles all registered flags generically via FLAG_KEYS loop - assert.match( - settingsStoreSource, - /for \(const key of FLAG_KEYS\)/, - ); -}); - -test("sidecar is feature-gated and, when enabled, starts before the gateway", () => { - assert.match( - appSource, - /this\.agentDashboardMode === "legacy"[\s\S]*new AgentMonitorSidecar/, - ); - assert.match( - appSource, - /private async startAgentCapture\(\): Promise \{[\s\S]*this\.agentDashboardMode === "legacy"[\s\S]*void this\.agentMonitor\?\.start\(\);[\s\S]*syncAgentMonitorHooksOnBoot\(\);[\s\S]*this\.agentSessionSync\.start\(\);/, - ); - const startIdx = appSource.indexOf("await this.startAgentCapture()"); - const gatewayTryIdx = appSource.indexOf("await this.server.start()"); - assert.ok(startIdx > 0, "agent capture start call missing"); - assert.ok(gatewayTryIdx > 0, "server.start call missing"); - assert.ok( - startIdx < gatewayTryIdx, - "sidecar must start before the gateway try-block", - ); -}); - -test("agent session sync starts and stops with the agent monitor flag", () => { - assert.match( - appSource, - /private async applyAgentMonitorSetting\(enabled: boolean\): Promise \{[\s\S]*if \(enabled\) \{[\s\S]*await this\.startAgentCapture\(\);[\s\S]*return;[\s\S]*await this\.stopAgentCapture\(\{ closeDesignSystem: true \}\);/, - ); - assert.match( - appSource, - /private async stopAgentCapture\(\s*options: \{ closeDesignSystem\?: boolean \} = \{\},\s*\): Promise \{[\s\S]*await this\.agentMonitor\?\.stop\(\);[\s\S]*this\.agentSessionSync\.stop\(\);/, - ); - assert.match( - appSource, - /options\.closeDesignSystem[\s\S]*this\.agentDashboardDesignSystem\.close\(\);[\s\S]*this\.agentDashboardDesignSystem = null;/, - ); -}); - -test("sidecar URL + hooks toggle exposed via IPC + preload", () => { - assert.match( - appSource, - /ipcMain\.handle\("desktop:get-agent-monitor-url",[\s\S]*url: this\.getAgentMonitorUrl\(\)[\s\S]*ready: this\.isAgentMonitorReady\(\)[\s\S]*enabled: this\.isAgentMonitorEnabled\(\)[\s\S]*planExtractionEnabled: this\.isPlanExtractionEnabled\(\)/, - ); - assert.match(appSource, /this\.agentMonitor\?\.getUrl\(\)/); - assert.match(appSource, /this\.agentMonitor\?\.isReady\(\)/); - assert.match( - appSource, - /ipcMain\.handle\(\s*"desktop:set-agent-monitor-hooks-enabled"[\s\S]*Agent Dashboard is disabled in Settings\.[\s\S]*setAgentMonitorHooksEnabled/, - ); - assert.match( - preloadSource, - /getAgentMonitorUrl: \(\) =>[\s\S]*planExtractionEnabled: boolean;/, - ); - assert.match( - preloadSource, - /setAgentMonitorHooksEnabled:[\s\S]*ipcRenderer\.invoke\(\s*"desktop:set-agent-monitor-hooks-enabled"/, - ); -}); - -test("openClaudeDashboard redirects to settings when disabled and tray access is gated", () => { - assert.match( - appSource, - /openClaudeDashboard\(\): void \{[\s\S]*this\.desktopWindow\.show\(\);[\s\S]*if \(!this\.isAgentMonitorEnabled\(\)\) \{[\s\S]*"desktop:navigate-tab", "settings"[\s\S]*"desktop:navigate-settings-tab", "relay-gateway"[\s\S]*return;[\s\S]*this\.agentDashboardMode === "design-system"[\s\S]*\? "dashboard"[\s\S]*: "claude-dashboard"/, - ); - assert.match( - appSource, - /onOpenClaudeDashboard: \(\) => this\.openClaudeDashboard\(\)/, - ); - assert.match( - appSource, - /ipcMain\.handle\("desktop:open-agent-monitor",[\s\S]*this\.openClaudeDashboard\(\)/, - ); - assert.match(traySource, /onOpenClaudeDashboard\?: \(\) => void/); - assert.match(traySource, /setAgentMonitorEnabled\(enabled: boolean\)/); - assert.match(traySource, /this\.agentMonitorEnabled/); - assert.match(traySource, /label: "Open Agent Dashboard"/); -}); - -test("hooks are opt-in: default off, silent server auto-install never enabled", () => { - // The host never sets CCAM_AUTO_INSTALL_HOOKS=1; it manages hooks directly. - assert.doesNotMatch(sidecarSource, /CCAM_AUTO_INSTALL_HOOKS:\s*"1"/); - assert.match(hooksSource, /store\(\)\.get\("enabled", false\)/); - assert.match(hooksCoreSource, /ELECTRON_RUN_AS_NODE=1/); - assert.match(hooksCoreSource, /JSON\.stringify\(hookType\)/); - assert.match(hooksCoreSource, /renameSync/); - assert.match(hooksSource, /function uninstallHooks/); - assert.match(appSource, /syncAgentMonitorHooksOnBoot\(\)/); -}); - -test("agent monitor terminal failure sets a tracked degraded state that refreshTrayState consults", () => { - // The one-shot tray.setState in onTerminalFailure was being stomped by the - // next refreshTrayState() call (cloud heartbeat / gateway recheck), which - // only branched on gatewayHealthy / cloudCommandsPaused / cloudStatus. The - // degraded indicator must instead be backed by a tracked field so it sticks. - // (PR #247 review — thadeusb.) - - // 1. A tracked field exists. - assert.match(appSource, /private agentMonitorFailed = false;/); - - // 2. onTerminalFailure latches the field and routes through refreshTrayState() - // rather than calling tray.setState directly (which would be transient). - assert.match( - appSource, - /onTerminalFailure: \(reason: string\) => \{[\s\S]*this\.agentMonitorFailed = true;[\s\S]*this\.refreshTrayState\(\);[\s\S]*\},/, - ); - - // 3. refreshTrayState() actually consults the field (degraded state is owned - // by the single state owner, not set out-of-band). - assert.match( - appSource, - /private refreshTrayState\([\s\S]*if \(this\.agentMonitorFailed\) \{[\s\S]*this\.tray\.setState\(\s*"degraded"/, - ); - - // 4. The degraded-monitor branch outranks cloud state: it must appear before - // the cloudStatus "online" branch so an online cloud cannot reset the tray - // to ready while the monitor is dead. - const failedBranchIdx = appSource.indexOf("if (this.agentMonitorFailed)"); - const cloudOnlineBranchIdx = appSource.indexOf( - 'if (this.cloudStatus.state === "online")', - ); - assert.ok(failedBranchIdx > 0, "agentMonitorFailed branch not found in refreshTrayState"); - assert.ok(cloudOnlineBranchIdx > 0, "cloud online branch not found in refreshTrayState"); - assert.ok( - failedBranchIdx < cloudOnlineBranchIdx, - "agentMonitorFailed branch must precede the cloud-online branch so the degraded indicator is not overwritten", - ); -}); - -test("shutdown sequence stops the sidecar before the server", () => { - assert.match(shutdownSource, /agentMonitor: \{ stop:/); - assert.match( - shutdownSource, - /runPhase\("agentMonitor\.stop"[\s\S]*deps\.agentMonitor\.stop\(\)/, - ); - const amIdx = shutdownSource.indexOf('runPhase("agentMonitor.stop"'); - const srvIdx = shutdownSource.indexOf('runPhase("server.stop"'); - assert.ok(amIdx > 0 && srvIdx > 0 && amIdx < srvIdx, "agentMonitor.stop must precede server.stop"); -}); - -test("renderer wires the Agent Dashboard sidecar into the sidebar and gates it on the setting", () => { - // Agent nav items live in the left sidebar; hidden when the sidecar is off. - assert.match(indexHtml, /