From e5f2342cbe6855eff8882125b5e8e58e4ee579b5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 21 Jun 2026 05:31:03 +0200 Subject: [PATCH 1/2] Update dashboard for unified stores --- dashboard/graph/src/GraphCanvas.tsx | 6 +- dashboard/graph/src/labelLayout.ts | 4 +- dashboard/graph/src/styles.css | 33 +++ dashboard/hermes-wrapper/plugin_api.py | 18 +- dashboard/holographic/manifest.json | 2 +- .../holographic/src/AssociationGraph.tsx | 2 +- .../holographic/src/HolographicMemoryPage.tsx | 22 +- dashboard/holographic/src/styles.css | 106 +++++++++ dashboard/lcm/src/App.tsx | 56 +++-- dashboard/lcm/src/styles.css | 83 ++++++- dashboard/savings/src/ModelsPanel.tsx | 30 +-- dashboard/savings/src/SessionsPanel.tsx | 20 +- dashboard/savings/src/charts.tsx | 10 +- dashboard/savings/src/styles.css | 189 ++++++++++++++- dashboard/shell/dist/source-stamp | 2 +- dashboard/smoke.mjs | 36 +++ dashboard/test/graph-logic.test.mjs | 4 +- src/dashboard/curate_preview_store.rs | 25 +- src/dashboard/graph_api.rs | 12 +- src/dashboard/graph_service.rs | 59 ++--- src/dashboard/lcm_api.rs | 14 +- src/dashboard/memory_api.rs | 115 ++++++++- src/dashboard/memory_curate.rs | 19 +- src/dashboard/memory_service.rs | 35 +-- src/dashboard/mod.rs | 155 +++++++++---- src/dashboard/savings_api.rs | 33 ++- src/dashboard/savings_pricing.rs | 22 +- src/dashboard/token_count.rs | 85 ++++++- tests/dashboard_api_test.rs | 218 +++++++++++++++--- tests/dashboard_lcm_api_test.rs | 33 +-- tests/dashboard_lcm_fixes_test.rs | 22 +- tests/dashboard_savings_api_test.rs | 140 ++++++++++- tests/hermes_dashboard_test.rs | 4 +- 33 files changed, 1290 insertions(+), 324 deletions(-) diff --git a/dashboard/graph/src/GraphCanvas.tsx b/dashboard/graph/src/GraphCanvas.tsx index aa68dcfb..89cceaca 100644 --- a/dashboard/graph/src/GraphCanvas.tsx +++ b/dashboard/graph/src/GraphCanvas.tsx @@ -227,7 +227,11 @@ export default function GraphCanvas({ // Collapsed-neighbor badge: full degree minus visible edges. const collapsed = Math.max(0, (node.degree || 0) - node.visibleDegree); - if (collapsed > 0 && camera.k > 0.35) { + const showCollapsedBadge = + collapsed > 0 && + camera.k > 0.35 && + (rect.width >= 560 || isSelected || isHovered || onPath); + if (showCollapsedBadge) { const bx = node.x + node.radius * 0.85; const by = node.y - node.radius * 0.85; const br = Math.max(6.5, 8 / camera.k); diff --git a/dashboard/graph/src/labelLayout.ts b/dashboard/graph/src/labelLayout.ts index a070a1b7..a5f45ecd 100644 --- a/dashboard/graph/src/labelLayout.ts +++ b/dashboard/graph/src/labelLayout.ts @@ -31,7 +31,9 @@ const LABEL_PAD = 3; * desktop canvas allows a few dozen and a narrow panel only a handful. */ export function labelCapForArea(width: number, height: number): number { - return Math.max(6, Math.floor((width * height) / 30_000)); + const density = width < 520 ? 58_000 : 30_000; + const floor = width < 520 ? 4 : 6; + return Math.max(floor, Math.floor((width * height) / density)); } function overlaps(a: LabelBox, b: LabelBox): boolean { diff --git a/dashboard/graph/src/styles.css b/dashboard/graph/src/styles.css index a1a3a0d0..7ae6459c 100644 --- a/dashboard/graph/src/styles.css +++ b/dashboard/graph/src/styles.css @@ -507,6 +507,19 @@ gap: 0.85rem; } +.tsg-analytics-grid .ts-card { + min-height: 0; +} + +.tsg-analytics-grid .ts-card-content { + min-height: 0; +} + +.tsg-analytics-grid .tdp-bar-list { + max-height: none; + overflow: visible; +} + .tsg-chart { display: block; width: 100%; @@ -553,13 +566,33 @@ } } +@media (min-width: 1280px) { + .tsg-analytics-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + @media (max-width: 860px) { .tsg-toolbar { grid-template-columns: 1fr; + gap: 0.7rem; + } + + .tsg-toolbar-left { + justify-content: space-between; + } + + .tsg-views { + flex: 1 1 auto; + } + + .tsg-view-tab { + flex: 1 1 0; } .tsg-totals { justify-content: flex-start; + flex-wrap: wrap; } .tsg-canvas, diff --git a/dashboard/hermes-wrapper/plugin_api.py b/dashboard/hermes-wrapper/plugin_api.py index c6f3248e..c3f2f5a9 100644 --- a/dashboard/hermes-wrapper/plugin_api.py +++ b/dashboard/hermes-wrapper/plugin_api.py @@ -29,8 +29,7 @@ unpinned profile-scoped installs. Source-tree development falls back to the Hermes process cwd when no deploy-time default has been baked in. -Legacy ``TOKENSAVE_*`` aliases remain accepted as fallbacks during the -TraceDecay rebrand; ``TRACEDECAY_*`` wins when both are present. +Use ``TRACEDECAY_*`` environment variables for runtime configuration. Hermes-only extension: ``POST /curation/llm-plan`` layers LLM-based curation (ported from the holographic_plus curator's one-shot review tier) on top of @@ -90,8 +89,8 @@ def _env(name: str) -> str | None: - """Read TRACEDECAY_, falling back to legacy TOKENSAVE_.""" - return os.environ.get(f"TRACEDECAY_{name}") or os.environ.get(f"TOKENSAVE_{name}") + """Read TRACEDECAY_.""" + return os.environ.get(f"TRACEDECAY_{name}") _SPAWN_TIMEOUT_SECONDS = 30.0 _PROXY_TIMEOUT_SECONDS = 30.0 @@ -196,15 +195,11 @@ def _dashboard_env() -> dict[str, str]: `subprocess.Popen` inherits by default, but constructing the child env explicitly makes the Hermes profile contract visible and stable: the spawned Rust server must resolve `HERMES_HOME` and any `TRACEDECAY_*` - / legacy `TOKENSAVE_*` overrides exactly like the wrapper process did. + overrides exactly like the wrapper process did. """ env = os.environ.copy() for key, value in os.environ.items(): - if ( - key == "HERMES_HOME" - or key.startswith("TRACEDECAY_") - or key.startswith("TOKENSAVE_") - ): + if key == "HERMES_HOME" or key.startswith("TRACEDECAY_"): env[key] = value return env @@ -217,8 +212,7 @@ def _spawn_dashboard() -> str: status_code=503, detail=( "tracedecay binary not found. Install tracedecay or set " - "TRACEDECAY_BIN / TRACEDECAY_DASHBOARD_URL " - "(legacy TOKENSAVE_* aliases are also accepted)." + "TRACEDECAY_BIN / TRACEDECAY_DASHBOARD_URL." ), ) project = _project_root() diff --git a/dashboard/holographic/manifest.json b/dashboard/holographic/manifest.json index 91ec2c18..e7af78b4 100644 --- a/dashboard/holographic/manifest.json +++ b/dashboard/holographic/manifest.json @@ -4,7 +4,7 @@ "description": "Holographic memory explorer + curation", "provider": { "kind": "memory", - "name": "holographic_plus" + "name": "tracedecay" }, "tab": { "path": "/holographic-memory", diff --git a/dashboard/holographic/src/AssociationGraph.tsx b/dashboard/holographic/src/AssociationGraph.tsx index 53e70d56..afd0f408 100644 --- a/dashboard/holographic/src/AssociationGraph.tsx +++ b/dashboard/holographic/src/AssociationGraph.tsx @@ -367,7 +367,7 @@ export default function AssociationGraph({ Association Graph -
+
No graph data.
diff --git a/dashboard/holographic/src/HolographicMemoryPage.tsx b/dashboard/holographic/src/HolographicMemoryPage.tsx index 39a92afd..14510ba8 100644 --- a/dashboard/holographic/src/HolographicMemoryPage.tsx +++ b/dashboard/holographic/src/HolographicMemoryPage.tsx @@ -119,14 +119,14 @@ function Stat({ }) { return (
-
+
{value}
-
+
{label}
@@ -159,7 +159,7 @@ function DataBars({

{title}

- {header &&
{header}
} + {header && items.length > 0 &&
{header}
}
{items.length === 0 ? (

No data.

@@ -214,7 +214,7 @@ function SystemStrip({ const db = data.holographic; return ( -
+
@@ -237,11 +237,11 @@ function SystemStrip({ } /> -
-
+
+
{db.path}
-
+
storage path
@@ -307,7 +307,7 @@ function MemoryHealthCard({
-
+
@@ -354,7 +354,7 @@ function SearchBox({ setQuery: (value: string) => void; }) { return ( -
+
{refreshing ? ( ) : ( @@ -1126,7 +1126,7 @@ export default function HolographicMemoryPage() {

- Plugin Inspector + Holographic Memory

) : null; + const lcmScopeLabel = data ? storageScopeLabel(data.storage_scope) : null; return (
@@ -761,15 +784,16 @@ function App(): React.ReactElement { Arrow keys browse results Enter opens detail
- {/* Which session store is being served (scope tag + database path). */}
{data ? ( <> - {data.storage_scope === "project_local" - ? Project store - : (data.storage_scope === "global" - ? Global store - : null)} + {lcmScopeLabel + ? ( + + {lcmScopeLabel} + + ) + : null} {data.path} ) : ""} @@ -812,9 +836,7 @@ function App(): React.ReactElement { @@ -826,9 +848,7 @@ function App(): React.ReactElement {
Lossless Context Store

No LCM sessions indexed yet

-

{data.storage_scope === "project_local" - ? "This project's session store (.tracedecay/sessions.db) exists but holds no messages yet. Cursor sessions are ingested by its end-of-turn hook; Claude/Codex/Vibe/Cline transcripts are swept automatically when the MCP server or this dashboard starts. Run an agent turn in this project and refresh." - : "The global database exists, but it does not contain raw messages or summary nodes. Once sessions are ingested, this page will fill with timelines, compression ratios, searchable messages, and summary-node drilldowns."}

+

{emptyStoreCopy(data.storage_scope)}

) : null} @@ -837,17 +857,19 @@ function App(): React.ReactElement { genuinely "empty database", never a masked fetch failure. */} {data ? (
- - - - - + + + + +
) : (overviewLoading ? (
+
+
) : null)} diff --git a/dashboard/lcm/src/styles.css b/dashboard/lcm/src/styles.css index e7d94d41..61e816f1 100644 --- a/dashboard/lcm/src/styles.css +++ b/dashboard/lcm/src/styles.css @@ -129,12 +129,11 @@ /* headline stat row */ .hermes-lcm-statrow { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0.75rem; } .hermes-lcm-stat { - flex: 1 1 120px; border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-card); @@ -187,8 +186,8 @@ display: flex; flex-direction: column; gap: 0.3rem; - width: fit-content; - max-width: 100%; + width: 100%; + max-width: 68rem; } .hermes-lcm-tl-undated { font-size: 0.7rem; @@ -196,15 +195,16 @@ .hermes-lcm-tl-bars { display: flex; align-items: flex-end; - gap: 5px; + gap: clamp(3px, 0.6vw, 7px); height: 84px; padding-bottom: 1px; border-bottom: 1px solid var(--color-border); overflow-x: auto; } .hermes-lcm-tl-col { - flex: 0 0 30px; - width: 30px; + flex: 1 0 clamp(18px, 3.2vw, 34px); + min-width: 18px; + max-width: 34px; height: 100%; display: flex; flex-direction: column; @@ -844,6 +844,22 @@ overflow: hidden; } +.hermes-lcm .tdp-stat-compact { + min-width: 0; +} + +.hermes-lcm .tdp-stat-value { + color: var(--lcm-text); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 1.45rem; + line-height: 1; +} + +.hermes-lcm .tdp-stat-label { + margin-top: 0.4rem; + color: var(--lcm-text-3); +} + .hermes-lcm-card::before { content: ""; position: absolute; @@ -1190,6 +1206,29 @@ .hermes-lcm-top { border-radius: 20px; flex-direction: column; + padding: 0.6rem; + align-items: stretch; + } + + .hermes-lcm-search-wrap { + flex: 0 1 auto; + min-width: 0; + width: 100%; + } + + .hermes-lcm-search { + min-width: 0; + width: 100%; + } + + .hermes-lcm-select, + .hermes-lcm-status { + width: 100%; + min-width: 0; + } + + .hermes-lcm-statrow { + grid-template-columns: repeat(2, minmax(0, 1fr)); } .hermes-lcm-empty-panel { @@ -1214,4 +1253,32 @@ .hermes-lcm-pager { justify-content: stretch; } + + .hermes-lcm-rows { + max-height: none; + overflow: visible; + } + + .hermes-lcm-card .tdp-empty.hermes-lcm-empty { + min-height: auto; + padding: 0.75rem; + } +} + +@media (max-width: 420px) { + .hermes-lcm-statrow { + gap: 0.6rem; + } + + .hermes-lcm-stat { + padding: 0.75rem; + } + + .hermes-lcm-stat-v { + font-size: 1.25rem; + } + + .hermes-lcm .tdp-stat-value { + font-size: 1.25rem; + } } diff --git a/dashboard/savings/src/ModelsPanel.tsx b/dashboard/savings/src/ModelsPanel.tsx index 1d595732..c8afc503 100644 --- a/dashboard/savings/src/ModelsPanel.tsx +++ b/dashboard/savings/src/ModelsPanel.tsx @@ -124,26 +124,28 @@ export default function ModelsPanel({ row.tokenized.output_tokens + row.estimated.output_tokens; return ( - - {row.model || unknown model} - + + + {row.model || unknown model} + + {cost.resolved ? ( {cost.resolved.slug} ) : ( no price data )} - {fmtTokens(row.sessions)} - {fmtTokens(row.messages)} - {fmtTokens(inputTokens)} - {fmtTokens(outputTokens)} - + {fmtTokens(row.sessions)} + {fmtTokens(row.messages)} + {fmtTokens(inputTokens)} + {fmtTokens(outputTokens)} + {cost.resolved ? `${fmtUsd(cost.resolved.price.prompt_per_mtok)} · ${fmtUsd(cost.resolved.price.completion_per_mtok)}` : "—"} - {cost.usd === null ? "—" : fmtUsd(cost.usd)} - + {cost.usd === null ? "—" : fmtUsd(cost.usd)} + @@ -209,10 +211,10 @@ export default function ModelsPanel({ {data.turns.by_model.map((row) => ( - {row.model} - {fmtTokens(row.total_tokens)} - {fmtUsd(row.cost_usd)} - + {row.model} + {fmtTokens(row.total_tokens)} + {fmtUsd(row.cost_usd)} + diff --git a/dashboard/savings/src/SessionsPanel.tsx b/dashboard/savings/src/SessionsPanel.tsx index ec28e865..2d8bba4d 100644 --- a/dashboard/savings/src/SessionsPanel.tsx +++ b/dashboard/savings/src/SessionsPanel.tsx @@ -176,19 +176,19 @@ export default function SessionsPanel({ className={cn("tss-session-row", isOpen && "tss-session-row-open")} onClick={() => setExpanded(isOpen ? null : key)} > - + {isOpen ? "▾" : "▸"} - {cleanTitle(session.title)} - {session.is_subagent && subagent} + {cleanTitle(session.title)} + {session.is_subagent && subagent} {session.session_id.slice(0, 8)} {when ? ` · ${timeAgo(when)}` : " · no timestamp"} - + {session.models.slice(0, 3).map((modelRow, index) => ( - + {modelRow.model || "unknown model"} ))} @@ -197,10 +197,10 @@ export default function SessionsPanel({ )} - {fmtTokens(session.messages)} - {fmtTokens(inputTokens)} - {fmtTokens(outputTokens)} - + {fmtTokens(session.messages)} + {fmtTokens(inputTokens)} + {fmtTokens(outputTokens)} + {cost.priced_rows === 0 ? ( no price data ) : ( @@ -215,7 +215,7 @@ export default function SessionsPanel({ )} - + diff --git a/dashboard/savings/src/charts.tsx b/dashboard/savings/src/charts.tsx index 1e8e7461..a5f55131 100644 --- a/dashboard/savings/src/charts.tsx +++ b/dashboard/savings/src/charts.tsx @@ -86,9 +86,11 @@ export function DailyBars({ } const width = 420; const height = 120; + const chartPadX = 18; const max = Math.max(1, ...series.map((point) => point.value)); - const barW = Math.max(2, Math.min(26, (width - 8) / series.length - 2)); - const step = (width - 8) / series.length; + const usableWidth = width - chartPadX * 2; + const barW = Math.max(2, Math.min(26, usableWidth / series.length - 2)); + const step = usableWidth / series.length; const label = valueLabel || fmtTokens; return ( {series.map((point, index) => { const h = Math.max(2, (point.value / max) * (height - 28)); - const x = 4 + index * step; + const x = chartPadX + index * step; return ( {fmtDay(point.day)} diff --git a/dashboard/savings/src/styles.css b/dashboard/savings/src/styles.css index 8b08b352..b6393e46 100644 --- a/dashboard/savings/src/styles.css +++ b/dashboard/savings/src/styles.css @@ -208,6 +208,17 @@ max-width: 26rem; } +.tss-session-name, +.tss-model-name { + min-width: 0; + overflow-wrap: anywhere; +} + +.tss-session-badge { + margin-left: 0.35rem; + vertical-align: baseline; +} + .tss-caret { color: var(--ts-text-3); margin-right: 0.4rem; @@ -243,6 +254,12 @@ } .tss-slug { + display: inline-block; + max-width: min(20rem, 100%); + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; + white-space: nowrap; color: var(--ts-text-3); font-family: var(--theme-font-mono); font-size: 0.74rem; @@ -371,15 +388,181 @@ @media (max-width: 560px) { .tss-toolbar { - flex-direction: column; + display: grid; + grid-template-columns: 1fr; align-items: stretch; } + .tss-toolbar-left { + display: grid; + grid-template-columns: 1fr; + gap: 0.6rem; + } + + .tss-views { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .tss-view-tab { + min-width: 0; + padding-inline: 0.45rem; + white-space: normal; + } + .tss-toolbar-right { - justify-content: flex-end; + justify-content: space-between; + } + + .tss-meta-strip-top { + display: grid; + grid-template-columns: 1fr; + gap: 0.35rem; + } + + .tss-table-scroll { + overflow: visible; + } + + .tss-table, + .tss-table thead, + .tss-table tbody, + .tss-table tr, + .tss-table th, + .tss-table td { + display: block; + } + + .tss-table thead { + display: none; + } + + .tss-table tbody { + display: grid; + gap: 0.65rem; + } + + .tss-table tr { + border: 1px solid var(--ts-line); + border-radius: 14px; + background: color-mix(in srgb, var(--ts-void) 24%, transparent); + overflow: hidden; + } + + .tss-table td { + display: grid; + grid-template-columns: minmax(5.8rem, 34%) minmax(0, 1fr); + gap: 0.6rem; + align-items: start; + min-width: 0; + padding: 0.42rem 0.65rem; + border-bottom: 1px solid color-mix(in srgb, var(--ts-line) 78%, transparent); + } + + .tss-table td::before { + content: attr(data-label); + color: var(--ts-text-3); + font-family: var(--theme-font-mono); + font-size: 0.64rem; + font-weight: 800; + letter-spacing: 0.06em; + line-height: 1.35; + text-transform: uppercase; + } + + .tss-table td:last-child { + border-bottom: 0; } .tss-session-title { - max-width: 14rem; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.25rem; + align-items: start; + grid-column: 1 / -1; + max-width: none; + } + + .tss-session-title::before { + content: none; + } + + .tss-caret { + margin-right: 0.25rem; + } + + .tss-session-name { + display: -webkit-box; + grid-column: 2; + max-width: 100%; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .tss-session-badge { + grid-column: 2; + width: fit-content; + margin-left: 0; + } + + .tss-session-meta { + grid-column: 2; + } + + .tss-session-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .tss-session-row > td { + grid-template-columns: 1fr; + gap: 0.18rem; + } + + .tss-session-row > td[data-label="Models"] { + grid-column: 1 / -1; + } + + .tss-model-chips { + justify-content: flex-start; + } + + .tss-chip, + .tss-slug, + .tss-model-name { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .tss-session-detail-row { + border: 0 !important; + background: transparent !important; + } + + .tss-session-detail-row > td { + display: block; + padding: 0.55rem; + } + + .tss-session-detail-row > td::before { + content: none; + } + + .tss-table-inner tr { + border-radius: 10px; + } + + .tss-pager { + justify-content: space-between; + flex-wrap: wrap; + } + + .tss-pager-label { + order: -1; + width: 100%; + text-align: center; } } diff --git a/dashboard/shell/dist/source-stamp b/dashboard/shell/dist/source-stamp index b061cbba..dced7199 100644 --- a/dashboard/shell/dist/source-stamp +++ b/dashboard/shell/dist/source-stamp @@ -1 +1 @@ -5f87e758cc7f8b9a +662fcdcc6ee9da1a diff --git a/dashboard/smoke.mjs b/dashboard/smoke.mjs index 227c0ce5..aad62f7d 100644 --- a/dashboard/smoke.mjs +++ b/dashboard/smoke.mjs @@ -152,6 +152,8 @@ async function runViewportSmoke(browser, baseUrl, profile, expectLcmMode) { const similarityViewButton = page.getByRole("button", { name: "Similarity" }); await similarityViewButton.waitFor({ state: "visible" }); + await assertNoHorizontalOverflow(page); + await assertViewSwitcherLayout(page, profile.name); await similarityViewButton.click(); await page.getByText("Similar Pairs").waitFor({ state: "visible" }); @@ -216,6 +218,40 @@ async function runViewportSmoke(browser, baseUrl, profile, expectLcmMode) { await context.close(); } +async function assertNoHorizontalOverflow(page) { + const overflow = await page.evaluate(() => { + const doc = document.documentElement; + return { + clientWidth: doc.clientWidth, + scrollWidth: doc.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + }; + }); + if (overflow.scrollWidth > overflow.clientWidth + 1) { + throw new Error( + `dashboard has horizontal overflow: ${JSON.stringify(overflow)}`, + ); + } +} + +async function assertViewSwitcherLayout(page, profileName) { + if (profileName !== "narrow") return; + const layout = await page.locator(".hv-viewswitch").first().evaluate((el) => { + const style = window.getComputedStyle(el); + return { + flexWrap: style.flexWrap, + clientWidth: el.clientWidth, + scrollWidth: el.scrollWidth, + }; + }); + if (layout.flexWrap !== "nowrap") { + throw new Error(`narrow Holographic view switcher should not wrap: ${JSON.stringify(layout)}`); + } + if (layout.scrollWidth < layout.clientWidth) { + throw new Error(`narrow Holographic view switcher should remain scrollable: ${JSON.stringify(layout)}`); + } +} + async function main() { const urlArg = process.argv.find((arg) => arg.startsWith("--url=")); const explicitUrl = urlArg ? withTrailingSlash(urlArg.replace("--url=", "")) : null; diff --git a/dashboard/test/graph-logic.test.mjs b/dashboard/test/graph-logic.test.mjs index 251757f8..d553733e 100644 --- a/dashboard/test/graph-logic.test.mjs +++ b/dashboard/test/graph-logic.test.mjs @@ -63,8 +63,8 @@ test("search hint only as a fallback when nothing loaded on a non-empty index", test("label cap scales with viewport area and never starves", () => { assert.equal(labels.labelCapForArea(1280, 900), 38); - assert.equal(labels.labelCapForArea(420, 700), 9); - assert.equal(labels.labelCapForArea(100, 100), 6); + assert.equal(labels.labelCapForArea(420, 700), 5); + assert.equal(labels.labelCapForArea(100, 100), 4); }); test("overlapping labels collapse to the highest-degree one", () => { diff --git a/src/dashboard/curate_preview_store.rs b/src/dashboard/curate_preview_store.rs index 44dc441f..df69a0b0 100644 --- a/src/dashboard/curate_preview_store.rs +++ b/src/dashboard/curate_preview_store.rs @@ -1,10 +1,9 @@ //! Sidecar persistence for the dashboard's last dry-run curation preview. //! //! The preview lives in memory (`DashboardState::curate_preview`) and is -//! mirrored to `.tracedecay/dashboard/curation_preview.json` so a server +//! mirrored to `/dashboard/curation_preview.json` so a server //! restart does not lose it (the original `holographic_plus` backend also -//! persisted previews to a JSON file). If only the legacy `.tokensave/` -//! directory exists, the sidecar stays there for backward compatibility. +//! persisted previews to a JSON file). //! The sidecar is a best-effort cache: //! load/save/clear failures are logged and never fail an API request, and //! the API shape of `GET /curation/preview` is unchanged — staleness is @@ -14,19 +13,15 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Value}; -use crate::config::get_tracedecay_dir; - use super::CuratePreviewEntry; -pub(crate) fn sidecar_path(project_root: &Path) -> PathBuf { - get_tracedecay_dir(project_root) - .join("dashboard") - .join("curation_preview.json") +pub(crate) fn sidecar_path(dashboard_root: &Path) -> PathBuf { + dashboard_root.join("curation_preview.json") } /// Loads the persisted preview, or `None` when absent/unreadable/malformed. -pub(crate) async fn load(project_root: &Path) -> Option { - let path = sidecar_path(project_root); +pub(crate) async fn load(dashboard_root: &Path) -> Option { + let path = sidecar_path(dashboard_root); let bytes = tokio::fs::read(&path).await.ok()?; let value: Value = serde_json::from_slice(&bytes).ok()?; let report = value.get("report")?.clone(); @@ -46,8 +41,8 @@ pub(crate) async fn load(project_root: &Path) -> Option { }) } -pub(crate) async fn save(project_root: &Path, entry: &CuratePreviewEntry) { - let path = sidecar_path(project_root); +pub(crate) async fn save(dashboard_root: &Path, entry: &CuratePreviewEntry) { + let path = sidecar_path(dashboard_root); let payload = json!({ "report": entry.report, "saved_at": entry.saved_at, @@ -72,8 +67,8 @@ pub(crate) async fn save(project_root: &Path, entry: &CuratePreviewEntry) { } } -pub(crate) async fn clear(project_root: &Path) { - let path = sidecar_path(project_root); +pub(crate) async fn clear(dashboard_root: &Path) { + let path = sidecar_path(dashboard_root); match tokio::fs::remove_file(&path).await { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} diff --git a/src/dashboard/graph_api.rs b/src/dashboard/graph_api.rs index 1b83221a..16858ff7 100644 --- a/src/dashboard/graph_api.rs +++ b/src/dashboard/graph_api.rs @@ -1,11 +1,11 @@ //! Code graph dashboard API, backed by tracedecay's indexed graph tables. //! -//! The explorer reads the project-local `nodes`, `edges`, and `files` tables -//! directly and returns compact payloads suitable for search, inspection, -//! progressive subgraph expansion, and shortest-path queries. Every endpoint -//! is bounded: subgraphs cap node/edge counts, search is paginated, and the -//! path BFS caps depth and visited-set size, so responses stay interactive -//! even on graphs with tens of thousands of nodes. +//! The explorer reads the resolved project graph `nodes`, `edges`, and +//! `files` tables directly and returns compact payloads suitable for search, +//! inspection, progressive subgraph expansion, and shortest-path queries. +//! Every endpoint is bounded: subgraphs cap node/edge counts, search is +//! paginated, and the path BFS caps depth and visited-set size, so responses +//! stay interactive even on graphs with tens of thousands of nodes. use axum::extract::State; use axum::http::StatusCode; diff --git a/src/dashboard/graph_service.rs b/src/dashboard/graph_service.rs index a31a5f84..7ad587f7 100644 --- a/src/dashboard/graph_service.rs +++ b/src/dashboard/graph_service.rs @@ -119,7 +119,7 @@ fn collect_node_ids(nodes: &[Value]) -> Vec { } async fn nodes_by_ids(state: &DashboardState, ids: &[String]) -> Vec { - graph_queries::node_rows_by_ids(&state.mem_conn, ids) + graph_queries::node_rows_by_ids(&state.graph_conn, ids) .await .into_iter() .map(node_with_span) @@ -127,14 +127,14 @@ async fn nodes_by_ids(state: &DashboardState, ids: &[String]) -> Vec { } async fn edges_for_ids(state: &DashboardState, ids: &[String], limit: i64) -> Vec { - graph_queries::edge_rows_for_ids(&state.mem_conn, ids, limit).await + graph_queries::edge_rows_for_ids(&state.graph_conn, ids, limit).await } /// Total (in + out) edge count per node, for the given ids. Drives the UI's /// size encoding and the "+N collapsed neighbors" affordance. async fn degrees_for_ids(state: &DashboardState, ids: &[String]) -> BTreeMap { let mut degrees = BTreeMap::new(); - for row in graph_queries::degree_rows_for_ids(&state.mem_conn, ids).await { + for row in graph_queries::degree_rows_for_ids(&state.graph_conn, ids).await { if let (Some(id), Some(degree)) = ( row.get("node_id").and_then(Value::as_str), row.get("degree").and_then(Value::as_i64), @@ -168,19 +168,19 @@ static DEGREE_CACHE: OnceLock Arc { let fingerprint = ( - graph_queries::total_edges(&state.mem_conn).await, - graph_queries::max_edge_id(&state.mem_conn).await, + graph_queries::total_edges(&state.graph_conn).await, + graph_queries::max_edge_id(&state.graph_conn).await, ); let cache = DEGREE_CACHE.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); // Held across the rebuild so concurrent requests share one aggregation. let mut guard = cache.lock().await; - if let Some(existing) = guard.get(&state.mem_db_path) { + if let Some(existing) = guard.get(&state.graph_db_path) { if existing.fingerprint == fingerprint { return existing.clone(); } } - let pool = graph_queries::degree_pool_rows(&state.mem_conn, DEGREE_POOL_CAP) + let pool = graph_queries::degree_pool_rows(&state.graph_conn, DEGREE_POOL_CAP) .await .iter() .filter_map(|row| { @@ -189,33 +189,33 @@ async fn degree_summary(state: &DashboardState) -> Arc { .map(|id| (id.to_string(), i64_field(row, "degree"))) }) .collect(); - let top_connected = graph_queries::top_connected_rows(&state.mem_conn).await; + let top_connected = graph_queries::top_connected_rows(&state.graph_conn).await; let summary = Arc::new(DegreeSummary { fingerprint, pool, top_connected, }); - guard.insert(state.mem_db_path.clone(), summary.clone()); + guard.insert(state.graph_db_path.clone(), summary.clone()); summary } pub(crate) async fn overview_payload(state: &DashboardState) -> Value { - let files = graph_queries::overview_file_rows(&state.mem_conn).await; + let files = graph_queries::overview_file_rows(&state.graph_conn).await; let summary = degree_summary(state).await; json!({ - "path": state.mem_db_path, + "path": state.graph_db_path, "totals": { - "nodes": graph_queries::total_nodes(&state.mem_conn).await, - "edges": graph_queries::total_edges(&state.mem_conn).await, - "files": graph_queries::total_files(&state.mem_conn).await, + "nodes": graph_queries::total_nodes(&state.graph_conn).await, + "edges": graph_queries::total_edges(&state.graph_conn).await, + "files": graph_queries::total_files(&state.graph_conn).await, }, - "nodes_by_kind": graph_queries::node_counts_by_kind(&state.mem_conn).await, - "edges_by_kind": graph_queries::edge_counts_by_kind(&state.mem_conn).await, + "nodes_by_kind": graph_queries::node_counts_by_kind(&state.graph_conn).await, + "edges_by_kind": graph_queries::edge_counts_by_kind(&state.graph_conn).await, "files_by_language": rows_by_language(&files), "top_connected": summary.top_connected, - "largest_files": graph_queries::largest_files(&state.mem_conn).await, + "largest_files": graph_queries::largest_files(&state.graph_conn).await, }) } @@ -225,8 +225,8 @@ pub(crate) async fn search_payload( limit: i64, offset: i64, ) -> Value { - let total = graph_queries::search_total(&state.mem_conn, query).await; - let results = graph_queries::search_rows(&state.mem_conn, query, limit, offset).await; + let total = graph_queries::search_total(&state.graph_conn, query).await; + let results = graph_queries::search_rows(&state.graph_conn, query, limit, offset).await; let ids = collect_node_ids(&results); let degrees = degrees_for_ids(state, &ids).await; let results = attach_degrees(results.into_iter().map(node_with_span).collect(), °rees); @@ -242,11 +242,11 @@ pub(crate) async fn search_payload( } pub(crate) async fn node_exists(state: &DashboardState, node_id: &str) -> bool { - graph_queries::node_exists(&state.mem_conn, node_id).await + graph_queries::node_exists(&state.graph_conn, node_id).await } pub(crate) async fn node_payload(state: &DashboardState, node_id: &str) -> Option { - let row = graph_queries::node_row(&state.mem_conn, node_id).await?; + let row = graph_queries::node_row(&state.graph_conn, node_id).await?; let degrees = degrees_for_ids(state, &[node_id.to_string()]).await; let node = attach_degrees(vec![node_with_span(row)], °rees) .into_iter() @@ -256,10 +256,10 @@ pub(crate) async fn node_payload(state: &DashboardState, node_id: &str) -> Optio } pub(crate) async fn neighbors_payload(state: &DashboardState, node_id: &str, limit: i64) -> Value { - let callers = graph_queries::caller_rows(&state.mem_conn, node_id, limit).await; - let callees = graph_queries::callee_rows(&state.mem_conn, node_id, limit).await; - let edges = graph_queries::neighborhood_edge_rows(&state.mem_conn, node_id, limit).await; - let edges_by_kind = graph_queries::neighborhood_edge_counts(&state.mem_conn, node_id).await; + let callers = graph_queries::caller_rows(&state.graph_conn, node_id, limit).await; + let callees = graph_queries::callee_rows(&state.graph_conn, node_id, limit).await; + let edges = graph_queries::neighborhood_edge_rows(&state.graph_conn, node_id, limit).await; + let edges_by_kind = graph_queries::neighborhood_edge_counts(&state.graph_conn, node_id).await; let mut neighbor_ids = collect_node_ids(&callers); neighbor_ids.extend(collect_node_ids(&callees)); @@ -393,7 +393,7 @@ async fn default_subgraph(state: &DashboardState, node_limit: i64, edge_limit: i .collect(); let nodes = attach_degrees(nodes_by_ids(state, &selected).await, °rees); - let total_nodes = graph_queries::total_nodes(&state.mem_conn).await; + let total_nodes = graph_queries::total_nodes(&state.graph_conn).await; json!({ "seed_id": Value::Null, @@ -421,7 +421,8 @@ pub(crate) async fn subgraph_payload( let seed_id = match node_id.filter(|id| !id.trim().is_empty()) { Some(id) => Some(id), None if !query.is_empty() => { - let Some(id) = graph_queries::first_node_for_query(&state.mem_conn, query).await else { + let Some(id) = graph_queries::first_node_for_query(&state.graph_conn, query).await + else { // Explicit query with no hit: an empty payload, not the // default slice, so a failed search reads as "no match". return json!({ @@ -441,7 +442,7 @@ pub(crate) async fn subgraph_payload( return default_subgraph(state, node_limit, edge_limit).await; }; - let candidate_rows = graph_queries::subgraph_candidate_rows(&state.mem_conn, &seed_id).await; + let candidate_rows = graph_queries::subgraph_candidate_rows(&state.graph_conn, &seed_id).await; let mut all_ids = Vec::new(); let mut seen = BTreeSet::new(); for row in candidate_rows { @@ -511,7 +512,7 @@ pub(crate) async fn path_payload( } let mut next = Vec::new(); for chunk in frontier.chunks(400) { - for row in graph_queries::frontier_edge_rows(&state.mem_conn, chunk).await { + for row in graph_queries::frontier_edge_rows(&state.graph_conn, chunk).await { let Some(source) = row.get("source").and_then(Value::as_str) else { continue; }; diff --git a/src/dashboard/lcm_api.rs b/src/dashboard/lcm_api.rs index a7a76a33..91668f3c 100644 --- a/src/dashboard/lcm_api.rs +++ b/src/dashboard/lcm_api.rs @@ -1,13 +1,9 @@ //! LCM dashboard API, backed by tracedecay's LCM session store. //! -//! Port of the hermes-lcm `dashboard/plugin_api.py` onto the session-store -//! tables `lcm_raw_messages`, `lcm_summary_nodes`, and `lcm_summary_sources`. -//! The store served is selected by [`super::resolve_lcm_store`]: the -//! project-local `.tracedecay/sessions.db` (where transcript ingest writes) -//! by default, or the global DB under a `TRACEDECAY_GLOBAL_DB` override / -//! fallback. Every payload reports the active store via the additive -//! `path` + `storage_scope` fields. Payload shapes otherwise mirror the -//! original routes so the ported UI bundle works unchanged. +//! Serves Hermes-compatible LCM routes from `lcm_raw_messages`, +//! `lcm_summary_nodes`, and `lcm_summary_sources`. The store is selected by +//! [`super::resolve_lcm_store`], and every payload reports it via `path` and +//! `storage_scope`. //! //! Schema mapping (hermes-lcm → tracedecay): //! - `messages` → `lcm_raw_messages` (`source` ← `provider`, @@ -464,7 +460,7 @@ fn merge_object(target: &mut Map, value: Value) { fn lcm_storage_root(state: &DashboardState) -> PathBuf { Path::new(&state.lcm_db_path) .parent() - .map_or_else(|| state.project_root.join(".tracedecay"), Path::to_path_buf) + .map_or_else(|| state.store_root.clone(), Path::to_path_buf) } fn now_unix() -> i64 { diff --git a/src/dashboard/memory_api.rs b/src/dashboard/memory_api.rs index 643ab864..305ccb9d 100644 --- a/src/dashboard/memory_api.rs +++ b/src/dashboard/memory_api.rs @@ -24,9 +24,12 @@ use serde_json::{json, Map, Value}; use super::memory_analysis::{SIMILARITY_DEFAULT_THRESHOLD, SIMILARITY_PAIR_CAP}; use super::memory_service; -use super::util::{coerce_limit, http_detail, JsonPath, JsonQuery}; +use super::util::{coerce_limit, http_detail, query_i64, JsonPath, JsonQuery}; use super::DashboardState; -use crate::tracedecay::TraceDecay; +use crate::memory::encoding::HolographicEncoder; +use crate::memory::store::MemoryStore; +use crate::memory::trust::DEFAULT_MIN_TRUST; +use crate::memory::types::{MemoryRepairStats, MemoryStatus}; #[derive(Deserialize)] pub(crate) struct OverviewParams { @@ -81,11 +84,105 @@ async fn largest_bank_fact_count(state: &DashboardState) -> Result Ok(row.get::(0).unwrap_or(0).max(0)) } -async fn memory_status_payload(state: &DashboardState) -> Result { - let cg = TraceDecay::open(&state.project_root) +pub(crate) async fn repair_derived_memory( + state: &DashboardState, +) -> Result { + let store = MemoryStore::new(&state.mem_conn); + let mut missing_vectors_repaired = 0; + loop { + let repaired = store + .compute_missing_vectors(500) + .await + .map_err(|e| e.to_string())?; + if repaired == 0 { + break; + } + missing_vectors_repaired += repaired; + } + + let banks_rebuilt = store + .rebuild_dirty_banks() .await .map_err(|e| e.to_string())?; - let status = cg.memory_status().await.map_err(|e| e.to_string())?; + + Ok(MemoryRepairStats { + missing_vectors_repaired, + banks_rebuilt, + }) +} + +async fn memory_status_payload(state: &DashboardState) -> Result { + let hrr_dim = HolographicEncoder::DIMENSIONS; + let repair = repair_derived_memory(state).await?; + let status = MemoryStatus { + fact_count: query_i64(&state.mem_conn, "SELECT COUNT(*) FROM memory_facts", ()).await + as usize, + entity_count: query_i64(&state.mem_conn, "SELECT COUNT(*) FROM memory_entities", ()).await + as usize, + bank_count: query_i64(&state.mem_conn, "SELECT COUNT(*) FROM memory_banks", ()).await + as usize, + algebra_name: "amari_fhrr".to_string(), + hrr_dim, + estimated_capacity: (hrr_dim as f64 / (hrr_dim as f64).ln()).round() as usize, + trust_0_025_count: query_i64( + &state.mem_conn, + "SELECT COUNT(*) FROM memory_facts WHERE trust_score < 0.25", + (), + ) + .await as usize, + trust_025_050_count: query_i64( + &state.mem_conn, + "SELECT COUNT(*) FROM memory_facts WHERE trust_score >= 0.25 AND trust_score < 0.50", + (), + ) + .await as usize, + trust_050_075_count: query_i64( + &state.mem_conn, + "SELECT COUNT(*) FROM memory_facts WHERE trust_score >= 0.50 AND trust_score < 0.75", + (), + ) + .await as usize, + trust_075_100_count: query_i64( + &state.mem_conn, + "SELECT COUNT(*) FROM memory_facts WHERE trust_score >= 0.75", + (), + ) + .await as usize, + below_default_recall_threshold_count: query_i64( + &state.mem_conn, + "SELECT COUNT(*) FROM memory_facts WHERE trust_score < ?1", + libsql::params![DEFAULT_MIN_TRUST], + ) + .await as usize, + helpful_count: query_i64( + &state.mem_conn, + "SELECT COALESCE(SUM(helpful_count), 0) FROM memory_facts", + (), + ) + .await as usize, + unhelpful_count: query_i64( + &state.mem_conn, + "SELECT COALESCE(SUM(unhelpful_count), 0) FROM memory_facts", + (), + ) + .await as usize, + missing_vector_count: query_i64( + &state.mem_conn, + "SELECT COUNT(*) FROM memory_facts + WHERE hrr_vector IS NULL OR hrr_algebra != 'amari_fhrr' OR hrr_dim != ?1", + libsql::params![hrr_dim as i64], + ) + .await as usize, + legacy_backfill_complete: query_i64( + &state.mem_conn, + "SELECT COUNT(*) FROM memory_facts + WHERE json_extract(metadata, '$.holographic_memory_backfill_v1') = 1", + (), + ) + .await + > 0, + repair, + }; let largest_bank_fact_count = largest_bank_fact_count(state).await?; let largest_bank_utilization_pct = if status.estimated_capacity > 0 { largest_bank_fact_count as f64 / status.estimated_capacity as f64 * 100.0 @@ -106,13 +203,11 @@ async fn fact_trust_history_payload( state: &DashboardState, fact_id: i64, ) -> Result, String> { - let cg = TraceDecay::open(&state.project_root) - .await - .map_err(|e| e.to_string())?; - let Some(_fact) = cg.get_fact(fact_id).await.map_err(|e| e.to_string())? else { + let store = MemoryStore::new(&state.mem_conn); + let Some(_fact) = store.get_fact(fact_id).await.map_err(|e| e.to_string())? else { return Ok(None); }; - let trust_history = cg + let trust_history = store .fact_trust_history(fact_id) .await .map_err(|e| e.to_string())?; diff --git a/src/dashboard/memory_curate.rs b/src/dashboard/memory_curate.rs index a68db86e..e68cff3a 100644 --- a/src/dashboard/memory_curate.rs +++ b/src/dashboard/memory_curate.rs @@ -23,7 +23,7 @@ use super::memory_service::{ apply_delete_op, apply_merge_op, build_delete_plan, delete_fact, similarity_computation, }; use super::util::{qmarks, query_rows}; -use super::{token_count, DashboardState}; +use super::{storage_mode_label, token_count, DashboardState}; use crate::errors::{Result, TraceDecayError}; use crate::tracedecay::TraceDecay; @@ -113,19 +113,22 @@ impl Default for MemoryCurateOptions { /// Minimal dashboard state over the project memory store — no LCM store, /// savings DB, or token-count cache warmup (those belong to the server). async fn cli_state(cg: &TraceDecay) -> DashboardState { - // Same vector/bank repair the dashboard runs before serving similarity. - if let Err(err) = cg.memory_status().await { - eprintln!("Warning: memory repair failed: {err}"); - } + let (mem_conn, mem_db_path) = super::resolve_project_memory_store(cg).await; + let store_layout = cg.store_layout(); DashboardState { - mem_conn: cg.dashboard_connection(), - mem_db_path: cg.dashboard_db_path().display().to_string(), + graph_conn: cg.dashboard_connection(), + graph_db_path: cg.dashboard_db_path().display().to_string(), + mem_conn, + mem_db_path, lcm_conn: None, lcm_db_path: String::new(), - lcm_scope: "project_local", + lcm_scope: storage_mode_label(&store_layout.storage_mode).to_string(), savings_db: None, savings_db_path: String::new(), project_root: cg.project_root().to_path_buf(), + storage_mode: storage_mode_label(&store_layout.storage_mode).to_string(), + store_root: store_layout.data_root.clone(), + dashboard_root: store_layout.dashboard_root.clone(), curate_preview: Arc::new(RwLock::new(None)), token_counts: Arc::new(token_count::TokenCountCache::new()), } diff --git a/src/dashboard/memory_service.rs b/src/dashboard/memory_service.rs index c05946d8..e150c3e4 100644 --- a/src/dashboard/memory_service.rs +++ b/src/dashboard/memory_service.rs @@ -24,7 +24,7 @@ pub(crate) fn providers_stub() -> Value { "memory_options": [ { "name": "tracedecay", - "description": "TraceDecay holographic memory store (project-local memory_facts)." + "description": "TraceDecay holographic memory store (resolved project memory_facts)." } ], "context_engine": "tracedecay", @@ -57,6 +57,13 @@ pub(crate) async fn fetch_entities( } async fn trust_histogram(state: &DashboardState) -> Vec { + let Ok(rows) = memory_queries::trust_histogram_rows(state).await else { + return Vec::new(); + }; + if rows.is_empty() { + return Vec::new(); + } + let mut buckets: Vec = (0..10) .map(|i| { json!({ @@ -66,17 +73,15 @@ async fn trust_histogram(state: &DashboardState) -> Vec { }) }) .collect(); - if let Ok(rows) = memory_queries::trust_histogram_rows(state).await { - for row in rows { - let idx = row - .get("bucket") - .and_then(Value::as_i64) - .unwrap_or(0) - .clamp(0, 9) as usize; - let added = row.get("count").and_then(Value::as_i64).unwrap_or(0); - if let Some(count) = buckets[idx].get_mut("count") { - *count = json!(count.as_i64().unwrap_or(0) + added); - } + for row in rows { + let idx = row + .get("bucket") + .and_then(Value::as_i64) + .unwrap_or(0) + .clamp(0, 9) as usize; + let added = row.get("count").and_then(Value::as_i64).unwrap_or(0); + if let Some(count) = buckets[idx].get_mut("count") { + *count = json!(count.as_i64().unwrap_or(0) + added); } } buckets @@ -704,7 +709,7 @@ pub(crate) async fn curate_payload(state: &DashboardState, dry_run: bool) -> Res active_facts_at_save: total, memory_fingerprint_at_save, }; - super::curate_preview_store::save(&state.project_root, &entry).await; + super::curate_preview_store::save(&state.dashboard_root, &entry).await; *state.curate_preview.write().await = Some(entry); return Ok(report); } @@ -725,7 +730,7 @@ pub(crate) async fn curate_payload(state: &DashboardState, dry_run: bool) -> Res } *state.curate_preview.write().await = None; - super::curate_preview_store::clear(&state.project_root).await; + super::curate_preview_store::clear(&state.dashboard_root).await; let _ = MemoryStore::new(&state.mem_conn) .record_oplog( @@ -893,7 +898,7 @@ pub(crate) async fn curate_apply_payload(state: &DashboardState, ops: &[Value]) if deleted > 0 || merged > 0 { *state.curate_preview.write().await = None; - super::curate_preview_store::clear(&state.project_root).await; + super::curate_preview_store::clear(&state.dashboard_root).await; let _ = MemoryStore::new(&state.mem_conn) .record_oplog( "curate_apply", diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index bee3dd81..d48863dd 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -8,9 +8,8 @@ //! - `/api/plugins/holographic/*` → project memory store //! (`memory_facts` / `memory_entities` / `memory_banks` in the project DB) //! - `/api/plugins/hermes-lcm/*` → LCM session store -//! (`lcm_raw_messages` / `lcm_summary_nodes` in the project-local -//! `.tracedecay/sessions.db` where transcript ingest writes; see -//! [`resolve_lcm_store`] for the `TRACEDECAY_GLOBAL_DB` override and the +//! (`lcm_raw_messages` / `lcm_summary_nodes` in the resolved active project +//! store where transcript ingest writes; see [`resolve_lcm_store`] for the //! global-DB fallback) //! //! The endpoint paths and JSON payload shapes intentionally mirror the @@ -40,7 +39,7 @@ mod savings_pricing; mod token_count; mod util; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use axum::extract::State; @@ -52,6 +51,7 @@ use tokio::sync::RwLock; use crate::errors::{Result, TraceDecayError}; use crate::global_db::GlobalDb; +use crate::storage::StorageMode; use crate::tracedecay::TraceDecay; /// Default port for `tracedecay dashboard` (chosen to avoid common dev-server @@ -74,23 +74,33 @@ pub(crate) struct CuratePreviewEntry { #[derive(Clone)] pub(crate) struct DashboardState { - /// Project database (code graph + holographic memory store). + /// Active code-graph database. This can be branch-specific. + pub(crate) graph_conn: libsql::Connection, + /// Display path of the active code-graph database. + pub(crate) graph_db_path: String, + /// Project memory database. This is shared across branches. pub(crate) mem_conn: libsql::Connection, - /// Display path of the project database. + /// Display path of the project memory database. pub(crate) mem_db_path: String, - /// LCM session store (project-local `sessions.db`, or the global DB - /// when overridden/unavailable), when available. + /// LCM session store for the resolved active project store, or the global + /// fallback when no project store is available. pub(crate) lcm_conn: Option, /// Display path of the LCM session store actually being served. pub(crate) lcm_db_path: String, - /// Which store `lcm_conn` points at: `"project_local"` or `"global"`. - pub(crate) lcm_scope: &'static str, + /// Which store `lcm_conn` points at, e.g. `"profile_sharded"` or `"global"`. + pub(crate) lcm_scope: String, /// Global accounting DB (savings ledger, lifetime counters, turns) used /// by the Savings & Cost tab, when available. pub(crate) savings_db: Option>, /// Display path of the global accounting DB. pub(crate) savings_db_path: String, pub(crate) project_root: PathBuf, + /// Storage mode resolved for the active project store. + pub(crate) storage_mode: String, + /// Resolved active project store root. + pub(crate) store_root: PathBuf, + /// Resolved dashboard sidecar root inside the active project store. + pub(crate) dashboard_root: PathBuf, /// Last saved dry-run curation preview (shared across all clones of the state). pub(crate) curate_preview: Arc>>, /// In-process BPE token-count cache for the Savings & Cost tab (backed @@ -102,32 +112,28 @@ pub(crate) struct DashboardState { pub(crate) struct LcmStoreSelection { pub(crate) conn: Option, pub(crate) path: String, - pub(crate) scope: &'static str, + pub(crate) scope: String, } -/// Selects the LCM session store for `project_root`. +/// Selects the LCM session store for the resolved active project store. /// -/// Transcript ingest writes per project: Cursor's end-of-turn hooks and the -/// MCP serve startup catch-up sweep (Claude/Codex/Vibe/Cline-like) both -/// upsert into `/.tracedecay/sessions.db`, never into -/// `~/.tracedecay/global.db`. So the dashboard serves the project-local store -/// by default — opened with the same writable schema-ensuring path the MCP -/// LCM tools use for `storage_scope = "project_local"`, creating it on first -/// run. +/// Transcript ingest writes to the active code-project store selected by the +/// storage resolver. For profile-backed projects, that is the user-level shard +/// under `~/.tracedecay/projects//`, not a repo-local DB. /// -/// An explicit `TRACEDECAY_GLOBAL_DB` override always wins (scope `"global"`; -/// legacy `TOKENSAVE_GLOBAL_DB` is still accepted): tests, the smoke harness, -/// and the Hermes wrapper use it to pin the -/// dashboard to a specific store. The legacy global DB is also the fallback -/// if the project store cannot be opened. -pub(crate) async fn resolve_lcm_store(project_root: &std::path::Path) -> LcmStoreSelection { - if !crate::global_db::global_db_path_is_overridden() { - let project_db_path = crate::sessions::cursor::project_session_db_path(project_root); +/// The global DB is only a fallback for sessions. `TRACEDECAY_GLOBAL_DB` +/// still controls the savings/accounting ledger, but it must not pull the +/// dashboard away from the resolved active project store transcript ingest uses. +pub(crate) async fn resolve_lcm_store(cg: &TraceDecay) -> LcmStoreSelection { + let project_root = cg.project_root(); + if let Some(project_db_path) = + crate::sessions::cursor::resolved_project_session_db_path(project_root).await + { if let Some(db) = GlobalDb::open_at(&project_db_path).await { return LcmStoreSelection { conn: Some(db.dashboard_connection()), path: project_db_path.display().to_string(), - scope: "project_local", + scope: storage_mode_label(&cg.store_layout().storage_mode).to_string(), }; } } @@ -137,36 +143,100 @@ pub(crate) async fn resolve_lcm_store(project_root: &std::path::Path) -> LcmStor path: crate::global_db::global_db_path() .map(|p| p.display().to_string()) .unwrap_or_default(), - scope: "global", + scope: "global".to_string(), } } +pub(crate) fn storage_mode_label(mode: &StorageMode) -> &'static str { + match mode { + StorageMode::ProjectLocal => "project_local", + StorageMode::ProfileSharded => "profile_sharded", + } +} + +async fn open_dashboard_connection(path: &Path) -> Option { + let db = libsql::Builder::new_local(path).build().await.ok()?; + db.connect().ok() +} + +async fn memory_fact_count(conn: &libsql::Connection) -> Option { + let mut rows = conn + .query("SELECT COUNT(*) FROM memory_facts", ()) + .await + .ok()?; + rows.next().await.ok()??.get::(0).ok() +} + +pub(crate) async fn resolve_project_memory_store(cg: &TraceDecay) -> (libsql::Connection, String) { + let candidates = [cg.store_layout().graph_db_path.clone()]; + let graph_path = cg.dashboard_db_path(); + let mut first_open: Option<(libsql::Connection, String)> = None; + let mut seen = std::collections::BTreeSet::new(); + + for path in candidates { + if !seen.insert(path.clone()) || !path.is_file() { + continue; + } + let conn = if path == graph_path { + Some(cg.dashboard_connection()) + } else { + open_dashboard_connection(&path).await + }; + let Some(conn) = conn else { + continue; + }; + let display_path = path.display().to_string(); + if first_open.is_none() { + first_open = Some((conn.clone(), display_path.clone())); + } + if memory_fact_count(&conn).await.unwrap_or(0) > 0 { + return (conn, display_path); + } + } + + first_open.unwrap_or_else(|| { + ( + cg.dashboard_connection(), + cg.dashboard_db_path().display().to_string(), + ) + }) +} + /// Builds the dashboard state shared by the CLI `run` path and the /// `tracedecay_dashboard` MCP tool. pub(crate) async fn build_state(cg: &TraceDecay) -> DashboardState { - if let Err(err) = cg.memory_status().await { - eprintln!("Warning: dashboard memory repair failed: {err}"); - } - let lcm = resolve_lcm_store(cg.project_root()).await; + let (mem_conn, mem_db_path) = resolve_project_memory_store(cg).await; + let lcm = resolve_lcm_store(cg).await; // Re-hydrate the last dry-run curation preview from its sidecar so it // survives server restarts (staleness is recomputed on read anyway). - let persisted_preview = curate_preview_store::load(cg.project_root()).await; + let dashboard_root = cg.store_layout().dashboard_root.clone(); + let store_root = cg.store_layout().data_root.clone(); + let storage_mode = storage_mode_label(&cg.store_layout().storage_mode).to_string(); + let persisted_preview = curate_preview_store::load(&dashboard_root).await; let savings_db = GlobalDb::open().await.map(Arc::new); let savings_db_path = crate::global_db::global_db_path() .map(|p| p.display().to_string()) .unwrap_or_default(); let state = DashboardState { - mem_conn: cg.dashboard_connection(), - mem_db_path: cg.dashboard_db_path().display().to_string(), + graph_conn: cg.dashboard_connection(), + graph_db_path: cg.dashboard_db_path().display().to_string(), + mem_conn, + mem_db_path, lcm_conn: lcm.conn, lcm_db_path: lcm.path, lcm_scope: lcm.scope, savings_db, savings_db_path, project_root: cg.project_root().to_path_buf(), + storage_mode, + store_root, + dashboard_root, curate_preview: Arc::new(RwLock::new(persisted_preview)), token_counts: Arc::new(token_count::TokenCountCache::new()), }; + if let Err(err) = memory_api::repair_derived_memory(&state).await { + eprintln!("Dashboard memory repair skipped: {err}"); + } // Pre-count non-usage messages in the background so the first Savings // tab paint doesn't pay the initial BPE pass over the session store. token_count::spawn_warm(state.clone()); @@ -206,7 +276,7 @@ fn config_error(message: impl Into) -> TraceDecayError { /// Pass `open: true` to also open the URL in the default browser (CLI --open). pub async fn run(cg: &TraceDecay, host: &str, port: u16, open: bool) -> Result<()> { let state = build_state(cg).await; - if state.lcm_scope == "project_local" { + if state.lcm_scope != "global" { spawn_session_catch_up_ingest(state.project_root.clone()); } @@ -340,19 +410,24 @@ pub(crate) fn router(state: DashboardState) -> Router { /// Capability discovery for hosts and future Hermes-side extensions. The UI /// (or a wrapper) can probe this to decide which panels/actions to enable. async fn capabilities(State(state): State) -> Json { + let has_lcm = state.lcm_conn.is_some(); Json(json!({ "name": "tracedecay-dashboard", "version": env!("CARGO_PKG_VERSION"), "mode": "standalone", "project_root": state.project_root.display().to_string(), + "storage_mode": state.storage_mode, + "store_root": state.store_root.display().to_string(), + "dashboard_root": state.dashboard_root.display().to_string(), "memory_db": state.mem_db_path, + "graph_db": state.graph_db_path, "lcm_db": state.lcm_db_path, "lcm_scope": state.lcm_scope, "features": { "memory": true, - "lcm": state.lcm_conn.is_some(), - "lcm_gc": state.lcm_conn.is_some(), - "lcm_payload_health": state.lcm_conn.is_some(), + "lcm": has_lcm, + "lcm_gc": has_lcm, + "lcm_payload_health": has_lcm, "graph": true, // Similarity-based dedup curation (delete/merge ops via /curate // and /curate/apply). LLM-proposed curation is a host-side diff --git a/src/dashboard/savings_api.rs b/src/dashboard/savings_api.rs index 7726fc3b..69aa798e 100644 --- a/src/dashboard/savings_api.rs +++ b/src/dashboard/savings_api.rs @@ -9,8 +9,8 @@ //! cost computed from real usage data at ingest — labeled `actual`). //! Ledger aggregation reuses [`GlobalDb::sum_savings`] / //! [`GlobalDb::savings_history`], the same queries `tracedecay gain` runs. -//! - **Session store** (the LCM store the dashboard already serves — -//! project-local `sessions.db` by default): `sessions` + +//! - **Session store** (the resolved LCM store the dashboard already serves): +//! `sessions` + //! `session_messages`, whose `model` and `metadata_json` columns drive //! per-session cost accounting. //! @@ -615,7 +615,8 @@ pub(crate) async fn models( let day_tiers = overlay.as_deref().map(|messages| { fold_overlay(messages, |msg| { let ts = msg.timestamp.unwrap_or(0); - (ts > 0 && (since == 0 || ts >= since)).then(|| (ts / 86_400) * 86_400) + (ts > 0 && (since == 0 || ts >= since)) + .then(|| ((ts / 86_400) * 86_400, msg.model.clone())) }) }); @@ -649,10 +650,18 @@ pub(crate) async fn models( ); let daily_sql = format!( - "SELECT (timestamp / 86400) * 86400 AS day, {TOKEN_AGG_COLUMNS} - FROM ({MESSAGE_TOKENS_CTE}) - WHERE timestamp IS NOT NULL AND timestamp > 0 AND (?1 = 0 OR timestamp >= ?1) - GROUP BY day ORDER BY day ASC LIMIT 366" + "WITH daily AS ( + SELECT (timestamp / 86400) * 86400 AS day, model, {TOKEN_AGG_COLUMNS} + FROM ({MESSAGE_TOKENS_CTE}) + WHERE timestamp IS NOT NULL AND timestamp > 0 AND (?1 = 0 OR timestamp >= ?1) + GROUP BY day, model + ), + latest_days AS ( + SELECT day FROM daily GROUP BY day ORDER BY day DESC LIMIT 366 + ) + SELECT daily.* + FROM daily JOIN latest_days ON latest_days.day = daily.day + ORDER BY daily.day ASC, daily.messages DESC" ); let daily_rows = query_rows(conn, &daily_sql, libsql::params![since]) .await @@ -662,8 +671,14 @@ pub(crate) async fn models( .iter() .map(|row| { let day = i64_field(row, "day"); - let tiers = day_tiers.as_ref().and_then(|map| map.get(&day)); - merge(token_block(row, tiers), json!({ "day": day })) + let model = str_field(row, "model"); + let tiers = day_tiers + .as_ref() + .and_then(|map| map.get(&(day, model.to_string()))); + merge( + token_block(row, tiers), + json!({ "day": day, "model": model_value(model) }), + ) }) .collect(), ); diff --git a/src/dashboard/savings_pricing.rs b/src/dashboard/savings_pricing.rs index 576919ff..eb6a3e0e 100644 --- a/src/dashboard/savings_pricing.rs +++ b/src/dashboard/savings_pricing.rs @@ -45,11 +45,9 @@ pub(crate) const CACHE_TTL_SECS: i64 = 86_400; /// Set to `1` to disable all network access for pricing. const OFFLINE_ENV: &str = "TRACEDECAY_OFFLINE"; -const OFFLINE_ENV_LEGACY: &str = "TOKENSAVE_OFFLINE"; /// Overrides the on-disk cache path (tests use a temp file). const CACHE_PATH_ENV: &str = "TRACEDECAY_MODEL_PRICES_PATH"; -const CACHE_PATH_ENV_LEGACY: &str = "TOKENSAVE_MODEL_PRICES_PATH"; /// Curated static snapshot of the `OpenRouter` response (same JSON shape). const FALLBACK_JSON: &str = include_str!("model_prices_fallback.json"); @@ -76,32 +74,18 @@ pub(crate) struct PriceTable { pub(crate) fetched_at: Option, } -fn env_with_legacy(primary: &str, legacy: &str) -> Option { - std::env::var(primary) - .ok() - .or_else(|| std::env::var(legacy).ok()) -} - fn cache_path() -> Option { - if let Some(path) = env_with_legacy(CACHE_PATH_ENV, CACHE_PATH_ENV_LEGACY) { + if let Ok(path) = std::env::var(CACHE_PATH_ENV) { if !path.is_empty() { return Some(PathBuf::from(path)); } } let home = dirs::home_dir()?; - let preferred = home.join(".tracedecay").join("model-prices.json"); - let legacy = home.join(".tokensave").join("model-prices.json"); - // Prefer the new data dir; fall back to the legacy cache location until - // users naturally migrate. - if preferred.exists() || !legacy.exists() { - Some(preferred) - } else { - Some(legacy) - } + Some(home.join(".tracedecay").join("model-prices.json")) } fn offline() -> bool { - env_with_legacy(OFFLINE_ENV, OFFLINE_ENV_LEGACY).is_some_and(|v| !v.is_empty() && v != "0") + std::env::var(OFFLINE_ENV).is_ok_and(|v| !v.is_empty() && v != "0") } /// Reads a price field that `OpenRouter` serves as a per-token decimal string diff --git a/src/dashboard/token_count.rs b/src/dashboard/token_count.rs index 7d39abb9..1f9e4e13 100644 --- a/src/dashboard/token_count.rs +++ b/src/dashboard/token_count.rs @@ -60,7 +60,8 @@ pub(super) const MESSAGE_TOKENS_CTE: &str = " CASE WHEN json_valid(metadata_json) THEN CAST(json_extract(metadata_json, '$.usage.cache_creation_input_tokens') AS INTEGER) END AS usage_cache_write - FROM session_messages"; + FROM session_messages + WHERE kind IS NULL OR kind <> 'summary'"; /// Which BPE vocabulary a model id maps to, and whether the resulting count /// is exact (the model's real tokenizer) or a labeled approximation. @@ -145,13 +146,15 @@ struct CachedCount { /// Cached non-usage overlay plus the `session_messages` fingerprint it was /// built from. struct OverlayCache { - /// `(COUNT(*), MAX(rowid))` of `session_messages` at build time. Inserts - /// change both; deletes change the count. (In-place row rewrites that - /// keep count and rowid are not detected — ingest only inserts.) - fingerprint: (i64, i64), + /// Cheap aggregate fingerprint of `session_messages` at build time. + /// Includes metadata/text/model lengths so usage backfills invalidate the + /// overlay even when they update existing rows in place. + fingerprint: OverlayFingerprint, overlay: Arc>, } +type OverlayFingerprint = (i64, i64, i64, i64, i64); + /// Process-lifetime token-count cache shared by all savings endpoints. pub(crate) struct TokenCountCache { map: Mutex>, @@ -224,11 +227,16 @@ pub(crate) async fn non_usage_message_tokens( Some(overlay) } -/// `(COUNT(*), MAX(rowid))` of `session_messages` — see [`OverlayCache`]. -async fn overlay_fingerprint(conn: &libsql::Connection) -> Option<(i64, i64)> { +/// Aggregate fingerprint of `session_messages` — see [`OverlayCache`]. +async fn overlay_fingerprint(conn: &libsql::Connection) -> Option { let rows = query_rows( conn, - "SELECT COUNT(*) AS count, COALESCE(MAX(rowid), 0) AS max_rowid FROM session_messages", + "SELECT COUNT(*) AS count, + COALESCE(MAX(rowid), 0) AS max_rowid, + COALESCE(SUM(LENGTH(COALESCE(metadata_json, ''))), 0) AS metadata_len, + COALESCE(SUM(LENGTH(COALESCE(text, ''))), 0) AS text_len, + COALESCE(SUM(LENGTH(COALESCE(model, ''))), 0) AS model_len + FROM session_messages", (), ) .await @@ -237,6 +245,9 @@ async fn overlay_fingerprint(conn: &libsql::Connection) -> Option<(i64, i64)> { Some(( row.get("count").and_then(Value::as_i64).unwrap_or(0), row.get("max_rowid").and_then(Value::as_i64).unwrap_or(0), + row.get("metadata_len").and_then(Value::as_i64).unwrap_or(0), + row.get("text_len").and_then(Value::as_i64).unwrap_or(0), + row.get("model_len").and_then(Value::as_i64).unwrap_or(0), )) } @@ -500,4 +511,62 @@ mod tests { let cl = count_text_tokens(text, "gpt-4"); assert!(cl > 0); } + + #[tokio::test] + async fn overlay_fingerprint_changes_when_metadata_is_backfilled() { + let db = match libsql::Builder::new_local(":memory:").build().await { + Ok(db) => db, + Err(err) => panic!("failed to build in-memory database: {err}"), + }; + let conn = match db.connect() { + Ok(conn) => conn, + Err(err) => panic!("failed to connect to in-memory database: {err}"), + }; + if let Err(err) = conn + .execute_batch( + "CREATE TABLE session_messages ( + provider TEXT NOT NULL, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + timestamp INTEGER, + ordinal INTEGER NOT NULL, + text TEXT NOT NULL, + kind TEXT, + model TEXT, + metadata_json TEXT, + PRIMARY KEY(provider, message_id) + ); + INSERT INTO session_messages + (provider, message_id, session_id, role, timestamp, ordinal, text, kind, model, metadata_json) + VALUES + ('codex', 'm1', 's1', 'assistant', 1, 1, 'hello', NULL, 'gpt-5', NULL);", + ) + .await + { + panic!("failed to seed session_messages: {err}"); + } + + let Some(before) = overlay_fingerprint(&conn).await else { + panic!("overlay fingerprint should exist"); + }; + if let Err(err) = conn + .execute( + "UPDATE session_messages + SET metadata_json = '{\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}' + WHERE provider = 'codex' AND message_id = 'm1'", + (), + ) + .await + { + panic!("failed to backfill metadata usage: {err}"); + } + let Some(after) = overlay_fingerprint(&conn).await else { + panic!("overlay fingerprint should exist after backfill"); + }; + + assert_eq!(before.0, after.0, "row count should be unchanged"); + assert_eq!(before.1, after.1, "max rowid should be unchanged"); + assert_ne!(before, after, "metadata backfill must invalidate overlay"); + } } diff --git a/tests/dashboard_api_test.rs b/tests/dashboard_api_test.rs index 246e328f..99174f07 100644 --- a/tests/dashboard_api_test.rs +++ b/tests/dashboard_api_test.rs @@ -18,6 +18,7 @@ use tracedecay::global_db::GlobalDb; use tracedecay::memory::encoding::HolographicEncoder; use tracedecay::sessions::lcm::{LcmSourceRef, LcmSummaryNodeDraft}; use tracedecay::sessions::{SessionMessageRecord, SessionRecord}; +use tracedecay::storage::{write_enrollment_marker, EnrollmentMarker, StorageMode}; use tracedecay::tracedecay::TraceDecay; /// Longer than 200 chars on purpose: list/projection payloads truncate @@ -367,10 +368,19 @@ async fn start_dashboard_fixture(seed_lcm: bool) -> DashboardFixture { .canonicalize() .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); let project_root = tmp_root.join("project"); - let global_db_path = tmp.path().join("global").join("global.db"); + let global_db_path = tmp_root.join("global").join("global.db"); let profile_root = tmp_root.join("profile").join(".tracedecay"); let env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); let data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + if let Err(err) = write_enrollment_marker( + &project_root, + &EnrollmentMarker { + project_id: "dashboard_fixture".to_string(), + storage_mode: StorageMode::ProfileSharded, + }, + ) { + panic!("failed to enroll dashboard fixture in profile storage: {err}"); + } let cg = setup_project(&project_root).await; seed_memory_fixture(&cg).await; @@ -382,10 +392,12 @@ async fn start_dashboard_fixture(seed_lcm: bool) -> DashboardFixture { global_db_path.display() ), }; + drop(global_db); if seed_lcm { - seed_lcm_fixture(&global_db, &project_root).await; + let session_store = open_project_session_store(&project_root).await; + seed_lcm_fixture(&session_store, &project_root).await; + drop(session_store); } - drop(global_db); let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); @@ -599,7 +611,7 @@ fn dashboard_memory_repairs_vectors_and_invalidates_similarity_cache() { assert_eq!( initial["pairs"].as_array().map(Vec::len), Some(3), - "dashboard startup backfills legacy fixture vectors, so all three seeded facts participate in the >=0.99 similarity set" + "dashboard startup should repair stale seeded vectors before similarity reads" ); set_fact_access_without_touching_updated_at(&fixture, 102, 7, 1_700_000_500).await; @@ -642,7 +654,8 @@ fn dashboard_memory_repairs_vectors_and_invalidates_similarity_cache() { clear_fact_vector_without_touching_updated_at(&fixture, 103).await; let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); - let cg = match TraceDecay::open(&fixture.project_root).await { + let project_root = fixture.project_root.clone(); + let cg = match TraceDecay::open(&project_root).await { Ok(cg) => cg, Err(err) => panic!("failed to reopen fixture project: {err}"), }; @@ -726,7 +739,129 @@ fn dashboard_reports_resolved_branch_db_path() { let (status, capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); server.abort(); assert_eq!(status, 200); - assert_eq!(capabilities["memory_db"], expected); + assert_eq!(capabilities["graph_db"], expected); + }); +} + +#[test] +fn dashboard_uses_project_memory_db_and_branch_graph_db() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let tmp = tempdir_or_panic(); + let project_root = tmp.path().join("project"); + let global_db_path = tmp.path().join("global").join("global.db"); + let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + + fs::create_dir_all(project_root.join("src")) + .unwrap_or_else(|err| panic!("failed to create src dir: {err}")); + git(&project_root, &["init", "-b", "main"]); + fs::write( + project_root.join("src/lib.rs"), + "pub fn main_branch_symbol() {}\n", + ) + .unwrap_or_else(|err| panic!("failed to write fixture lib.rs: {err}")); + commit_all(&project_root, "initial commit"); + + let main = match TraceDecay::init(&project_root).await { + Ok(cg) => cg, + Err(err) => panic!("failed to initialize fixture project: {err}"), + }; + index_all_retrying_sync_lock(&main, "failed to index main branch fixture").await; + drop(main); + + git(&project_root, &["checkout", "-b", "feature/dashboard-storage"]); + fs::write( + project_root.join("src/feature.rs"), + "pub fn feature_branch_symbol() {}\n", + ) + .unwrap_or_else(|err| panic!("failed to write feature fixture: {err}")); + if let Err(err) = branch::add_branch_tracking(&project_root, "feature/dashboard-storage").await + { + panic!("failed to track feature branch: {err}"); + } + + let cg = match TraceDecay::open(&project_root).await { + Ok(cg) => cg, + Err(err) => panic!("failed to open feature branch fixture: {err}"), + }; + let project_db_path = cg.store_layout().graph_db_path.clone(); + let project_db = libsql::Builder::new_local(&project_db_path) + .build() + .await + .unwrap_or_else(|err| panic!("failed to open project DB: {err}")); + let project_conn = project_db + .connect() + .unwrap_or_else(|err| panic!("failed to connect to project DB: {err}")); + project_conn + .execute( + "INSERT INTO memory_facts + (fact_id, content, category, tags, trust_score, retrieval_count, helpful_count, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + libsql::params![ + 9001_i64, + "Project memory must survive branch dashboard routing", + "project", + "[\"dashboard\",\"storage\"]", + 0.99_f64, + 1_i64, + 1_i64, + 1_700_100_000_i64, + 1_700_100_000_i64, + ], + ) + .await + .unwrap_or_else(|err| panic!("failed to seed project memory fact: {err}")); + + let branch_db_path = cg.db_path(); + assert!( + branch_db_path + .display() + .to_string() + .replace('\\', "/") + .contains("/branches/"), + "fixture should serve a branch graph DB path, got {}", + branch_db_path.display() + ); + + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let server = tokio::spawn(async move { + let _ = dashboard::run(&cg, "127.0.0.1", port, false).await; + }); + let agent = http_agent(); + wait_for_dashboard(&agent, &base_url).await; + + let (status, capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); + assert_eq!(status, 200); + assert_eq!(capabilities["memory_db"], project_db_path.display().to_string()); + assert_eq!(capabilities["graph_db"], branch_db_path.display().to_string()); + + let (status, memory) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/?limit=5&graph_limit=5"), + ); + assert_eq!(status, 200); + assert_eq!(memory["holographic"]["overview"]["facts"], 1); + + let (status, memory_status) = + get_json(&agent, &format!("{base_url}/api/plugins/holographic/status")); + assert_eq!(status, 200); + assert_eq!(memory_status["path"], project_db_path.display().to_string()); + assert_eq!(memory_status["memory"]["fact_count"], 1); + + let (status, graph_search) = get_json( + &agent, + &format!("{base_url}/api/plugins/graph/search?q=feature_branch_symbol"), + ); + server.abort(); + assert_eq!(status, 200); + assert!( + graph_search["total"].as_i64().unwrap_or_default() > 0, + "graph search should read the branch graph DB" + ); }); } @@ -1825,8 +1960,8 @@ fn lcm_endpoints_cover_seeded_fts_and_like_fallback() { assert_eq!(status, 200); assert_eq!(overview["exists"], true); assert_eq!( - overview["storage_scope"], "global", - "TRACEDECAY_GLOBAL_DB override fixtures serve the global scope" + overview["storage_scope"], "profile_sharded", + "LCM serves the resolved project session store even when TRACEDECAY_GLOBAL_DB is set for accounting" ); assert_eq!(overview["overview"]["messages_total"], 3); assert_eq!(overview["overview"]["sessions_total"], 1); @@ -1938,8 +2073,8 @@ fn lcm_endpoints_return_empty_state_when_no_rows_exist() { }); } -/// Opens (creating if needed) the project-local session store at -/// `/.tracedecay/sessions.db` — the DB transcript ingest writes to. +/// Opens (creating if needed) the resolved project session store — profile +/// sharded by default, project-local only for explicit or legacy projects. async fn open_project_session_store(project_root: &Path) -> GlobalDb { let db_path = tracedecay::sessions::cursor::project_session_db_path(project_root); match GlobalDb::open_at(&db_path).await { @@ -1952,9 +2087,8 @@ async fn open_project_session_store(project_root: &Path) -> GlobalDb { } /// Without a `TRACEDECAY_GLOBAL_DB` override the dashboard must serve the -/// project-local `.tracedecay/sessions.db` (where Cursor hooks and the -/// catch-up sweep ingest transcripts), and report it via the additive -/// `storage_scope` payload field. +/// resolved project session store, profile-sharded by default, and report it +/// via the additive `storage_scope` payload field. #[test] fn lcm_serves_project_session_store_without_global_override() { let _env_lock = GLOBAL_DB_ENV_LOCK @@ -1973,11 +2107,9 @@ fn lcm_serves_project_session_store_without_global_override() { let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); let cg = setup_project(&project_root).await; - let expected_session_db = - tracedecay::sessions::cursor::project_session_db_path(&project_root) - .display() - .to_string(); let session_store = open_project_session_store(&project_root).await; + let expected_session_path = + tracedecay::sessions::cursor::project_session_db_path(&project_root); seed_lcm_fixture(&session_store, &project_root).await; drop(session_store); @@ -1992,19 +2124,22 @@ fn lcm_serves_project_session_store_without_global_override() { let (status, capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); assert_eq!(status, 200); - assert_eq!(capabilities["lcm_scope"], "project_local"); + assert_eq!(capabilities["lcm_scope"], "profile_sharded"); assert_eq!(capabilities["features"]["lcm"], true); let lcm_db = capabilities["lcm_db"] .as_str() .unwrap_or_else(|| panic!("expected capabilities.lcm_db string")); - assert_eq!(lcm_db, expected_session_db); + assert!( + Path::new(lcm_db) == expected_session_path, + "capabilities.lcm_db should be the resolved project session store, got {lcm_db}" + ); let (status, overview) = get_json( &agent, &format!("{base_url}/api/plugins/hermes-lcm/overview?limit=20"), ); assert_eq!(status, 200); - assert_eq!(overview["storage_scope"], "project_local"); + assert_eq!(overview["storage_scope"], "profile_sharded"); assert_eq!(overview["exists"], true); assert_eq!(overview["overview"]["messages_total"], 3); assert_eq!(overview["overview"]["sessions_total"], 1); @@ -2012,14 +2147,17 @@ fn lcm_serves_project_session_store_without_global_override() { let path = overview["path"] .as_str() .unwrap_or_else(|| panic!("expected overview.path string")); - assert_eq!(path, expected_session_db); + assert!( + Path::new(path) == expected_session_path, + "overview.path should be the resolved project session store, got {path}" + ); let (status, search) = get_json( &agent, &format!("{base_url}/api/plugins/hermes-lcm/search?q=vector&limit=20"), ); assert_eq!(status, 200); - assert_eq!(search["storage_scope"], "project_local"); + assert_eq!(search["storage_scope"], "profile_sharded"); let search_messages = search["matches"]["messages"] .as_array() .unwrap_or_else(|| panic!("expected search.matches.messages array")); @@ -2032,11 +2170,10 @@ fn lcm_serves_project_session_store_without_global_override() { }); } -/// An explicit `TRACEDECAY_GLOBAL_DB` override pins the dashboard to that -/// store even when the project-local session store exists and has rows — -/// the contract the smoke harness and the Hermes wrapper rely on. +/// `TRACEDECAY_GLOBAL_DB` pins savings/accounting, but LCM sessions still +/// come from the resolved project store that transcript ingest writes. #[test] -fn lcm_global_override_wins_over_project_store() { +fn lcm_project_store_wins_over_global_accounting_override() { let _env_lock = GLOBAL_DB_ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -2054,8 +2191,10 @@ fn lcm_global_override_wins_over_project_store() { let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); let cg = setup_project(&project_root).await; - // The project store has rows; the overridden global store has none. + // The project store has rows; the overridden global accounting store has none. let session_store = open_project_session_store(&project_root).await; + let expected_session_path = + tracedecay::sessions::cursor::project_session_db_path(&project_root); seed_lcm_fixture(&session_store, &project_root).await; drop(session_store); @@ -2070,32 +2209,35 @@ fn lcm_global_override_wins_over_project_store() { let (status, capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); assert_eq!(status, 200); - assert_eq!(capabilities["lcm_scope"], "global"); + assert_eq!(capabilities["lcm_scope"], "profile_sharded"); let (status, overview) = get_json( &agent, &format!("{base_url}/api/plugins/hermes-lcm/overview?limit=20"), ); assert_eq!(status, 200); - assert_eq!(overview["storage_scope"], "global"); + assert_eq!(overview["storage_scope"], "profile_sharded"); assert_eq!(overview["exists"], true); assert_eq!( - overview["overview"]["messages_total"], 0, - "override must serve the pinned (empty) store, not the project store" + overview["overview"]["messages_total"], 3, + "LCM must serve the project store, not the empty accounting DB" ); let path = overview["path"] .as_str() .unwrap_or_else(|| panic!("expected overview.path string")); - assert_eq!(path, global_db_path.display().to_string()); + assert!( + Path::new(path) == expected_session_path, + "expected resolved project session DB path, got {path}" + ); server.abort(); }); } /// The dry-run curation preview must survive a dashboard restart: it is -/// mirrored to `.tracedecay/dashboard/curation_preview.json` and re-hydrated -/// by `build_state`, and applying curation clears both the memory copy and -/// the sidecar. +/// mirrored to the resolved dashboard sidecar path and re-hydrated by +/// `build_state`, and applying curation clears both the memory copy and the +/// sidecar. #[test] fn curation_preview_persists_across_dashboard_restarts() { let _env_lock = GLOBAL_DB_ENV_LOCK @@ -2117,9 +2259,9 @@ fn curation_preview_persists_across_dashboard_restarts() { let cg = setup_project(&project_root).await; seed_memory_fixture(&cg).await; let agent = http_agent(); - let sidecar = project_root - .join(".tracedecay") - .join("dashboard") + let sidecar = cg + .store_layout() + .dashboard_root .join("curation_preview.json"); async fn start_server(cg: TraceDecay) -> (String, tokio::task::JoinHandle<()>) { diff --git a/tests/dashboard_lcm_api_test.rs b/tests/dashboard_lcm_api_test.rs index 8615582a..10704a21 100644 --- a/tests/dashboard_lcm_api_test.rs +++ b/tests/dashboard_lcm_api_test.rs @@ -1,6 +1,6 @@ //! Integration tests for the LCM dashboard API //! (`/api/plugins/hermes-lcm/*`) against a seeded temp session store served -//! through the `TRACEDECAY_GLOBAL_DB` override path. +//! from the profile-sharded project session DB. #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -18,6 +18,7 @@ use serde_json::{json, Value}; use tempfile::TempDir; use tracedecay::dashboard; use tracedecay::global_db::GlobalDb; +use tracedecay::sessions::cursor::project_session_db_path; use tracedecay::sessions::lcm::{LcmCleanConfig, LcmGcConfig, LcmSourceRef, LcmSummaryNodeDraft}; use tracedecay::sessions::{SessionMessageRecord, SessionRecord}; use tracedecay::tracedecay::TraceDecay; @@ -29,7 +30,7 @@ struct Fixture { _env_guards: Vec, base_url: String, server: tokio::task::JoinHandle<()>, - global_db_path: std::path::PathBuf, + session_db_path: std::path::PathBuf, _project_root: std::path::PathBuf, session_id: String, child_node_id: String, @@ -217,12 +218,12 @@ async fn start_fixture() -> Fixture { let global_db_path = tmp.path().join("global").join("global.db"); let env_guards = vec![EnvVarGuard::set("TRACEDECAY_GLOBAL_DB", &global_db_path)]; - let (session_id, child_node_id, parent_node_id) = - seed_lcm_store(&global_db_path, &project_root).await; - let cg = TraceDecay::init(&project_root) .await .expect("tracedecay init"); + let session_db_path = project_session_db_path(&project_root); + let (session_id, child_node_id, parent_node_id) = + seed_lcm_store(&session_db_path, &project_root).await; let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); let server = tokio::spawn(async move { @@ -235,7 +236,7 @@ async fn start_fixture() -> Fixture { _env_guards: env_guards, base_url, server, - global_db_path, + session_db_path, _project_root: project_root, session_id, child_node_id, @@ -256,7 +257,7 @@ fn lcm_overview_and_search_preserve_shapes() { let (status, caps) = get_json(&agent, &format!("{}/api/capabilities", fixture.base_url)); assert_eq!(status, 200); assert_eq!(caps["features"]["lcm"], true); - assert_eq!(caps["lcm_scope"], "global"); + assert_eq!(caps["lcm_scope"], "profile_sharded"); let (status, overview) = get_json( &agent, @@ -267,7 +268,7 @@ fn lcm_overview_and_search_preserve_shapes() { ); assert_eq!(status, 200); assert_eq!(overview["exists"], true); - assert_eq!(overview["storage_scope"], "global"); + assert_eq!(overview["storage_scope"], "profile_sharded"); assert_eq!(overview["overview"]["messages_total"], 2); assert_eq!(overview["overview"]["summary_nodes_total"], 2); assert_eq!(overview["latest_sessions"][0]["session_id"], fixture.session_id); @@ -381,9 +382,9 @@ fn lcm_payload_health_and_gc_routes_require_preview_then_apply() { let runtime = create_runtime(); runtime.block_on(async { let fixture = start_fixture().await; - let db = GlobalDb::open_at(&fixture.global_db_path) + let db = GlobalDb::open_at(&fixture.session_db_path) .await - .expect("global db should reopen"); + .expect("session db should reopen"); let mut external = message( "payload-tool-1", &fixture.session_id, @@ -395,12 +396,12 @@ fn lcm_payload_health_and_gc_routes_require_preview_then_apply() { None, ); external.kind = Some("tool_result".to_string()); - db.lcm_store(fixture.global_db_path.parent().expect("global db parent")) + db.lcm_store(fixture.session_db_path.parent().expect("session db parent")) .ingest_raw_message(&external) .await .expect("payload-backed message should ingest"); let payload_dir = fixture - .global_db_path + .session_db_path .parent() .unwrap() .join("lcm-payloads"); @@ -488,9 +489,9 @@ fn lcm_payload_health_numbers_agree_across_status_doctor_and_dashboard() { let runtime = create_runtime(); runtime.block_on(async { let fixture = start_fixture().await; - let db = GlobalDb::open_at(&fixture.global_db_path) + let db = GlobalDb::open_at(&fixture.session_db_path) .await - .expect("global db should reopen"); + .expect("session db should reopen"); let body = format!("cross surface payload secret {}", "Y".repeat(300_000)); let mut external = message( "payload-tool-agreement", @@ -503,13 +504,13 @@ fn lcm_payload_health_numbers_agree_across_status_doctor_and_dashboard() { None, ); external.kind = Some("tool_result".to_string()); - db.lcm_store(fixture.global_db_path.parent().expect("global db parent")) + db.lcm_store(fixture.session_db_path.parent().expect("session db parent")) .ingest_raw_message(&external) .await .expect("payload-backed message should ingest"); let payload_dir = fixture - .global_db_path + .session_db_path .parent() .unwrap() .join("lcm-payloads"); diff --git a/tests/dashboard_lcm_fixes_test.rs b/tests/dashboard_lcm_fixes_test.rs index 399fd3ac..19b0284a 100644 --- a/tests/dashboard_lcm_fixes_test.rs +++ b/tests/dashboard_lcm_fixes_test.rs @@ -15,6 +15,7 @@ use serde_json::Value; use tempfile::TempDir; use tracedecay::dashboard; use tracedecay::global_db::GlobalDb; +use tracedecay::sessions::cursor::project_session_db_path; use tracedecay::sessions::lcm::{LcmSourceRef, LcmStorageKind, LcmSummaryNodeDraft}; use tracedecay::sessions::{SessionMessageRecord, SessionRecord}; use tracedecay::tracedecay::TraceDecay; @@ -307,24 +308,27 @@ async fn start_fixture(break_message_fts: bool) -> DashboardFixture { let env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); let cg = setup_project(&project_root).await; + let session_db_path = project_session_db_path(&project_root); - let global_db = match GlobalDb::open_at(&global_db_path).await { + let global_db = match GlobalDb::open_at(&session_db_path).await { Some(db) => db, None => panic!( - "failed to open temporary global DB at {}", - global_db_path.display() + "failed to open temporary session DB at {}", + session_db_path.display() ), }; - let storage_root = tmp.path().join("lcm-storage"); - if let Err(err) = std::fs::create_dir_all(&storage_root) { + let storage_root = session_db_path + .parent() + .unwrap_or_else(|| panic!("session DB has no parent: {}", session_db_path.display())); + if let Err(err) = std::fs::create_dir_all(storage_root) { panic!("failed to create LCM storage root: {err}"); } - let linked_node_id = seed_lcm_fixture(&global_db, &storage_root, &project_root).await; + let linked_node_id = seed_lcm_fixture(&global_db, storage_root, &project_root).await; drop(global_db); - plant_external_needle(&global_db_path).await; + plant_external_needle(&session_db_path).await; if break_message_fts { - drop_raw_message_fts(&global_db_path).await; + drop_raw_message_fts(&session_db_path).await; } let port = pick_free_port(); @@ -341,7 +345,7 @@ async fn start_fixture(break_message_fts: bool) -> DashboardFixture { _env_guard: env_guard, base_url, server, - global_db_path, + global_db_path: session_db_path, linked_node_id, } } diff --git a/tests/dashboard_savings_api_test.rs b/tests/dashboard_savings_api_test.rs index f635a8f9..f66df08c 100644 --- a/tests/dashboard_savings_api_test.rs +++ b/tests/dashboard_savings_api_test.rs @@ -1,6 +1,6 @@ //! Integration tests for the Savings & Cost dashboard API -//! (`/api/plugins/savings/*`), against a seeded temp global DB serving both -//! the savings ledger and the session store (`TRACEDECAY_GLOBAL_DB` override). +//! (`/api/plugins/savings/*`), against a seeded temp global DB for accounting +//! and the resolved project session store for transcript cost rows. //! //! Pricing runs offline (`TRACEDECAY_OFFLINE=1`) with the cache pointed at a //! nonexistent temp path, so the bundled fallback snapshot is exercised. @@ -9,7 +9,7 @@ mod common; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use common::{ @@ -20,6 +20,7 @@ use serde_json::Value; use tempfile::TempDir; use tracedecay::dashboard; use tracedecay::global_db::GlobalDb; +use tracedecay::sessions::cursor::project_session_db_path; use tracedecay::sessions::{SessionMessageRecord, SessionRecord}; use tracedecay::tracedecay::TraceDecay; use tracedecay::types::CostTurn; @@ -32,6 +33,8 @@ struct Fixture { _env_guards: Vec, base_url: String, server: tokio::task::JoinHandle<()>, + session_db_path: PathBuf, + project_root: PathBuf, /// Start of the current UTC day; seeded timestamps hang off this. day_start: i64, } @@ -298,6 +301,68 @@ async fn seed_global_db(db_path: &Path, project: &Path, day_start: i64) { )) .await ); + assert!( + gdb.upsert_session_message(&message_record_at( + "cursor", + "m-codex-summary", + "sess-codex", + "assistant", + 2, + Some(day_start + 520), + "Synthetic Codex compaction placeholder that is not real model output.", + "summary", + Some("gpt-5.3-codex-high"), + None, + None, + None, + None, + )) + .await + ); +} + +async fn seed_daily_limit_regression(db_path: &Path, project: &Path, latest_day: i64) { + let gdb = GlobalDb::open_at(db_path).await.expect("open session db"); + assert!( + gdb.upsert_session(&session( + "sess-daily-limit", + project, + latest_day + 30, + "Daily limit regression" + )) + .await + ); + + for offset in 0..=366 { + let timestamp = latest_day - (offset * 86_400) + 60; + assert!( + gdb.upsert_session_message(&message( + &format!("m-daily-limit-{offset}"), + "sess-daily-limit", + "assistant", + offset, + timestamp, + "Daily limit accounting row.", + Some("daily-limit-a"), + None, + )) + .await + ); + } + + assert!( + gdb.upsert_session_message(&message( + "m-daily-limit-latest-b", + "sess-daily-limit", + "assistant", + 500, + latest_day + 90, + "Second latest-day model bucket.", + Some("daily-limit-b"), + None, + )) + .await + ); } async fn start_fixture() -> Fixture { @@ -332,6 +397,8 @@ async fn start_fixture() -> Fixture { let cg = TraceDecay::init(&project_root) .await .expect("tracedecay init"); + let session_db_path = project_session_db_path(&project_root); + seed_global_db(&session_db_path, &project_root, day_start).await; let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); let server = tokio::spawn(async move { @@ -345,6 +412,8 @@ async fn start_fixture() -> Fixture { _env_guards: env_guards, base_url, server, + session_db_path, + project_root, day_start, } } @@ -459,6 +528,52 @@ fn savings_ledger_endpoints_reflect_seeded_ledger() { }); } +#[test] +fn daily_model_series_limits_days_not_model_rows() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let runtime = create_runtime(); + runtime.block_on(async { + let fixture = start_fixture().await; + seed_daily_limit_regression( + &fixture.session_db_path, + &fixture.project_root, + fixture.day_start, + ) + .await; + + let (_, models) = get_json( + &http_agent(), + &format!("{}/api/plugins/savings/models?range=all", fixture.base_url), + ); + let daily = models["daily"].as_array().expect("daily rows"); + let oldest_included_day = fixture.day_start - (365 * 86_400); + let excluded_day = fixture.day_start - (366 * 86_400); + + assert!( + daily + .iter() + .any(|row| row["day"] == fixture.day_start && row["model"] == "daily-limit-a"), + "latest day/model row was truncated: {daily:?}" + ); + assert!( + daily + .iter() + .any(|row| row["day"] == fixture.day_start && row["model"] == "daily-limit-b"), + "second latest-day model row was truncated: {daily:?}" + ); + assert!( + daily.iter().any(|row| row["day"] == oldest_included_day), + "expected the 366th latest day to remain: {daily:?}" + ); + assert!( + daily.iter().all(|row| row["day"] != excluded_day), + "row limit included an older day outside the 366-day window: {daily:?}" + ); + }); +} + #[test] fn session_costs_label_actual_vs_tokenized_vs_estimated() { let _lock = ENV_LOCK @@ -611,8 +726,23 @@ fn session_costs_label_actual_vs_tokenized_vs_estimated() { assert!((turns_by_model[0]["cost_usd"].as_f64().expect("cost") - 1.25).abs() < 1e-9); let daily = models["daily"].as_array().expect("daily"); - assert_eq!(daily.len(), 1, "all seeded messages share one UTC day"); - assert_eq!(daily[0]["day"], fixture.day_start); + assert_eq!( + daily.len(), + 5, + "daily session costs should keep one row per day/model price bucket" + ); + assert!( + daily.iter().all(|row| row["day"] == fixture.day_start), + "all seeded daily rows should stay in the same UTC day" + ); + assert!( + daily.iter().any(|row| row["model"] == "gpt-5.5-high"), + "daily rows must carry model ids so frontend price lookup works" + ); + assert!( + daily.iter().any(|row| row["model"].is_null()), + "unknown-model daily rows should remain explicit" + ); let turns_by_day = models["turns"]["by_day"].as_array().expect("turns by day"); assert_eq!(turns_by_day.len(), 1); diff --git a/tests/hermes_dashboard_test.rs b/tests/hermes_dashboard_test.rs index 04219d4f..f4eeb9c2 100644 --- a/tests/hermes_dashboard_test.rs +++ b/tests/hermes_dashboard_test.rs @@ -72,8 +72,8 @@ fn install_deploys_dashboard_plugin_page() { // The proxy backend bakes in the installing binary (env still wins). let api = read(&dash.join("plugin_api.py")); assert!(api.contains(r#"DEPLOYED_TRACEDECAY_BIN = "/usr/local/bin/tracedecay""#)); - // Unpinned installs serve the profile home's `.tracedecay/` stores (the - // hermes_profile storage scope), not whatever cwd Hermes spawns from. + // Unpinned installs serve the profile home as the project identity, not + // whatever cwd Hermes spawns from. let encoded_home = serde_json::to_string(home.path().join(".hermes").to_string_lossy().as_ref()).unwrap(); assert!(api.contains(&format!("DEPLOYED_PROJECT_ROOT = {encoded_home}"))); From 67d5e2ffd15a3fbe2a727aabf9b6cd656c3f0c38 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 21 Jun 2026 06:14:48 +0200 Subject: [PATCH 2/2] fix dashboard store and savings calculations --- dashboard/savings/src/ModelsPanel.tsx | 15 ++---- dashboard/shell/dist/source-stamp | 2 +- src/dashboard/mod.rs | 8 +-- src/dashboard/savings_api.rs | 18 +++++-- src/dashboard/token_count.rs | 71 +++++++++++++++++++++------ tests/dashboard_savings_api_test.rs | 54 +++++++++++++++++++- 6 files changed, 130 insertions(+), 38 deletions(-) diff --git a/dashboard/savings/src/ModelsPanel.tsx b/dashboard/savings/src/ModelsPanel.tsx index c8afc503..73e955fb 100644 --- a/dashboard/savings/src/ModelsPanel.tsx +++ b/dashboard/savings/src/ModelsPanel.tsx @@ -73,17 +73,10 @@ export default function ModelsPanel({ (row) => row.value, ); const dailyCost = fillDailySeries( - data.daily - .map((row) => { - const cost = rowCost(row, prices); - return { day: row.day, value: cost.usd ?? 0 }; - }) - .concat( - (data.turns.by_day || []).map((row) => ({ - day: row.day, - value: row.cost_usd || 0, - })), - ), + data.daily.map((row) => { + const cost = rowCost(row, prices); + return { day: row.day, value: cost.usd ?? 0 }; + }), (row) => row.value, ); diff --git a/dashboard/shell/dist/source-stamp b/dashboard/shell/dist/source-stamp index dced7199..a84beb2b 100644 --- a/dashboard/shell/dist/source-stamp +++ b/dashboard/shell/dist/source-stamp @@ -1 +1 @@ -662fcdcc6ee9da1a +6aded4b202b4bd1b diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index d48863dd..21b318de 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -49,6 +49,7 @@ use axum::Router; use serde_json::{json, Value}; use tokio::sync::RwLock; +use crate::db::Database; use crate::errors::{Result, TraceDecayError}; use crate::global_db::GlobalDb; use crate::storage::StorageMode; @@ -155,8 +156,8 @@ pub(crate) fn storage_mode_label(mode: &StorageMode) -> &'static str { } async fn open_dashboard_connection(path: &Path) -> Option { - let db = libsql::Builder::new_local(path).build().await.ok()?; - db.connect().ok() + let (db, _) = Database::open(path).await.ok()?; + Some(db.conn().clone()) } async fn memory_fact_count(conn: &libsql::Connection) -> Option { @@ -168,12 +169,11 @@ async fn memory_fact_count(conn: &libsql::Connection) -> Option { } pub(crate) async fn resolve_project_memory_store(cg: &TraceDecay) -> (libsql::Connection, String) { - let candidates = [cg.store_layout().graph_db_path.clone()]; let graph_path = cg.dashboard_db_path(); let mut first_open: Option<(libsql::Connection, String)> = None; let mut seen = std::collections::BTreeSet::new(); - for path in candidates { + for path in [cg.store_layout().graph_db_path.clone()] { if !seen.insert(path.clone()) || !path.is_file() { continue; } diff --git a/src/dashboard/savings_api.rs b/src/dashboard/savings_api.rs index 69aa798e..988801e7 100644 --- a/src/dashboard/savings_api.rs +++ b/src/dashboard/savings_api.rs @@ -702,11 +702,19 @@ pub(crate) async fn models( let conn = gdb.dashboard_connection(); let by_day = query_rows( &conn, - "SELECT (timestamp / 86400) * 86400 AS day, - SUM(cost_usd) AS cost_usd, - SUM(input_tokens + output_tokens) AS total_tokens - FROM turns WHERE timestamp >= ?1 - GROUP BY day ORDER BY day ASC LIMIT 366", + "WITH daily AS ( + SELECT (timestamp / 86400) * 86400 AS day, + SUM(cost_usd) AS cost_usd, + SUM(input_tokens + output_tokens) AS total_tokens + FROM turns WHERE timestamp >= ?1 + GROUP BY day + ), + latest_days AS ( + SELECT day FROM daily ORDER BY day DESC LIMIT 366 + ) + SELECT daily.* + FROM daily JOIN latest_days ON latest_days.day = daily.day + ORDER BY daily.day ASC", libsql::params![since], ) .await diff --git a/src/dashboard/token_count.rs b/src/dashboard/token_count.rs index 1f9e4e13..acda5373 100644 --- a/src/dashboard/token_count.rs +++ b/src/dashboard/token_count.rs @@ -20,6 +20,7 @@ //! the Savings tab doesn't pay the initial counting cost. use std::collections::HashMap; +use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -153,7 +154,7 @@ struct OverlayCache { overlay: Arc>, } -type OverlayFingerprint = (i64, i64, i64, i64, i64); +type OverlayFingerprint = (i64, i64, u64); /// Process-lifetime token-count cache shared by all savings endpoints. pub(crate) struct TokenCountCache { @@ -231,24 +232,28 @@ pub(crate) async fn non_usage_message_tokens( async fn overlay_fingerprint(conn: &libsql::Connection) -> Option { let rows = query_rows( conn, - "SELECT COUNT(*) AS count, - COALESCE(MAX(rowid), 0) AS max_rowid, - COALESCE(SUM(LENGTH(COALESCE(metadata_json, ''))), 0) AS metadata_len, - COALESCE(SUM(LENGTH(COALESCE(text, ''))), 0) AS text_len, - COALESCE(SUM(LENGTH(COALESCE(model, ''))), 0) AS model_len - FROM session_messages", + "SELECT rowid, provider, message_id, model, metadata_json, LENGTH(text) AS text_len + FROM session_messages ORDER BY rowid", (), ) .await .ok()?; - let row = rows.first()?; - Some(( - row.get("count").and_then(Value::as_i64).unwrap_or(0), - row.get("max_rowid").and_then(Value::as_i64).unwrap_or(0), - row.get("metadata_len").and_then(Value::as_i64).unwrap_or(0), - row.get("text_len").and_then(Value::as_i64).unwrap_or(0), - row.get("model_len").and_then(Value::as_i64).unwrap_or(0), - )) + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let mut max_rowid = 0_i64; + for row in &rows { + let rowid = row.get("rowid").and_then(Value::as_i64).unwrap_or(0); + max_rowid = max_rowid.max(rowid); + rowid.hash(&mut hasher); + row_str(row, "provider").hash(&mut hasher); + row_str(row, "message_id").hash(&mut hasher); + row_str(row, "model").hash(&mut hasher); + row_str(row, "metadata_json").hash(&mut hasher); + row.get("text_len") + .and_then(Value::as_i64) + .unwrap_or(0) + .hash(&mut hasher); + } + Some((rows.len() as i64, max_rowid, hasher.finish())) } async fn build_overlay( @@ -568,5 +573,41 @@ mod tests { assert_eq!(before.0, after.0, "row count should be unchanged"); assert_eq!(before.1, after.1, "max rowid should be unchanged"); assert_ne!(before, after, "metadata backfill must invalidate overlay"); + + let Some(first_backfill) = overlay_fingerprint(&conn).await else { + panic!("overlay fingerprint should exist after first backfill"); + }; + if let Err(err) = conn + .execute( + "UPDATE session_messages + SET metadata_json = '{\"usage\":{\"input_tokens\":9,\"output_tokens\":8}}' + WHERE provider = 'codex' AND message_id = 'm1'", + (), + ) + .await + { + panic!("failed to replace metadata usage: {err}"); + } + let Some(second_backfill) = overlay_fingerprint(&conn).await else { + panic!("overlay fingerprint should exist after second backfill"); + }; + + assert_eq!( + first_backfill.0, second_backfill.0, + "row count should be unchanged" + ); + assert_eq!( + first_backfill.1, second_backfill.1, + "max rowid should be unchanged" + ); + assert_eq!( + "{\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}".len(), + "{\"usage\":{\"input_tokens\":9,\"output_tokens\":8}}".len(), + "regression fixture must keep metadata string length stable" + ); + assert_ne!( + first_backfill, second_backfill, + "same-length metadata edits must invalidate overlay" + ); } } diff --git a/tests/dashboard_savings_api_test.rs b/tests/dashboard_savings_api_test.rs index f66df08c..89843a0e 100644 --- a/tests/dashboard_savings_api_test.rs +++ b/tests/dashboard_savings_api_test.rs @@ -33,6 +33,7 @@ struct Fixture { _env_guards: Vec, base_url: String, server: tokio::task::JoinHandle<()>, + global_db_path: PathBuf, session_db_path: PathBuf, project_root: PathBuf, /// Start of the current UTC day; seeded timestamps hang off this. @@ -321,8 +322,15 @@ async fn seed_global_db(db_path: &Path, project: &Path, day_start: i64) { ); } -async fn seed_daily_limit_regression(db_path: &Path, project: &Path, latest_day: i64) { - let gdb = GlobalDb::open_at(db_path).await.expect("open session db"); +async fn seed_daily_limit_regression( + session_db_path: &Path, + global_db_path: &Path, + project: &Path, + latest_day: i64, +) { + let gdb = GlobalDb::open_at(session_db_path) + .await + .expect("open session db"); assert!( gdb.upsert_session(&session( "sess-daily-limit", @@ -363,6 +371,30 @@ async fn seed_daily_limit_regression(db_path: &Path, project: &Path, latest_day: )) .await ); + + let accounting = GlobalDb::open_at(global_db_path) + .await + .expect("open accounting db"); + for offset in 0..=366 { + assert!( + accounting + .insert_turn(&CostTurn { + message_id: format!("turn-daily-limit-{offset}"), + project_hash: "fixture".to_string(), + session_id: "turns-daily-limit".to_string(), + model: "claude-opus-4-6".to_string(), + timestamp: (latest_day - (offset * 86_400) + 120) as u64, + input_tokens: 100 + offset as u64, + output_tokens: 50, + cache_write_tokens: 0, + cache_read_tokens: 0, + cost_usd: 0.01, + category: "code".to_string(), + tool_names: String::new(), + }) + .await + ); + } } async fn start_fixture() -> Fixture { @@ -412,6 +444,7 @@ async fn start_fixture() -> Fixture { _env_guards: env_guards, base_url, server, + global_db_path, session_db_path, project_root, day_start, @@ -538,6 +571,7 @@ fn daily_model_series_limits_days_not_model_rows() { let fixture = start_fixture().await; seed_daily_limit_regression( &fixture.session_db_path, + &fixture.global_db_path, &fixture.project_root, fixture.day_start, ) @@ -571,6 +605,22 @@ fn daily_model_series_limits_days_not_model_rows() { daily.iter().all(|row| row["day"] != excluded_day), "row limit included an older day outside the 366-day window: {daily:?}" ); + + let turns_daily = models["turns"]["by_day"].as_array().expect("turn daily rows"); + assert!( + turns_daily + .iter() + .any(|row| row["day"] == fixture.day_start), + "latest actual-cost day was truncated: {turns_daily:?}" + ); + assert!( + turns_daily.iter().any(|row| row["day"] == oldest_included_day), + "expected the 366th latest actual-cost day to remain: {turns_daily:?}" + ); + assert!( + turns_daily.iter().all(|row| row["day"] != excluded_day), + "actual-cost day limit included an older day outside the 366-day window: {turns_daily:?}" + ); }); }