From 4df5c62854088f9dc21c59412f235f74c6fe17e6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 14:08:43 -0700 Subject: [PATCH] FN-8133: add local codebase context metrics Expose local codebase token estimates and apparent disk size in the project dashboard. - Add a bounded, cached, symlink-safe codebase metrics service and project API. - Display token and disk indicators across Command Center overview states. - Add formatting, route, UI, and metric calibration coverage with a minor changeset. Files changed: .changeset/fn-8133-codebase-metrics.md | 7 + docs/dashboard-guide.md | 2 +- .../dashboard/app/__tests__/api-projects.test.ts | 12 ++ packages/dashboard/app/api/legacy.ts | 14 ++ .../components/command-center/CommandCenter.css | 5 + .../components/command-center/CommandCenter.tsx | 65 +++++++-- .../CommandCenter.mobile-chart-layout.test.ts | 4 + .../__tests__/CommandCenter.test.tsx | 10 ++ .../__tests__/SystemStatsArea.test.tsx | 8 + .../command-center/areas/SystemStatsArea.tsx | 8 +- .../app/utils/__tests__/formatBytes.test.ts | 18 +++ packages/dashboard/app/utils/formatBytes.ts | 11 ++ .../src/lib/__tests__/codebase-metrics.test.ts | 62 ++++++++ .../lib/__tests__/fixtures/token-corpus/README.md | 17 +++ .../fixtures/token-corpus/calibration/component.ts | 1 + .../fixtures/token-corpus/calibration/config.json | 1 + .../lib/__tests__/fixtures/token-corpus/example.ts | 1 + .../lib/__tests__/fixtures/token-corpus/notes.md | 3 + .../fixtures/token-corpus/reference-counts.json | 9 ++ .../__tests__/fixtures/token-corpus/settings.json | 1 + .../lib/__tests__/fixtures/token-corpus/view.tsx | 1 + packages/dashboard/src/lib/codebase-metrics.ts | 161 +++++++++++++++++++++ .../__tests__/codebase-metrics-route.test.ts | 34 +++++ .../src/routes/register-project-routes.ts | 19 +++ 24 files changed, 456 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-8133 Fusion-Task-Lineage: d591636b-d4b8-469e-8788-c4516eeb8405 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8133-codebase-metrics.md | 7 + docs/dashboard-guide.md | 2 +- .../app/__tests__/api-projects.test.ts | 12 ++ packages/dashboard/app/api/legacy.ts | 14 ++ .../command-center/CommandCenter.css | 5 + .../command-center/CommandCenter.tsx | 65 +++++-- .../CommandCenter.mobile-chart-layout.test.ts | 4 + .../__tests__/CommandCenter.test.tsx | 10 ++ .../__tests__/SystemStatsArea.test.tsx | 8 + .../command-center/areas/SystemStatsArea.tsx | 8 +- .../app/utils/__tests__/formatBytes.test.ts | 18 ++ packages/dashboard/app/utils/formatBytes.ts | 11 ++ .../lib/__tests__/codebase-metrics.test.ts | 62 +++++++ .../__tests__/fixtures/token-corpus/README.md | 17 ++ .../token-corpus/calibration/component.ts | 1 + .../token-corpus/calibration/config.json | 1 + .../fixtures/token-corpus/example.ts | 1 + .../__tests__/fixtures/token-corpus/notes.md | 3 + .../token-corpus/reference-counts.json | 9 + .../fixtures/token-corpus/settings.json | 1 + .../__tests__/fixtures/token-corpus/view.tsx | 1 + .../dashboard/src/lib/codebase-metrics.ts | 161 ++++++++++++++++++ .../__tests__/codebase-metrics-route.test.ts | 34 ++++ .../src/routes/register-project-routes.ts | 19 +++ 24 files changed, 456 insertions(+), 18 deletions(-) create mode 100644 .changeset/fn-8133-codebase-metrics.md create mode 100644 packages/dashboard/app/utils/__tests__/formatBytes.test.ts create mode 100644 packages/dashboard/app/utils/formatBytes.ts create mode 100644 packages/dashboard/src/lib/__tests__/codebase-metrics.test.ts create mode 100644 packages/dashboard/src/lib/__tests__/fixtures/token-corpus/README.md create mode 100644 packages/dashboard/src/lib/__tests__/fixtures/token-corpus/calibration/component.ts create mode 100644 packages/dashboard/src/lib/__tests__/fixtures/token-corpus/calibration/config.json create mode 100644 packages/dashboard/src/lib/__tests__/fixtures/token-corpus/example.ts create mode 100644 packages/dashboard/src/lib/__tests__/fixtures/token-corpus/notes.md create mode 100644 packages/dashboard/src/lib/__tests__/fixtures/token-corpus/reference-counts.json create mode 100644 packages/dashboard/src/lib/__tests__/fixtures/token-corpus/settings.json create mode 100644 packages/dashboard/src/lib/__tests__/fixtures/token-corpus/view.tsx create mode 100644 packages/dashboard/src/lib/codebase-metrics.ts create mode 100644 packages/dashboard/src/routes/__tests__/codebase-metrics-route.test.ts diff --git a/.changeset/fn-8133-codebase-metrics.md b/.changeset/fn-8133-codebase-metrics.md new file mode 100644 index 0000000000..460519e4f3 --- /dev/null +++ b/.changeset/fn-8133-codebase-metrics.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Show a local codebase token estimate and on-disk size on the project Dashboard Overview. +category: feature +dev: New GET /api/projects/:id/codebase-metrics; calibrated cl100k_base pre-tokenization estimator with separate bounded source/disk domains, symlink-safe local traversal, two-minute caching, and granular shared B/KB/MB/GB formatting. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0152372d32..f2b950f66c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1168,7 +1168,7 @@ Features: - **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, the shared Global Max Concurrent slider backed by `/api/global-concurrency`, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. The four concurrency sliders ask for confirmation after a changed value settles; confirming persists the new cap, while cancel, backdrop, or Escape dismissal reverts to the last persisted value without saving. The global and current-project max-concurrent sliders show running-agent counts plus a current-use dot on the track once utilization data loads; triage and worktree sliders remain cap-only. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner. -- **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview's active-agent and daily activity values count durable-agent usage events plus ephemeral task-worker execution runs, including graph-owned workflow step sessions that publish `agentRuns` lifecycle rows, with the same agent counted once per day/range if both sources record activity. Overview includes a graph-rich software-factory snapshot with the existing top-model-consumers bar, tool-category bar, top-model token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. +- **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. **Codebase tokens** is a local-only `cl100k_base`-calibrated pre-tokenization estimate; it does not upload source content or add a runtime tokenizer. **Disk size** is local apparent size (regular files and symlink entries only, not allocated blocks or directory bookkeeping). Both project-intrinsic cards render even before agent usage settles and show `Approx. (partial)` when bounded traversal is incomplete. Git projects scan non-ignored tracked files; non-git fallback excludes only directories whose basename starts with `.` or is exactly `node_modules`, `dist`, or `build`. Source candidates are lstat-gated regular files (symlinks and out-of-root candidates are skipped); disk walking does not follow symlinks. Defaults cap source scanning at 50,000 entries, 4 seconds, 64 MiB total, and 2 MiB/file, disk walking at 500,000 entries and 4 seconds, and cache results for two minutes. System telemetry now formats small byte quantities granularly as B/KB rather than displaying sub-MB values as 0 MB. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview's active-agent and daily activity values count durable-agent usage events plus ephemeral task-worker execution runs, including graph-owned workflow step sessions that publish `agentRuns` lifecycle rows, with the same agent counted once per day/range if both sources record activity. Overview includes a graph-rich software-factory snapshot with the existing top-model-consumers bar, tool-category bar, top-model token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. - **Tokens** breaks down token totals, estimated cost, task count, chat-turn count, and per-model usage. Per-model and per-provider breakdowns include task execution tokens plus supported dashboard chat, task-detail planner chat, and room responder turns when their runtime exposes authoritative session token stats; CLI-backed chat and title generation are excluded until those paths expose reliable stats. Task counts remain task-only while chat turns are counted separately, so task detail token panels stay execution-scoped and planner chat does not double-count the task it discusses. Per-model and per-provider breakdowns use the task/chat analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. Estimated cost is derived at read time from recorded token counts multiplied by the effective per-model pricing table: Settings → Global Models pricing overrides win first, then the built-in fallback table is used. It is not persisted, so historical rows stay tied to current maintained prices instead of stale stored billing truth. The Tokens area shows a **prices as of** date/source for the effective table, marks pricing older than the staleness threshold as low-confidence, and shows cost unavailable for models with no pricing entry rather than guessing a price. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, a full token-by-model bar, and a token-share pie backed by every grouped model returned by token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users. diff --git a/packages/dashboard/app/__tests__/api-projects.test.ts b/packages/dashboard/app/__tests__/api-projects.test.ts index 7d8be6c205..0ee3d46409 100644 --- a/packages/dashboard/app/__tests__/api-projects.test.ts +++ b/packages/dashboard/app/__tests__/api-projects.test.ts @@ -52,6 +52,7 @@ import { registerProject, unregisterProject, fetchProjectHealth, + fetchCodebaseMetrics, fetchProjectPathMappings, fetchProjectPathMapping, upsertProjectPathMapping, @@ -834,6 +835,17 @@ describe("fetchProjectHealth", () => { }); }); +describe("fetchCodebaseMetrics", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + it("uses the project-scoped metrics URL and returns its response", async () => { + globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { tokenEstimate: 42, sourceFileCount: 1, sourceByteCount: 20, diskBytes: 30, diskFileCount: 2, method: "local", truncated: false })); + await expect(fetchCodebaseMetrics("project/a")).resolves.toMatchObject({ tokenEstimate: 42, diskFileCount: 2 }); + expect(globalThis.fetch).toHaveBeenCalledWith("/api/projects/project%2Fa/codebase-metrics", expect.any(Object)); + }); +}); + describe("fetchActivityFeed", () => { const originalFetch = globalThis.fetch; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 98686ac745..1993ab94f3 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -6864,6 +6864,16 @@ export interface ProjectInfo { } /** Project health metrics */ +export interface CodebaseMetrics { + tokenEstimate: number; + sourceFileCount: number; + sourceByteCount: number; + diskBytes: number; + diskFileCount: number; + method: string; + truncated: boolean; +} + export interface ProjectHealth { projectId: string; status: "active" | "paused" | "errored" | "initializing"; @@ -7501,6 +7511,10 @@ export function fetchProjectHealth(id: string): Promise { return api(`/projects/${encodeURIComponent(id)}/health`); } +export function fetchCodebaseMetrics(id: string): Promise { + return api(`/projects/${encodeURIComponent(id)}/codebase-metrics`); +} + /** Fetch executor statistics for the status bar. * * Returns settings-based values and lastActivityAt. diff --git a/packages/dashboard/app/components/command-center/CommandCenter.css b/packages/dashboard/app/components/command-center/CommandCenter.css index 4f7674b956..2cc382fda5 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.css +++ b/packages/dashboard/app/components/command-center/CommandCenter.css @@ -571,6 +571,11 @@ The Command Center subtree previously had no tablet tier, so at 769px–1024px t } @media (max-width: 768px) { + .cc-stat-grid { + min-inline-size: 0; + grid-template-columns: minmax(0, 1fr); + } + .cc-live-strip { grid-template-columns: 1fr; } diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index c346819f35..23653cbf04 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, Gauge } from "lucide-react"; import type { ActivityAnalytics, ColorTheme, LiveSnapshot, SignalsAnalytics, ThemeMode, TokenAnalytics, ToolAnalytics } from "@fusion/core"; -import { api, withProjectId } from "../../api/legacy"; +import { api, fetchCodebaseMetrics, withProjectId, type CodebaseMetrics } from "../../api/legacy"; +import { formatBytes } from "../../utils/formatBytes"; import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker"; import { LoadingSpinner } from "../LoadingSpinner"; import { TokensArea } from "./areas/TokensArea"; @@ -102,6 +103,7 @@ interface OverviewStatCard { label: string; value: string; subLabel?: string; + testId?: string; } /* @@ -160,6 +162,7 @@ function OverviewTab({ const signals = useAnalyticsArea("/command-center/signals", range, { projectId }); const [liveSnapshot, setLiveSnapshot] = useState(null); const [liveSnapshotLoading, setLiveSnapshotLoading] = useState(true); + const [codebaseMetrics, setCodebaseMetrics] = useState(null); useEffect(() => { let cancelled = false; @@ -185,6 +188,17 @@ function OverviewTab({ }; }, [projectId]); + useEffect(() => { + let cancelled = false; + setCodebaseMetrics(null); + if (!projectId) return () => { cancelled = true; }; + void fetchCodebaseMetrics(projectId).then( + (result) => { if (!cancelled) setCodebaseMetrics(result); }, + () => { if (!cancelled) setCodebaseMetrics(null); }, + ); + return () => { cancelled = true; }; + }, [projectId]); + const tokenTotal = tokens.data?.totals?.totalTokens ?? 0; const toolCalls = tools.data?.toolCalls ?? 0; const activeNodes = activity.data?.activeNodes ?? 0; @@ -267,6 +281,42 @@ function OverviewTab({ : `${tools.data.autonomyRatio.toFixed(1)}:1` : "—"; + /* + FNXC:CommandCenter 2026-07-16-10:45: + Codebase tokens and Disk size are project-intrinsic local metrics, not agent-usage analytics. Keep their cards in loading, core-error, empty, and populated Overview branches so a new project can inspect its context and apparent footprint without sending code to a service. + */ + const projectMetricCards: OverviewStatCard[] = [ + { + id: "codebaseTokens", + testId: "cc-overview-codebase-tokens", + label: t("commandCenter.overview.codebaseTokens", "Codebase tokens"), + value: codebaseMetrics ? formatCount(codebaseMetrics.tokenEstimate) : "—", + subLabel: codebaseMetrics?.truncated + ? t("commandCenter.overview.metricsPartial", "Approx. (partial)") + : codebaseMetrics ? t("commandCenter.overview.codebaseTokensHint", "Local estimate calibrated to cl100k_base") : undefined, + }, + { + id: "diskSize", + testId: "cc-overview-disk-size", + label: t("commandCenter.overview.diskSize", "Disk size"), + value: codebaseMetrics ? formatBytes(codebaseMetrics.diskBytes) : "—", + subLabel: codebaseMetrics?.truncated + ? t("commandCenter.overview.metricsPartial", "Approx. (partial)") + : codebaseMetrics ? t("commandCenter.overview.diskSizeHint", "Apparent local size") : undefined, + }, + ]; + const renderStatGrid = (items: OverviewStatCard[]) => ( +
+ {items.map((card) => ( +
+
{card.label}
+
{card.value}
+ {card.subLabel ? {card.subLabel} : null} +
+ ))} +
+ ); + const cards: OverviewStatCard[] = [ { id: "tokens", @@ -323,6 +373,7 @@ function OverviewTab({ return (
{controlsSection} + {renderStatGrid(projectMetricCards)}

@@ -336,6 +387,7 @@ function OverviewTab({ return (
{controlsSection} + {renderStatGrid(projectMetricCards)}

{coreError}

@@ -349,6 +401,7 @@ function OverviewTab({ return (
{controlsSection} + {renderStatGrid(projectMetricCards)}

{t("commandCenter.empty", "No usage data yet. Run some agents to populate the Dashboard.")}

@@ -361,15 +414,7 @@ function OverviewTab({ return (
{controlsSection} -
- {cards.map((card) => ( -
-
{card.label}
-
{card.value}
- {card.subLabel ? {card.subLabel} : null} -
- ))} -
+ {renderStatGrid([...projectMetricCards, ...cards])} {/* FNXC:CommandCenter 2026-06-18-00:00: The Overview should feel like a living software factory, so the live strip now surfaces animated pulses for tasks in progress, agents working, open signals, and a compact throughput trend from existing activity analytics without adding a new endpoint. diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-chart-layout.test.ts b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-chart-layout.test.ts index cdab1961dc..35eb48fcdf 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-chart-layout.test.ts +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-chart-layout.test.ts @@ -43,6 +43,10 @@ describe("CommandCenter.mobile-chart-layout.css", () => { expect(cssContent).toContain(".cc-system-chart-grid"); }); + it("stacks Overview stat cards without mobile overflow", () => { + expect(mobileCss).toMatch(/\.cc-stat-grid\s*\{[^}]*min-inline-size:\s*0;[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/); + }); + it("keeps chart primitives and text-bearing children shrink-bounded for real mobile layout", () => { expect(cssContent).toMatch(/\.cc-token-series-axis,[\s\S]*\.cc-funnel-stage\s*\{[\s\S]*min-inline-size:\s*0;[\s\S]*max-inline-size:\s*100%/); expect(cssContent).toMatch(/\.cc-bar-label\s*\{[^}]*min-inline-size:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis/); diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index 544cb81858..1991eaf927 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -9,6 +9,7 @@ import { CommandCenter } from "../CommandCenter"; const apiMock = vi.fn(); vi.mock("../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => apiMock(path, opts), + fetchCodebaseMetrics: vi.fn().mockResolvedValue({ tokenEstimate: 42_000, sourceFileCount: 10, sourceByteCount: 100, diskBytes: 1024, diskFileCount: 12, method: "local", truncated: false }), withProjectId: (path: string, projectId?: string) => projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path, fetchOrgTree: vi.fn().mockResolvedValue([]), @@ -422,6 +423,11 @@ function mockEmptyOverviewApi() { mockOverviewApi({ tokens: tokenFixture(0), tools: toolsFixture(0), activity: emptyActivityFixture(), signals: signalsFixture(0), live: liveFixture([{ column: "in-progress", count: 0 }]) }); } +function expectProjectMetricCards() { + expect(screen.getByTestId("cc-overview-codebase-tokens")).toBeInTheDocument(); + expect(screen.getByTestId("cc-overview-disk-size")).toBeInTheDocument(); +} + function statValue(testId: string) { return within(screen.getByTestId(testId)).getByText((content, element) => element?.classList.contains("cc-stat-value") === true && content.length > 0, @@ -481,6 +487,8 @@ describe("CommandCenter shell", () => { mockEmptyOverviewApi(); render(); expect(screen.getByTestId("command-center-overview-loading")).toBeTruthy(); + expectProjectMetricCards(); + expect(screen.getByTestId("cc-overview-codebase-tokens")).toHaveTextContent("—"); expectThroughputLastAfter("command-center-overview-loading"); }); @@ -494,6 +502,7 @@ describe("CommandCenter shell", () => { expect(screen.queryByTestId("cc-overview-line")).toBeNull(); expect(screen.queryByTestId("command-center-overview-chart-activity")).toBeNull(); await screen.findByTestId("command-center-empty"); + expectProjectMetricCards(); expectThroughputLastAfter("command-center-empty"); // FNXC:CommandCenter 2026-06-23-01:30: Sessions/Active-nodes cards were removed — neither renders in the empty-data branch (the empty state has no stat grid at all). expect(screen.queryByTestId("command-center-stat-sessions")).toBeNull(); @@ -987,6 +996,7 @@ describe("CommandCenter shell", () => { render(); await screen.findByTestId("command-center-overview-error"); + expectProjectMetricCards(); expectThroughputLastAfter("command-center-overview-error"); expect(screen.getByTestId("command-center-overview-error").textContent).toContain("tokens failed"); expect(screen.queryByTestId("command-center-overview-loading")).toBeNull(); diff --git a/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx index d59daa4bbf..d4df1a744d 100644 --- a/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx @@ -145,6 +145,14 @@ describe("SystemStatsArea", () => { expect(screen.getByTestId("cc-system-vitest-controls")).toHaveTextContent("Vitest Processes"); }); + it("renders granular sub-megabyte system measurements", async () => { + mockFetchSystemStats.mockResolvedValue(sampleStats({ systemStats: { external: 512 } })); + + render(); + + expect(await screen.findByTestId("cc-system-details-grid")).toHaveTextContent("512 B"); + }); + it("renders the first-sample CPU state safely without NaN", async () => { mockFetchSystemStats.mockResolvedValue(sampleStats({ systemStats: { cpuPercent: null } })); diff --git a/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx b/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx index b3cf701b43..9b90539cdd 100644 --- a/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx @@ -17,6 +17,7 @@ import { Sparkline } from "../charts/Sparkline"; import { LineChart, PieChart } from "../charts/recharts"; import { AreaShell } from "./AreaShell"; import { formatCount } from "./areaShared"; +import { formatBytes } from "../../../utils/formatBytes"; import "./SystemStatsArea.css"; type Severity = "normal" | "warning" | "critical"; @@ -37,13 +38,6 @@ FNXC:CommandCenter 2026-06-18-00:00: System telemetry now lives in the Command Center System area; it polls /api/system-stats (no new endpoint) and keeps a bounded rolling sample buffer to render live CPU/memory trend sparklines. */ -function formatBytes(bytes: number): string { - if (!Number.isFinite(bytes) || bytes < 0) return "—"; - const mb = bytes / (1024 * 1024); - if (mb < 1024) return `${mb.toFixed(0)} MB`; - return `${(mb / 1024).toFixed(2)} GB`; -} - function toPercent(used: number, total: number): string { if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return "—"; return `${((used / total) * 100).toFixed(1)}%`; diff --git a/packages/dashboard/app/utils/__tests__/formatBytes.test.ts b/packages/dashboard/app/utils/__tests__/formatBytes.test.ts new file mode 100644 index 0000000000..dcd3e908c8 --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/formatBytes.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { formatBytes } from "../formatBytes"; + +describe("formatBytes", () => { + it("formats byte-unit boundaries with granular local sizes", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(1024)).toBe("1 KB"); + expect(formatBytes(1024 * 1024)).toBe("1 MB"); + expect(formatBytes(1024 * 1024 * 1024)).toBe("1.00 GB"); + }); + + it("marks negative and non-finite measurements unavailable", () => { + expect(formatBytes(-1)).toBe("—"); + expect(formatBytes(Number.NaN)).toBe("—"); + expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("—"); + }); +}); diff --git a/packages/dashboard/app/utils/formatBytes.ts b/packages/dashboard/app/utils/formatBytes.ts new file mode 100644 index 0000000000..5b8eab039c --- /dev/null +++ b/packages/dashboard/app/utils/formatBytes.ts @@ -0,0 +1,11 @@ +/* +FNXC:CommandCenter 2026-07-16-10:00: +The shared byte formatter is deliberately granular (B/KB/MB/GB) because Project Disk size and System telemetry must not present a sub-megabyte value as 0 MB. Invalid and negative measurements remain unavailable rather than inventing a size. +*/ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return "—"; + if (bytes < 1024) return `${Math.round(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} diff --git a/packages/dashboard/src/lib/__tests__/codebase-metrics.test.ts b/packages/dashboard/src/lib/__tests__/codebase-metrics.test.ts new file mode 100644 index 0000000000..4c20d7188d --- /dev/null +++ b/packages/dashboard/src/lib/__tests__/codebase-metrics.test.ts @@ -0,0 +1,62 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { computeCodebaseMetrics, countPreTokenPieces, estimateTextTokens, resetCodebaseMetricsCache } from "../codebase-metrics"; +import counts from "./fixtures/token-corpus/reference-counts.json"; + +const roots: string[] = []; +async function root(): Promise { const value = await mkdtemp(join(tmpdir(), "fn8133-metrics-")); roots.push(value); return value; } +afterEach(async () => { resetCodebaseMetricsCache(); await Promise.all(roots.splice(0).map((value) => rm(value, { recursive: true, force: true }))); }); + +describe("codebase metrics", () => { + it("beats chars/4 on the disjoint frozen cl100k_base holdout", async () => { + const fixture = join(process.cwd(), "src/lib/__tests__/fixtures/token-corpus"); + let estimateError = 0; let charsError = 0; + for (const [file, reference] of Object.entries(counts.counts)) { + const text = await (await import("node:fs/promises")).readFile(join(fixture, file), "utf8"); + expect(Math.abs(estimateTextTokens(text) - reference)).toBeLessThan(Math.abs(text.length / 4 - reference)); + estimateError += Math.abs(estimateTextTokens(text) - reference); charsError += Math.abs(text.length / 4 - reference); + } + expect(estimateError).toBeLessThan(charsError); + }); + + it("segments long letter and digit runs rather than collapsing them", () => { + expect(countPreTokenPieces("x")).toBe(1); + expect(countPreTokenPieces("x".repeat(10_000))).toBeGreaterThan(1); + expect(countPreTokenPieces("1".repeat(10_000))).toBeGreaterThan(1); + }); + + it("uses fallback exclusions, skips binary source, and keeps source and disk domains separate", async () => { + const dir = await root(); + await mkdir(join(dir, "src")); await mkdir(join(dir, ".cache")); await mkdir(join(dir, "node_modules")); + await writeFile(join(dir, "src", "source.ts"), "export const local = true;"); + await writeFile(join(dir, "binary.bin"), Buffer.from([0, 1, 2])); + await writeFile(join(dir, ".cache", "ignored.ts"), "outside"); await writeFile(join(dir, "node_modules", "ignored.ts"), "outside"); + const result = await computeCodebaseMetrics(dir, { now: () => 0 }); + expect(result.sourceFileCount).toBe(1); expect(result.sourceByteCount).toBeGreaterThan(0); + expect(result.diskFileCount).toBeGreaterThan(result.sourceFileCount); expect(result.diskBytes).toBeGreaterThan(result.sourceByteCount); + }); + + it("does not follow source or disk symlinks", async () => { + const dir = await root(); const outside = await root(); + await writeFile(join(dir, "safe.ts"), "export const safe = true;"); await writeFile(join(outside, "secret.ts"), "SECRET_SENTINEL"); + await symlink(join(outside, "secret.ts"), join(dir, "escape.ts")); await symlink(outside, join(dir, "outside-dir")); + const result = await computeCodebaseMetrics(dir, { now: () => 0 }); + expect(result.sourceFileCount).toBe(1); expect(result.diskFileCount).toBe(3); expect(result.tokenEstimate).not.toBe(estimateTextTokens("SECRET_SENTINEL")); + }); + + it("marks entry, byte, and time caps partial without throwing", async () => { + const dir = await root(); await writeFile(join(dir, "a.ts"), "const a = 1;"); await writeFile(join(dir, "b.ts"), "const b = 2;"); + expect((await computeCodebaseMetrics(dir, { maxSourceEntries: 1, now: () => 0 })).truncated).toBe(true); + expect((await computeCodebaseMetrics(dir, { maxScanBytes: 1, now: () => 0 })).truncated).toBe(true); + expect((await computeCodebaseMetrics(dir, { maxFileBytes: 1, now: () => 0 })).truncated).toBe(true); + let tick = 0; expect((await computeCodebaseMetrics(dir, { maxWalkMs: 1, now: () => ++tick * 2 })).truncated).toBe(true); + }); + + it("caches default calls", async () => { + const dir = await root(); await writeFile(join(dir, "a.ts"), "const a = 1;"); + const first = await computeCodebaseMetrics(dir); await writeFile(join(dir, "b.ts"), "const b = 2;"); + expect(await computeCodebaseMetrics(dir)).toEqual(first); + }); +}); diff --git a/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/README.md b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/README.md new file mode 100644 index 0000000000..41558ec419 --- /dev/null +++ b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/README.md @@ -0,0 +1,17 @@ +# Frozen `cl100k_base` token corpus + +The holdout files at this directory's root are acceptance-only. `calibration/` is disjoint and is used only to derive the fixed estimator calibration. + +- Canonical upstream repository: https://github.com/openai/tiktoken +- Homepage: https://github.com/openai/tiktoken#readme +- Pinned release: https://pypi.org/project/tiktoken/0.7.0/ (`pip install tiktoken==0.7.0`) +- Invocation: `python` importing `tiktoken` +- Encoding: `cl100k_base` +- Resolved artifact: `tiktoken-0.7.0-cp311-cp311-macosx_11_0_arm64.whl` +- SHA-256: `084cec29713bc9d4189a937f8a35dbdfa785bd1235a34c1124fe2323821ee93f` + +Regenerate frozen counts with: + +```sh +python -c 'import json,pathlib,tiktoken; root=pathlib.Path("packages/dashboard/src/lib/__tests__/fixtures/token-corpus"); enc=tiktoken.get_encoding("cl100k_base"); files=["example.ts","view.tsx","settings.json","notes.md"]; print(json.dumps({"encoding":"cl100k_base","counts":{f:len(enc.encode((root/f).read_text())) for f in files}},indent=2))' +``` diff --git a/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/calibration/component.ts b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/calibration/component.ts new file mode 100644 index 0000000000..d6fad41235 --- /dev/null +++ b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/calibration/component.ts @@ -0,0 +1 @@ +const metrics = { "codebaseTokens": 13579, "diskBytes": 24680, "truncated": false }; diff --git a/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/calibration/config.json b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/calibration/config.json new file mode 100644 index 0000000000..6e5a9fa1ce --- /dev/null +++ b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/calibration/config.json @@ -0,0 +1 @@ +{"sourceFileCount": 12345, "sourceByteCount": 67890, "method": "local"} diff --git a/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/example.ts b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/example.ts new file mode 100644 index 0000000000..6c3d5621dd --- /dev/null +++ b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/example.ts @@ -0,0 +1 @@ +function calculateAccumulatedProjectMetrics(currentValue, incomingValue) { return currentValue + incomingValue; } diff --git a/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/notes.md b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/notes.md new file mode 100644 index 0000000000..7b92399847 --- /dev/null +++ b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/notes.md @@ -0,0 +1,3 @@ +```json +{"codebaseTokens": 13579, "diskBytes": 24680, "truncated": false} +``` diff --git a/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/reference-counts.json b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/reference-counts.json new file mode 100644 index 0000000000..36004ee19e --- /dev/null +++ b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/reference-counts.json @@ -0,0 +1,9 @@ +{ + "encoding": "cl100k_base", + "counts": { + "example.ts": 21, + "view.tsx": 13, + "settings.json": 23, + "notes.md": 28 + } +} diff --git a/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/settings.json b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/settings.json new file mode 100644 index 0000000000..2438cef003 --- /dev/null +++ b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/settings.json @@ -0,0 +1 @@ +{"codebaseTokens": 13579, "diskBytes": 24680, "truncated": false} diff --git a/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/view.tsx b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/view.tsx new file mode 100644 index 0000000000..60cc0544f5 --- /dev/null +++ b/packages/dashboard/src/lib/__tests__/fixtures/token-corpus/view.tsx @@ -0,0 +1 @@ +{"codebaseTokens"}: {13579} diff --git a/packages/dashboard/src/lib/codebase-metrics.ts b/packages/dashboard/src/lib/codebase-metrics.ts new file mode 100644 index 0000000000..4ba31c87f8 --- /dev/null +++ b/packages/dashboard/src/lib/codebase-metrics.ts @@ -0,0 +1,161 @@ +import { spawn } from "node:child_process"; +import { lstat, readdir, readFile, realpath } from "node:fs/promises"; +import { basename, resolve, sep } from "node:path"; + +export interface CodebaseMetrics { + tokenEstimate: number; + sourceFileCount: number; + sourceByteCount: number; + diskBytes: number; + diskFileCount: number; + method: string; + truncated: boolean; +} + +export interface CodebaseMetricsOptions { + maxSourceEntries?: number; + maxSourceWalkMs?: number; + maxScanBytes?: number; + maxFileBytes?: number; + maxDiskEntries?: number; + maxWalkMs?: number; + cacheTtlMs?: number; + now?: () => number; +} + +export const MAX_SOURCE_ENTRIES = 50_000; +export const MAX_SOURCE_WALK_MS = 4_000; +export const MAX_SCAN_BYTES = 64 * 1024 * 1024; +export const MAX_FILE_BYTES = 2 * 1024 * 1024; +export const MAX_DISK_ENTRIES = 500_000; +export const MAX_WALK_MS = 4_000; +export const CACHE_TTL_MS = 120_000; +const RUN_SEGMENT_LENGTH = 4; +const CALIBRATION_MULTIPLIER = 51 / 66; + +/* +FNXC:CodebaseMetrics 2026-07-16-10:30: +Codebase context is computed entirely locally: a cl100k_base-calibrated, length-sensitive pre-tokenization piece count (P * K, L=4) is materially more useful for source than chars/4 without shipping files or loading a BPE tokenizer. Source metrics describe only scanned text files; disk metrics independently describe apparent size. + +Git source enumeration uses bounded `git ls-files -z`. Non-git fallback includes regular files except directories whose basename starts with `.` or is exactly `node_modules`, `dist`, or `build`. Both paths reject out-of-root candidates and lstat-gate regular files before reading, never following symlinks. Source defaults are 50,000 entries, 4 seconds, 64 MiB total, and 2 MiB/file; disk defaults are 500,000 entries and 4 seconds. Disk sums lstat.size for regular files and symlink entries only (apparent size, never directories/special files), does not descend symlinks, and cache TTL is two minutes. Injectable limits and clock make every cap deterministic in tests. +*/ + +const cache = new Map(); +export function resetCodebaseMetricsCache(): void { cache.clear(); } + +export function countPreTokenPieces(text: string): number { + let pieces = 0; + for (const match of text.matchAll(/[\p{L}]+|[\p{N}]+|[^\s\p{L}\p{N}]/gu)) { + const run = match[0]; + pieces += /[\p{L}\p{N}]/u.test(run[0]) ? Math.ceil(Array.from(run).length / RUN_SEGMENT_LENGTH) : 1; + } + return pieces; +} +export function estimateTextTokens(text: string): number { return Math.round(countPreTokenPieces(text) * CALIBRATION_MULTIPLIER); } + +function isInside(root: string, candidate: string): boolean { return candidate === root || candidate.startsWith(`${root}${sep}`); } +function isFallbackExcluded(name: string): boolean { return name.startsWith(".") || ["node_modules", "dist", "build"].includes(name); } +function isBinary(buffer: Buffer): boolean { return buffer.subarray(0, 8_192).includes(0); } + +async function gitFiles(root: string, maxEntries: number, now: () => number, deadline: number): Promise<{ files: string[]; truncated: boolean } | null> { + return await new Promise((resolveResult) => { + const child = spawn("git", ["ls-files", "-z"], { cwd: root, stdio: ["ignore", "pipe", "ignore"] }); + const files: string[] = []; let pending = ""; let truncated = false; let settled = false; + const stop = () => { child.stdout.removeAllListeners(); child.kill(); }; + const finish = (result: { files: string[]; truncated: boolean } | null) => { + if (!settled) { + settled = true; + if (timeout) clearTimeout(timeout); + resolveResult(result); + } + }; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + if (settled) return; + pending += chunk; + let nul: number; + while ((nul = pending.indexOf("\0")) !== -1) { + const file = pending.slice(0, nul); pending = pending.slice(nul + 1); + if (files.length >= maxEntries || now() > deadline) { truncated = true; stop(); finish({ files, truncated }); return; } + files.push(file); + } + }); + child.on("error", () => finish(null)); + child.on("close", (code) => { + if (settled) return; + if (code === 0) finish({ files, truncated }); else finish(null); + }); + // FNXC:CodebaseMetrics 2026-07-16-14:00: The source-walk deadline must also stop a silent or stalled git process; checking it only while stdout arrives leaves the dashboard request unbounded. + const timeout = setTimeout(() => { + truncated = true; + stop(); + finish({ files, truncated }); + }, Math.max(0, deadline - now())); + }); +} + +async function fallbackFiles(root: string, maxEntries: number, now: () => number, deadline: number): Promise<{ files: string[]; truncated: boolean }> { + const files: string[] = []; let truncated = false; + async function walk(dir: string): Promise { + if (truncated || now() > deadline) { truncated = true; return; } + let entries: string[]; + try { entries = await readdir(dir); } catch { return; } + for (const entry of entries) { + if (truncated || now() > deadline || files.length >= maxEntries) { truncated = true; return; } + const candidate = resolve(dir, entry); let info; + try { info = await lstat(candidate); } catch { continue; } + if (info.isSymbolicLink()) continue; + if (info.isDirectory()) { if (!isFallbackExcluded(basename(candidate))) await walk(candidate); } + else if (info.isFile()) files.push(candidate.slice(root.length + 1)); + } + } + await walk(root); return { files, truncated }; +} + +export async function computeCodebaseMetrics(rootDir: string, options: CodebaseMetricsOptions = {}): Promise { + const now = options.now ?? Date.now; + const limits = { + maxSourceEntries: options.maxSourceEntries ?? MAX_SOURCE_ENTRIES, maxSourceWalkMs: options.maxSourceWalkMs ?? MAX_SOURCE_WALK_MS, + maxScanBytes: options.maxScanBytes ?? MAX_SCAN_BYTES, maxFileBytes: options.maxFileBytes ?? MAX_FILE_BYTES, + maxDiskEntries: options.maxDiskEntries ?? MAX_DISK_ENTRIES, maxWalkMs: options.maxWalkMs ?? MAX_WALK_MS, cacheTtlMs: options.cacheTtlMs ?? CACHE_TTL_MS, + }; + const cacheKey = resolve(rootDir); const cached = options.now === undefined ? cache.get(cacheKey) : undefined; + if (cached && cached.expiresAt > now()) return cached.result; + const root = await realpath(rootDir); + let truncated = false; const sourceStart = now(); + const git = await gitFiles(root, limits.maxSourceEntries, now, sourceStart + limits.maxSourceWalkMs); + const enumeration = git ?? await fallbackFiles(root, limits.maxSourceEntries, now, sourceStart + limits.maxSourceWalkMs); + truncated ||= enumeration.truncated; + let tokenEstimate = 0; let sourceFileCount = 0; let sourceByteCount = 0; + for (const relative of enumeration.files) { + if (now() > sourceStart + limits.maxSourceWalkMs || sourceFileCount >= limits.maxSourceEntries) { truncated = true; break; } + const candidate = resolve(root, relative); + if (!isInside(root, candidate)) continue; + let info; try { info = await lstat(candidate); } catch { continue; } + if (!info.isFile()) continue; + // FNXC:CodebaseMetrics 2026-07-16-14:00: A source candidate above the per-file read limit is omitted to protect the local machine, so surface the aggregate estimate as partial rather than implying complete project coverage. + if (info.size > limits.maxFileBytes) { truncated = true; continue; } + if (sourceByteCount + info.size > limits.maxScanBytes) { truncated = true; break; } + const contents = await readFile(candidate); + if (isBinary(contents)) continue; + sourceByteCount += contents.length; sourceFileCount++; tokenEstimate += estimateTextTokens(contents.toString("utf8")); + } + let diskBytes = 0; let diskFileCount = 0; let diskEntries = 0; const diskStart = now(); + async function walkDisk(dir: string): Promise { + if (truncated && diskEntries >= limits.maxDiskEntries) return; + if (now() > diskStart + limits.maxWalkMs) { truncated = true; return; } + let entries: string[]; try { entries = await readdir(dir); } catch { return; } + for (const entry of entries) { + if (diskEntries >= limits.maxDiskEntries || now() > diskStart + limits.maxWalkMs) { truncated = true; return; } + const candidate = resolve(dir, entry); if (!isInside(root, candidate)) continue; + let info; try { info = await lstat(candidate); } catch { continue; } + diskEntries++; + if (info.isDirectory()) await walkDisk(candidate); + else if (info.isFile() || info.isSymbolicLink()) { diskFileCount++; diskBytes += info.size; } + } + } + await walkDisk(root); + const result = { tokenEstimate, sourceFileCount, sourceByteCount, diskBytes, diskFileCount, method: "local-pretokenization-cl100k_base", truncated }; + if (options.now === undefined) cache.set(cacheKey, { result, expiresAt: now() + limits.cacheTtlMs }); + return result; +} diff --git a/packages/dashboard/src/routes/__tests__/codebase-metrics-route.test.ts b/packages/dashboard/src/routes/__tests__/codebase-metrics-route.test.ts new file mode 100644 index 0000000000..8ddbac09e3 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/codebase-metrics-route.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node + +import express from "express"; +import { describe, expect, it, vi } from "vitest"; +import { request } from "../../test-request.js"; +import { registerProjectRoutes } from "../register-project-routes.js"; + +function appFor(project: { id: string; path: string } | null) { + const router = express.Router(); + registerProjectRoutes({ + router, + options: { centralCore: { getProject: vi.fn(async () => project), isInitialized: () => true } }, + runtimeLogger: { child: () => ({ warn: vi.fn() }) }, + prioritizeProjectsForCurrentDirectory: vi.fn((projects) => projects), + rethrowAsApiError: (error: unknown) => { throw error; }, + } as never); + const app = express(); app.use("/api", router); + app.use((error: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => res.status(error.statusCode ?? 500).json({ error: error.message })); + return app; +} + +describe("codebase metrics route", () => { + it("returns separately named source and disk metrics", async () => { + const app = appFor({ id: "p1", path: process.cwd() }); + const response = await request(app, "GET", "/api/projects/p1/codebase-metrics"); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tokenEstimate: expect.any(Number), sourceFileCount: expect.any(Number), sourceByteCount: expect.any(Number), diskBytes: expect.any(Number), diskFileCount: expect.any(Number), method: expect.any(String), truncated: expect.any(Boolean) }); + }); + + it("returns 404 for a missing project", async () => { + const response = await request(appFor(null), "GET", "/api/projects/missing/codebase-metrics"); + expect(response.status).toBe(404); + }); +}); diff --git a/packages/dashboard/src/routes/register-project-routes.ts b/packages/dashboard/src/routes/register-project-routes.ts index a7ee1b73b0..a9176ab95b 100644 --- a/packages/dashboard/src/routes/register-project-routes.ts +++ b/packages/dashboard/src/routes/register-project-routes.ts @@ -12,6 +12,7 @@ import type { CentralCore as CentralCoreApi } from "@fusion/core"; import { ApiError, badRequest, notFound } from "../api-error.js"; import { execFileAsync } from "../exec-file.js"; import { getOrCreateProjectStore, evictProjectStore } from "../project-store-resolver.js"; +import { computeCodebaseMetrics } from "../lib/codebase-metrics.js"; import type { ApiRouteRegistrar } from "./types.js"; const { @@ -951,6 +952,24 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => { } }); + /** + * GET /api/projects/:id/codebase-metrics + * Compute bounded, local-only codebase context and apparent disk metrics. + */ + router.get("/projects/:id/codebase-metrics", async (req, res) => { + try { + const metrics = await withCentralCore(async (central) => { + const project = await central.getProject(req.params.id); + if (!project) throw notFound("Project not found"); + return await computeCodebaseMetrics(project.path); + }); + res.json(metrics); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + /** * POST /api/projects/:id/pause * Pause a project.