pi-mega-compact 0.11.1 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +28 -1
  2. package/dist/extensions/dashboard-server/api-contracts/maintenance.js +7 -0
  3. package/dist/extensions/dashboard-server/api-contracts/provider-cache.js +7 -0
  4. package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
  5. package/dist/extensions/dashboard-server/index-reader.js +22 -0
  6. package/dist/extensions/dashboard-server/routes-cache.js +80 -0
  7. package/dist/extensions/dashboard-server/routes-cache.test.js +248 -0
  8. package/dist/extensions/dashboard-server/routes-maintenance.js +293 -0
  9. package/dist/extensions/dashboard-server/routes-repo.js +24 -0
  10. package/dist/extensions/dashboard-server/routes.js +2 -0
  11. package/dist/extensions/dashboard-server/server.js +5 -1
  12. package/dist/extensions/mega-cache-replay.test.js +233 -24
  13. package/dist/extensions/mega-compact-s38.test.js +201 -6
  14. package/dist/extensions/mega-config.js +3 -0
  15. package/dist/extensions/mega-events/agent-handlers.js +23 -7
  16. package/dist/extensions/mega-events/context-handler.js +37 -19
  17. package/dist/extensions/mega-events/error-classifier.js +27 -16
  18. package/dist/extensions/mega-runtime/runtime-snapshot.js +14 -4
  19. package/dist/extensions/mega-runtime/snapshot.js +1 -1
  20. package/dist/extensions/mega-runtime/snapshot.test.js +127 -0
  21. package/dist/extensions/mega-runtime/widget.js +4 -4
  22. package/dist/extensions/mega-runtime/widget.test.js +103 -12
  23. package/dist/src/pricing.js +78 -0
  24. package/dist/src/store/sqlite/maintenance.js +20 -1
  25. package/dist/src/store/sqlite/perf-samples.js +104 -0
  26. package/dist/src/store/sqlite/perf-samples.test.js +93 -1
  27. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js → AchievementTiles-vEo2MtE2.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js.map → AchievementTiles-vEo2MtE2.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/AchievementsTab-DjxfYrDc.js +2 -0
  30. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BzFHpAHM.js.map → AchievementsTab-DjxfYrDc.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js +2 -0
  32. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js.map +1 -0
  33. package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js → ConfigTab-Cm6ymbmx.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js.map → ConfigTab-Cm6ymbmx.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js → EventsTab-DXWl_YzI.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js.map → EventsTab-DXWl_YzI.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js → GameTab-BLDudcO5.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js.map → GameTab-BLDudcO5.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js +2 -0
  40. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js.map +1 -0
  41. package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js → MetricsTab-Cq4u-rZF.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js.map → MetricsTab-Cq4u-rZF.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js → OverviewTab-D4kGvsiQ.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js.map → OverviewTab-D4kGvsiQ.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js +2 -0
  46. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js.map +1 -0
  47. package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js → SessionsTab-DZC2-GCn.js} +2 -2
  48. package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js.map → SessionsTab-DZC2-GCn.js.map} +1 -1
  49. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js +2 -0
  50. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js.map +1 -0
  51. package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js → TopicsTab-01G71hv1.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js.map → TopicsTab-01G71hv1.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js → TurnsTab-DLRg0LZT.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js.map → TurnsTab-DLRg0LZT.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/index-BEdKlpAB.css +1 -0
  56. package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js → index-BX0bcgLu.js} +10 -10
  57. package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js.map → index-BX0bcgLu.js.map} +1 -1
  58. package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js → useSSE-BBYt5UPB.js} +2 -2
  59. package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js.map → useSSE-BBYt5UPB.js.map} +1 -1
  60. package/extensions/dashboard-client/dist/index.html +2 -2
  61. package/extensions/dashboard-client/src/App.tsx +5 -1
  62. package/extensions/dashboard-client/src/api/client.ts +29 -0
  63. package/extensions/dashboard-client/src/components/ActiveReposTable.tsx +13 -1
  64. package/extensions/dashboard-client/src/components/ProviderCacheCard.tsx +141 -0
  65. package/extensions/dashboard-client/src/components/SavingsByModelTable.tsx +31 -3
  66. package/extensions/dashboard-client/src/main.tsx +1 -0
  67. package/extensions/dashboard-client/src/styles/maintenance.css +267 -0
  68. package/extensions/dashboard-client/src/styles/overview-extra.css +9 -0
  69. package/extensions/dashboard-client/src/tabs/CacheTab.tsx +83 -29
  70. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +458 -0
  71. package/extensions/dashboard-client/src/utils/format.ts +42 -7
  72. package/extensions/dashboard-server/api-contracts/endpoints.ts +54 -1
  73. package/extensions/dashboard-server/api-contracts/index.ts +16 -0
  74. package/extensions/dashboard-server/api-contracts/maintenance.ts +84 -0
  75. package/extensions/dashboard-server/api-contracts/multi-repo.ts +6 -0
  76. package/extensions/dashboard-server/api-contracts/provider-cache.ts +30 -0
  77. package/extensions/dashboard-server/api-contracts.test.ts +3 -2
  78. package/extensions/dashboard-server/api-contracts.ts +9 -0
  79. package/extensions/dashboard-server/index-reader.ts +23 -0
  80. package/extensions/dashboard-server/routes-cache.test.ts +302 -0
  81. package/extensions/dashboard-server/routes-cache.ts +123 -0
  82. package/extensions/dashboard-server/routes-maintenance.ts +352 -0
  83. package/extensions/dashboard-server/routes-repo.ts +31 -4
  84. package/extensions/dashboard-server/routes.ts +2 -0
  85. package/extensions/dashboard-server/server.ts +4 -0
  86. package/extensions/mega-cache-replay.test.ts +481 -166
  87. package/extensions/mega-compact-s38.test.ts +210 -6
  88. package/extensions/mega-config.ts +6 -0
  89. package/extensions/mega-events/agent-handlers.ts +24 -6
  90. package/extensions/mega-events/context-handler.ts +59 -24
  91. package/extensions/mega-events/error-classifier.ts +30 -13
  92. package/extensions/mega-runtime/runtime-snapshot.ts +31 -9
  93. package/extensions/mega-runtime/snapshot.test.ts +139 -0
  94. package/extensions/mega-runtime/snapshot.ts +6 -4
  95. package/extensions/mega-runtime/widget.test.ts +290 -105
  96. package/extensions/mega-runtime/widget.ts +29 -6
  97. package/package.json +1 -1
  98. package/src/pricing.ts +95 -0
  99. package/src/store/sqlite/maintenance.ts +20 -1
  100. package/src/store/sqlite/perf-samples.test.ts +107 -3
  101. package/src/store/sqlite/perf-samples.ts +142 -6
  102. package/extensions/dashboard-client/dist/assets/AchievementsTab-BzFHpAHM.js +0 -2
  103. package/extensions/dashboard-client/dist/assets/CacheTab-DEQbTq2T.js +0 -2
  104. package/extensions/dashboard-client/dist/assets/CacheTab-DEQbTq2T.js.map +0 -1
  105. package/extensions/dashboard-client/dist/assets/ReposTab-DuyhOktG.js +0 -2
  106. package/extensions/dashboard-client/dist/assets/ReposTab-DuyhOktG.js.map +0 -1
  107. package/extensions/dashboard-client/dist/assets/TimeSavedCard-B8AIjNTY.js +0 -2
  108. package/extensions/dashboard-client/dist/assets/TimeSavedCard-B8AIjNTY.js.map +0 -1
  109. package/extensions/dashboard-client/dist/assets/index-gn66lYD2.css +0 -1
@@ -14,6 +14,7 @@ import type { SnapshotResponse } from "./snapshot.js";
14
14
  import type { IndexesIndexRow, IndexesSummaryResponse } from "./multi-repo.js";
15
15
  import type { GameStateResponse } from "./game.js";
16
16
  import type { SseEvent } from "./index.js";
17
+ import type { ProviderCacheResponse } from "./provider-cache.js";
17
18
  import type {
18
19
  TurnsResponse,
19
20
  ConversationTurnsResponse,
@@ -26,6 +27,13 @@ import type {
26
27
  TopicMemoriesResponse,
27
28
  } from "./turns.js";
28
29
 
30
+ import type {
31
+ DbStatsResponse,
32
+ MaintenanceActionResult,
33
+ MaintenanceAction,
34
+ SchemaHealthResponse,
35
+ } from "./maintenance.js";
36
+
29
37
  import type {
30
38
  GameScoreRow,
31
39
  GameScoresQuery,
@@ -240,6 +248,15 @@ export interface ServerEntry {
240
248
  } | null;
241
249
  /** ISO timestamp of the live snapshot, or null. Present when a live snapshot has updatedAt. */
242
250
  readonly updatedAt?: string | null;
251
+ /** Provider prompt-cache lifetime aggregates for this repo, or null when unavailable. */
252
+ readonly providerCache?: {
253
+ readonly avgHitPct: number;
254
+ readonly cacheRead: number;
255
+ readonly cacheWrite: number;
256
+ readonly totalInput: number;
257
+ readonly sampleCount: number;
258
+ readonly estimatedSaved: number | null;
259
+ } | null;
243
260
  }
244
261
 
245
262
  /**
@@ -345,6 +362,8 @@ export interface PerfResponse {
345
362
  readonly diag: PerfDiag | null;
346
363
  }
347
364
 
365
+ // ─── Provider Cache Lifetime (A.2) — see provider-cache.ts ─────────────────
366
+
348
367
  // ─── Game Score & Achievement Types ─────────────────────────────────────────
349
368
  // (moved to game-types.ts; re-exported for backwards compatibility)
350
369
 
@@ -360,7 +379,7 @@ export interface PerfResponse {
360
379
  // ─── ENDPOINTS Registry ─────────────────────────────────────────────────────
361
380
 
362
381
  /**
363
- * Central registry of all 13 dashboard API endpoints. Each entry is an
382
+ * Central registry of all 14 dashboard API endpoints. Each entry is an
364
383
  * `EndpointDef` or `SseEndpointDef` instance serving as the single source of
365
384
  * truth for route paths, methods, descriptions, and typed request/response
366
385
  * contracts.
@@ -553,4 +572,38 @@ export const ENDPOINTS = {
553
572
  path: "/api/topics/:topicId/memories",
554
573
  description: "Member memories assigned to a wiki topic (drill-down).",
555
574
  } as const satisfies EndpointDef<"GET", undefined, TopicMemoriesResponse>,
575
+
576
+ // ─── S49B Maintenance Tab ─────────────────────────────────────────
577
+
578
+ /** GET /api/maintenance — DB stats (table row counts + storage). */
579
+ maintenanceStats: {
580
+ method: "GET",
581
+ path: "/api/maintenance",
582
+ description: "SQLite table row counts, storage stats, and DB file sizes.",
583
+ } as const satisfies EndpointDef<"GET", undefined, DbStatsResponse>,
584
+
585
+ /** GET /api/maintenance/schema-health — Schema version + integrity audit. */
586
+ schemaHealth: {
587
+ method: "GET",
588
+ path: "/api/maintenance/schema-health",
589
+ description: "SCHEMA_VERSION, PRAGMA integrity_check, FK check, and per-column audit.",
590
+ } as const satisfies EndpointDef<"GET", undefined, SchemaHealthResponse>,
591
+
592
+ /** POST /api/maintenance/action — Trigger a maintenance action. */
593
+ maintenanceAction: {
594
+ method: "POST",
595
+ path: "/api/maintenance/action",
596
+ description: "Run vacuum, checkpoint, reindex, fts5-rebuild, reconcile-dedup, prune, or integrity-check.",
597
+ } as const satisfies EndpointDef<"POST", MaintenanceAction, MaintenanceActionResult>,
598
+
599
+ // ─── Provider Cache (Sprint A.2) ─────────────────────────────────
600
+
601
+ /** GET /api/provider-cache — Lifetime provider prompt cache aggregates + $ savings. */
602
+ providerCache: {
603
+ method: "GET",
604
+ path: "/api/provider-cache",
605
+ description:
606
+ "Lifetime provider prompt cache hit-rate aggregates + dollar savings estimate.",
607
+ } as const satisfies EndpointDef<"GET", undefined, ProviderCacheResponse>,
608
+
556
609
  } as const;
@@ -144,6 +144,10 @@ export type {
144
144
  TopicsResponse,
145
145
  TopicDetailResponse,
146
146
  } from "./endpoints.js";
147
+ export type {
148
+ ProviderCacheSavings,
149
+ ProviderCacheResponse,
150
+ } from "./provider-cache.js";
147
151
 
148
152
  export { ENDPOINTS } from "./endpoints.js";
149
153
 
@@ -171,6 +175,18 @@ export type SseEvent =
171
175
  | SseGameRender
172
176
  | SseSessionSample;
173
177
 
178
+ // S49B — maintenance tab API
179
+ export type {
180
+ TableStats,
181
+ DbFiles,
182
+ DbStorageStats,
183
+ DbStatsResponse,
184
+ MaintenanceActionResult,
185
+ SchemaHealthRow,
186
+ SchemaHealthResponse,
187
+ MaintenanceAction,
188
+ } from "./maintenance.js";
189
+
174
190
  // S52 — turn-by-turn memory tracking + recall + rewind
175
191
  export type {
176
192
  TurnRow,
@@ -0,0 +1,84 @@
1
+ /**
2
+ * api-contracts/maintenance.ts — Maintenance tab API contract (S49B).
3
+ *
4
+ * Types for the /api/maintenance and /api/maintenance/action endpoints.
5
+ * All pi-agnostic, all PREVENT-PI-004 compliant (type definitions only).
6
+ */
7
+
8
+ /** Row count + size stats per SQLite table. */
9
+ export interface TableStats {
10
+ /** Table name. */
11
+ table: string;
12
+ /** Row count (or -1 if FTS virtual table). */
13
+ rowCount: number;
14
+ }
15
+
16
+ /** DB file sizes on disk in bytes. */
17
+ export interface DbFiles {
18
+ /** Main sqlite.db size. */
19
+ dbBytes: number;
20
+ /** WAL sidecar size (0 if absent). */
21
+ walBytes: number;
22
+ /** SHM sidecar size (0 if absent). */
23
+ shmBytes: number;
24
+ }
25
+
26
+ /** SQLite storage-level stats. */
27
+ export interface DbStorageStats {
28
+ files: DbFiles;
29
+ pageSize: number;
30
+ pageCount: number;
31
+ freelistPages: number;
32
+ }
33
+
34
+ /** Full table + storage overview (GET /api/maintenance result). */
35
+ export interface DbStatsResponse {
36
+ type: "db-stats";
37
+ /** Per-table row counts. */
38
+ tables: TableStats[];
39
+ /** Storage stats. */
40
+ storage: DbStorageStats;
41
+ }
42
+
43
+ /** Result of a single maintenance action. */
44
+ export interface MaintenanceActionResult {
45
+ operation: string;
46
+ success: boolean;
47
+ affected: number;
48
+ reclaimedBytes: number;
49
+ summary: string;
50
+ detail?: string[];
51
+ }
52
+
53
+ /** Schema health check — per-column audit result. */
54
+ export interface SchemaHealthRow {
55
+ table: string;
56
+ column: string;
57
+ present: boolean;
58
+ expectedDecl: string;
59
+ }
60
+
61
+ /** Schema health audit (GET /api/maintenance/schema-health). */
62
+ export interface SchemaHealthResponse {
63
+ type: "schema-health";
64
+ /** SCHEMA_VERSION constant compiled in. */
65
+ schemaVersion: number;
66
+ /** PRAGMA integrity_check result lines. */
67
+ integrity: string[];
68
+ /** PRAGMA foreign_key_check result lines. */
69
+ fkCheck: string[];
70
+ /** Per-column audit of every ensureColumn migration column. */
71
+ columns: SchemaHealthRow[];
72
+ /** All present = healthy. */
73
+ healthy: boolean;
74
+ }
75
+
76
+ /** Maintenance actions the client can trigger (POST /api/maintenance/action). */
77
+ export type MaintenanceAction =
78
+ | { action: "vacuum" }
79
+ | { action: "checkpoint" }
80
+ | { action: "reindex" }
81
+ | { action: "fts5-rebuild" }
82
+ | { action: "reconcile-dedup" }
83
+ | { action: "prune"; daysOld: number }
84
+ | { action: "integrity-check" };
@@ -172,6 +172,12 @@ export interface IndexesIndexRow {
172
172
  maxTokens: number | null;
173
173
  /** Whether reasoning/extended-thinking mode is enabled, or `null` if not configured. */
174
174
  reasoning: boolean | null;
175
+ /** Lifetime provider prompt-cache hit percentage, or `null` if no samples. */
176
+ providerCachePct: number | null;
177
+ /** Lifetime provider prompt-cache read tokens, or `null` if no samples. */
178
+ providerCacheRead: number | null;
179
+ /** Lifetime provider prompt-cache write tokens, or `null` if no samples. */
180
+ providerCacheWrite: number | null;
175
181
  }
176
182
 
177
183
  /**
@@ -0,0 +1,30 @@
1
+ /**
2
+ * api-contracts/provider-cache.ts — Provider prompt cache contract (A.2).
3
+ *
4
+ * Response shape for GET /api/provider-cache: lifetime cache hit-rate
5
+ * aggregates plus optional $ savings estimate priced from model snapshots.
6
+ */
7
+
8
+ /** Savings estimate derived from model pricing snapshot. */
9
+ export interface ProviderCacheSavings {
10
+ readonly cacheReadSaved: number;
11
+ readonly cacheWriteCost: number;
12
+ readonly netSaved: number;
13
+ readonly model: string;
14
+ readonly inputRate: number;
15
+ }
16
+
17
+ /** Response body for GET /api/provider-cache. */
18
+ export interface ProviderCacheResponse {
19
+ readonly cache: {
20
+ readonly avgHitPct: number;
21
+ readonly turnCount: number;
22
+ readonly totalCacheRead: number;
23
+ readonly totalCacheWrite: number;
24
+ readonly totalInput: number;
25
+ readonly firstTurnAt: string | null;
26
+ readonly latestTurnAt: string | null;
27
+ };
28
+ readonly savings: ProviderCacheSavings | null;
29
+ readonly updatedAt: string;
30
+ }
@@ -162,7 +162,7 @@ void _c_achievements;
162
162
  // (Sprint 39 added /api/sessions + /api/sessions/timeseries.)
163
163
 
164
164
  const ENDPOINT_KEYS = Object.keys(ENDPOINTS) as (keyof typeof ENDPOINTS)[];
165
- const EXPECTED_ENDPOINT_COUNT = 23;
165
+ const EXPECTED_ENDPOINT_COUNT = 27; // 23 base + 3 S49B maintenance + 1 provider-cache
166
166
 
167
167
  /** All `/api/*` paths served by server.ts (extracted from the route handlers). */
168
168
  const SERVER_TS_PATHS: string[] = [
@@ -181,12 +181,13 @@ const SERVER_TS_PATHS: string[] = [
181
181
  "/api/sessions",
182
182
  "/api/sessions/timeseries",
183
183
  "/api/topics",
184
+ "/api/provider-cache",
184
185
  ];
185
186
 
186
187
  // ─── Tests ───────────────────────────────────────────────────────────────────
187
188
 
188
189
  describe("ENDPOINTS registry", () => {
189
- test("has exactly 16 endpoint entries", () => {
190
+ test("has exactly the expected endpoint count", () => {
190
191
  assert.equal(
191
192
  ENDPOINT_KEYS.length,
192
193
  EXPECTED_ENDPOINT_COUNT,
@@ -66,6 +66,15 @@ export type {
66
66
  MinHashBandState,
67
67
  // Composite
68
68
  SseEvent,
69
+ // S49B — maintenance tab
70
+ TableStats,
71
+ DbFiles,
72
+ DbStorageStats,
73
+ DbStatsResponse,
74
+ MaintenanceActionResult,
75
+ SchemaHealthRow,
76
+ SchemaHealthResponse,
77
+ MaintenanceAction,
69
78
  // Endpoints registry (Sprint A1)
70
79
  VersionResponse,
71
80
  IndexSummary,
@@ -58,6 +58,9 @@ export function readIndex(): IndexIndex | null {
58
58
  contextWindow: null,
59
59
  maxTokens: null,
60
60
  reasoning: null,
61
+ providerCachePct: null,
62
+ providerCacheRead: null,
63
+ providerCacheWrite: null,
61
64
  }));
62
65
  // Enrich each repo with per-store token + model detail read directly via
63
66
  // node:sqlite (same zero-dependency invariant as readIndex; no store graph
@@ -91,6 +94,26 @@ export function readIndex(): IndexIndex | null {
91
94
  repo.maxTokens = Number(mrow.max_tokens ?? 0) || null;
92
95
  repo.reasoning = Number(mrow.reasoning ?? 0) === 1;
93
96
  }
97
+ // Provider prompt-cache stats (E.3)
98
+ try {
99
+ const pRow = sdb
100
+ .prepare(
101
+ `SELECT
102
+ SUM(cache_read) AS totalRead,
103
+ SUM(cache_write) AS totalWrite,
104
+ SUM(cache_read + cache_write) * 1.0 / MAX(SUM(cache_read + cache_write + tokens_in), 1) AS hitPct
105
+ FROM perf_samples
106
+ WHERE cache_read > 0 OR cache_write > 0`,
107
+ )
108
+ .get() as { totalRead: number; totalWrite: number; hitPct: number } | undefined;
109
+ if (pRow && pRow.totalRead > 0) {
110
+ repo.providerCacheRead = Number(pRow.totalRead);
111
+ repo.providerCacheWrite = Number(pRow.totalWrite);
112
+ repo.providerCachePct = Number((pRow.hitPct * 100).toFixed(1));
113
+ }
114
+ } catch {
115
+ /* best-effort — perf_samples may not exist */
116
+ }
94
117
  } finally {
95
118
  sdb.close();
96
119
  }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * routes-cache.test.ts — Sprint A /api/provider-cache endpoint tests.
3
+ *
4
+ * Covers the A.4 spec:
5
+ * - GET 200 → shape matches ProviderCacheResponse
6
+ * - POST → 405
7
+ * - empty perf_samples → zeros/nulls
8
+ * - no model snapshot → savings: null
9
+ * - with model snapshot → priced savings fields
10
+ *
11
+ * Uses the same spawn-and-fetch harness as perf-server.test.ts.
12
+ */
13
+ import { test, describe } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { spawn } from "node:child_process";
19
+ import {
20
+ recordPerfSample,
21
+ recordModelSnapshot,
22
+ } from "../../src/store/sqlite.js";
23
+
24
+ const SERVER_ENTRY = new URL("./server.js", import.meta.url).pathname;
25
+
26
+ function waitFor(
27
+ cond: () => boolean | Promise<boolean>,
28
+ timeoutMs = 6000,
29
+ ): Promise<void> {
30
+ const start = Date.now();
31
+ return new Promise((resolve, reject) => {
32
+ const tick = async () => {
33
+ if (await cond()) return resolve();
34
+ if (Date.now() - start > timeoutMs) return reject(new Error("timeout"));
35
+ setTimeout(tick, 50);
36
+ };
37
+ tick();
38
+ });
39
+ }
40
+
41
+ function freshDir(prefix: string): string {
42
+ return mkdtempSync(join(tmpdir(), prefix));
43
+ }
44
+
45
+ async function withServer<T>(
46
+ port: string,
47
+ dir: string,
48
+ fn: (port: number) => Promise<T>,
49
+ ): Promise<T> {
50
+ process.env.MEGACOMPACT_DASHBOARD_PORT = port;
51
+ const child = spawn(process.execPath, [SERVER_ENTRY, dir], {
52
+ stdio: "ignore",
53
+ });
54
+ try {
55
+ await waitFor(async () => {
56
+ try {
57
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
58
+ const res = await fetch(`http://localhost:${raw.port}/api/version`);
59
+ return res.ok;
60
+ } catch {
61
+ return false;
62
+ }
63
+ });
64
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
65
+ return await fn(raw.port);
66
+ } finally {
67
+ child.kill("SIGTERM");
68
+ delete process.env.MEGACOMPACT_DASHBOARD_PORT;
69
+ rmSync(dir, { recursive: true, force: true });
70
+ }
71
+ }
72
+
73
+ interface ProviderCacheResponse {
74
+ cache: {
75
+ avgHitPct: number;
76
+ turnCount: number;
77
+ totalCacheRead: number;
78
+ totalCacheWrite: number;
79
+ totalInput: number;
80
+ firstTurnAt: string | null;
81
+ latestTurnAt: string | null;
82
+ };
83
+ savings: {
84
+ cacheReadSaved: number;
85
+ cacheWriteCost: number;
86
+ netSaved: number;
87
+ model: string;
88
+ inputRate: number;
89
+ } | null;
90
+ updatedAt: string;
91
+ }
92
+
93
+ describe("/api/provider-cache", () => {
94
+ test("GET 200 — shape matches ProviderCacheResponse", async () => {
95
+ const dir = freshDir("dash-pcache-agg-");
96
+ // Seed one cache_hit_pct sample with full meta.
97
+ recordPerfSample(dir, "cache_hit_pct", 42, {
98
+ cacheRead: 100,
99
+ cacheWrite: 20,
100
+ input: 500,
101
+ });
102
+ await withServer("19450", dir, async (port) => {
103
+ const res = await fetch(`http://localhost:${port}/api/provider-cache`);
104
+ assert.equal(res.status, 200);
105
+ const body = (await res.json()) as ProviderCacheResponse;
106
+ // Shape assertions
107
+ assert.ok("cache" in body, "should have cache");
108
+ assert.ok("savings" in body, "should have savings");
109
+ assert.ok("updatedAt" in body, "should have updatedAt");
110
+ assert.equal(body.cache.turnCount, 1);
111
+ assert.equal(body.cache.totalCacheRead, 100);
112
+ assert.equal(body.cache.totalCacheWrite, 20);
113
+ assert.equal(body.cache.totalInput, 500);
114
+ assert.ok(typeof body.cache.firstTurnAt === "string");
115
+ assert.ok(typeof body.cache.latestTurnAt === "string");
116
+ assert.ok(typeof body.updatedAt === "string");
117
+ });
118
+ });
119
+
120
+ test("GET 200 — empty perf_samples returns zeros/nulls", async () => {
121
+ const dir = freshDir("dash-pcache-empty-");
122
+ await withServer("19451", dir, async (port) => {
123
+ const res = await fetch(`http://localhost:${port}/api/provider-cache`);
124
+ assert.equal(res.status, 200);
125
+ const body = (await res.json()) as ProviderCacheResponse;
126
+ assert.equal(body.cache.turnCount, 0);
127
+ assert.equal(body.cache.avgHitPct, 0);
128
+ assert.equal(body.cache.totalCacheRead, 0);
129
+ assert.equal(body.cache.totalCacheWrite, 0);
130
+ assert.equal(body.cache.totalInput, 0);
131
+ assert.equal(body.cache.firstTurnAt, null);
132
+ assert.equal(body.cache.latestTurnAt, null);
133
+ assert.equal(body.savings, null);
134
+ });
135
+ });
136
+
137
+ test("POST → 405", async () => {
138
+ const dir = freshDir("dash-pcache-meth-");
139
+ await withServer("19452", dir, async (port) => {
140
+ const res = await fetch(`http://localhost:${port}/api/provider-cache`, {
141
+ method: "POST",
142
+ });
143
+ assert.equal(res.status, 405);
144
+ const body = (await res.json()) as { error: string };
145
+ assert.equal(body.error, "method_not_allowed");
146
+ });
147
+ });
148
+
149
+ test("no model snapshot → savings: null", async () => {
150
+ const dir = freshDir("dash-pcache-nosnap-");
151
+ recordPerfSample(dir, "cache_hit_pct", 50, {
152
+ cacheRead: 200,
153
+ cacheWrite: 30,
154
+ input: 1000,
155
+ });
156
+ await withServer("19453", dir, async (port) => {
157
+ const res = await fetch(`http://localhost:${port}/api/provider-cache`);
158
+ assert.equal(res.status, 200);
159
+ const body = (await res.json()) as ProviderCacheResponse;
160
+ assert.equal(body.savings, null);
161
+ });
162
+ });
163
+
164
+ test("with model snapshot → priced savings fields", async () => {
165
+ const dir = freshDir("dash-pcache-savings-");
166
+ recordPerfSample(dir, "cache_hit_pct", 50, {
167
+ cacheRead: 2000,
168
+ cacheWrite: 400,
169
+ input: 10000,
170
+ });
171
+ // Record a model snapshot with a known input rate.
172
+ recordModelSnapshot(
173
+ "/tmp/test-repo",
174
+ {
175
+ provider: "anthropic",
176
+ providerName: "Anthropic",
177
+ modelId: "claude-sonnet-4-20250514",
178
+ modelName: "Claude Sonnet 4",
179
+ inputRate: 3e-6, // $3 / 1M tokens
180
+ outputRate: 15e-6,
181
+ contextWindow: 200000,
182
+ maxTokens: 32000,
183
+ reasoning: false,
184
+ },
185
+ dir,
186
+ );
187
+ await withServer("19454", dir, async (port) => {
188
+ const res = await fetch(`http://localhost:${port}/api/provider-cache`);
189
+ assert.equal(res.status, 200);
190
+ const body = (await res.json()) as ProviderCacheResponse;
191
+ assert.ok(body.savings, "savings should not be null");
192
+ // cacheReadSaved = 2000 * 3e-6 * 0.9 = 0.0054
193
+ assert.ok(body.savings!.cacheReadSaved > 0, "cacheReadSaved > 0");
194
+ // cacheWriteCost = 400 * 3e-6 * 0.25 = 0.0003
195
+ assert.ok(body.savings!.cacheWriteCost > 0, "cacheWriteCost > 0");
196
+ assert.ok(
197
+ body.savings!.netSaved ===
198
+ body.savings!.cacheReadSaved - body.savings!.cacheWriteCost,
199
+ "netSaved = cacheReadSaved - cacheWriteCost",
200
+ );
201
+ assert.equal(body.savings!.model, "Claude Sonnet 4");
202
+ assert.equal(body.savings!.inputRate, 3e-6);
203
+ });
204
+ });
205
+
206
+ test("partial meta (missing fields) → treated as 0", async () => {
207
+ const dir = freshDir("dash-pcache-partial-");
208
+ // Only input in meta; cacheRead/cacheWrite missing → treated as 0.
209
+ recordPerfSample(dir, "cache_hit_pct", 30, { input: 800 });
210
+ // Only cacheRead in meta; input/cacheWrite missing → treated as 0.
211
+ recordPerfSample(dir, "cache_hit_pct", 60, { cacheRead: 150 });
212
+ await withServer("19455", dir, async (port) => {
213
+ const res = await fetch(`http://localhost:${port}/api/provider-cache`);
214
+ assert.equal(res.status, 200);
215
+ const body = (await res.json()) as ProviderCacheResponse;
216
+ // First sample: input=800, cacheRead=0, cacheWrite=0
217
+ // Second sample: cacheRead=150, input=0, cacheWrite=0
218
+ assert.equal(body.cache.turnCount, 2);
219
+ assert.equal(body.cache.totalCacheRead, 150);
220
+ assert.equal(body.cache.totalCacheWrite, 0);
221
+ assert.equal(body.cache.totalInput, 800);
222
+ });
223
+ });
224
+
225
+ test("NULL meta → counted in turnCount, contributes 0 tokens + 0 avg", async () => {
226
+ const dir = freshDir("dash-pcache-nullmeta-");
227
+ // avgHitPct is derived from (cacheRead / (cacheRead + input + cacheWrite)) * 100,
228
+ // NOT from the `value` column. NULL meta → no tokens → avg 0, not 25.
229
+ recordPerfSample(dir, "cache_hit_pct", 25, undefined);
230
+ await withServer("19456", dir, async (port) => {
231
+ const res = await fetch(`http://localhost:${port}/api/provider-cache`);
232
+ assert.equal(res.status, 200);
233
+ const body = (await res.json()) as ProviderCacheResponse;
234
+ assert.equal(body.cache.turnCount, 1);
235
+ assert.equal(body.cache.avgHitPct, 0);
236
+ assert.equal(body.cache.totalCacheRead, 0);
237
+ assert.equal(body.cache.totalCacheWrite, 0);
238
+ assert.equal(body.cache.totalInput, 0);
239
+ });
240
+ });
241
+
242
+ test("model snapshot with modelName=null → falls back to modelId", async () => {
243
+ const dir = freshDir("dash-pcache-modelid-");
244
+ recordPerfSample(dir, "cache_hit_pct", 10, {
245
+ cacheRead: 100,
246
+ cacheWrite: 10,
247
+ input: 1000,
248
+ });
249
+ recordModelSnapshot(
250
+ "/tmp/test-repo",
251
+ {
252
+ provider: "openai",
253
+ providerName: null,
254
+ modelId: "gpt-4o",
255
+ modelName: null,
256
+ inputRate: 2.5e-6,
257
+ outputRate: 10e-6,
258
+ contextWindow: 128000,
259
+ maxTokens: 16384,
260
+ reasoning: false,
261
+ },
262
+ dir,
263
+ );
264
+ await withServer("19457", dir, async (port) => {
265
+ const res = await fetch(`http://localhost:${port}/api/provider-cache`);
266
+ assert.equal(res.status, 200);
267
+ const body = (await res.json()) as ProviderCacheResponse;
268
+ assert.ok(body.savings);
269
+ assert.equal(body.savings!.model, "gpt-4o");
270
+ });
271
+ });
272
+
273
+ test("model snapshot with zero inputRate → savings: null", async () => {
274
+ const dir = freshDir("dash-pcache-zerorate-");
275
+ recordPerfSample(dir, "cache_hit_pct", 10, {
276
+ cacheRead: 100,
277
+ cacheWrite: 10,
278
+ input: 1000,
279
+ });
280
+ recordModelSnapshot(
281
+ "/tmp/test-repo",
282
+ {
283
+ provider: "local",
284
+ providerName: null,
285
+ modelId: "local-llm",
286
+ modelName: null,
287
+ inputRate: 0,
288
+ outputRate: 0,
289
+ contextWindow: 4096,
290
+ maxTokens: 2048,
291
+ reasoning: false,
292
+ },
293
+ dir,
294
+ );
295
+ await withServer("19458", dir, async (port) => {
296
+ const res = await fetch(`http://localhost:${port}/api/provider-cache`);
297
+ assert.equal(res.status, 200);
298
+ const body = (await res.json()) as ProviderCacheResponse;
299
+ assert.equal(body.savings, null);
300
+ });
301
+ });
302
+ });