From 6650c30026a8a543ee020fdf7b4d07b7000fdb67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sat, 22 Aug 2026 14:12:37 +0800 Subject: [PATCH 01/11] feat(usage): include ZCode activity --- .../src/features/usage/usageProviders.ts | 7 +- apps/server/src/usage/UsageService.ts | 36 +++- apps/server/src/usage/usageScanCache.test.ts | 21 ++ apps/server/src/usage/usageScanCache.ts | 2 +- .../src/usage/usageTranscriptReader.test.ts | 194 ++++++++++++++++++ .../server/src/usage/usageTranscriptReader.ts | 63 ++++++ apps/server/src/usage/usageTranscripts.ts | 55 ++++- .../usage/UsageProviderChart.test.ts | 1 + .../src/components/usage/usageProviders.ts | 7 +- docs/user/usage.md | 8 +- packages/contracts/src/usage.ts | 12 +- 11 files changed, 386 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/usage/usageTranscriptReader.test.ts diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 9a9ec5f2282d..4fed71197d0d 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -5,21 +5,24 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe * Series and table order. The chart stacks providers from the bottom in this * order, so it also fixes which band sits on top of the bars. */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "zcode"]; export const PROVIDER_LABEL: Record = { claude: "Claude Code", codex: "Codex", + zcode: "ZCode", }; /** * Claude's brand orange holds in both themes; Codex is neutral and must flip - * with the theme or its bars vanish against the matching background. + * with the theme or its bars vanish against the matching background. ZCode's + * indigo reads on either theme. */ export function useProviderColors(): Record { const { themeAppearance: scheme } = useAppearancePreferences(); return { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + zcode: "#6366f1", }; } diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0bf131ac973b..5618e7bb03f4 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -43,6 +43,7 @@ import { listTranscriptFiles, readDirectoryVolumeId, readTranscriptRecords, + statSqliteUsageStore, } from "./usageTranscriptReader.ts"; import { decodeScanCache, @@ -74,6 +75,19 @@ const RatesCacheFile = Schema.Struct({ fetchedAtMs: Schema.Number, document: Schema.Unknown, }); + +/** + * One provider's usage store. + * + * `dir` is stat'd for existence and the source fingerprint's volume id, and + * walked for `*.jsonl` transcripts — unless `file` names a single-file store + * (ZCode's sqlite db), which is read instead of walking. + */ +interface TranscriptSource { + readonly provider: UsageProviderKind; + readonly dir: string; + readonly file?: string; +} const decodeRatesCache = Schema.decodeUnknownEffect( Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), ); @@ -219,10 +233,17 @@ export const make = Effect.gen(function* () { const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); - return [ - { provider: "claude" as const, dir: claudeDir }, - { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + // ZCode has no settings-driven home override; its usage store is always + // the app's own sqlite db. A missing install simply resolves to a dir + // that does not exist, which the scan reports as a missing source. + const zcodeDbDir = path.join(NodeOS.homedir(), ".zcode", "cli", "db"); + + const sources: readonly TranscriptSource[] = [ + { provider: "claude", dir: claudeDir }, + { provider: "codex", dir: path.join(codexLayout.sharedHomePath, "sessions") }, + { provider: "zcode", dir: zcodeDbDir, file: path.join(zcodeDbDir, "db.sqlite") }, ]; + return sources; }); /** @@ -353,7 +374,8 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir } of dirs) { + for (const source of dirs) { + const { provider, dir } = source; const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); const exists = yield* fileSystem .exists(dir) @@ -373,7 +395,11 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + const files = yield* Effect.promise(() => + source.file === undefined + ? listTranscriptFiles(dir, windowStartMs) + : statSqliteUsageStore(source.file, windowStartMs), + ); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 64673e96c090..a8895787a579 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -50,6 +50,27 @@ describe("scan cache round trip", () => { expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); }); + it("round-trips a ZCode sqlite entry", () => { + const zcodeRecord = record({ + provider: "zcode", + model: "glm-5.2", + sessionId: "zcode-session", + dedupeKey: "usage-row-1", + }); + const original: ScanCache = new Map([ + [ + "/home/user/.zcode/cli/db/db.sqlite", + { size: 42, mtimeMs: 200, provider: "zcode", records: [zcodeRecord] }, + ], + ]); + + const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); + + expect(restored.get("/home/user/.zcode/cli/db/db.sqlite")).toEqual( + original.get("/home/user/.zcode/cli/db/db.sqlite"), + ); + }); + it("interns repeated model and session strings", () => { const encoded = encodeScanCache( cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index cc15ee9cee62..7b803eb88cad 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -134,7 +134,7 @@ export function decodeScanCache(document: unknown): ScanCache { if (typeof raw !== "object" || raw === null) continue; const entry = raw as Partial; if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "zcode") continue; if (!isRecordArray(entry.r)) continue; const provider: UsageProviderKind = entry.p; diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts new file mode 100644 index 000000000000..76dea557c304 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -0,0 +1,194 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; + +import { describe, expect, it } from "@effect/vitest"; + +import { readTranscriptRecords, statSqliteUsageStore } from "./usageTranscriptReader.ts"; + +interface ZcodeRow { + readonly id: string; + readonly sessionId?: string; + readonly modelId?: string; + readonly status?: string; + readonly startedAt?: number; + readonly completedAt?: number | null; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly reasoningTokens?: number; + readonly cacheCreationInputTokens?: number; + readonly cacheReadInputTokens?: number; +} + +/** Writes a minimal `model_usage` fixture db, shaped after ZCode's real store. */ +function createZcodeDb(rows: readonly ZcodeRow[]): { dbPath: string; cleanup: () => void } { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-zcode-usage-")); + const dbPath = NodePath.join(dir, "db.sqlite"); + const db = new NodeSqlite.DatabaseSync(dbPath); + db.exec(` + CREATE TABLE model_usage ( + id TEXT PRIMARY KEY, + session_id TEXT, + turn_id TEXT, + model_id TEXT, + status TEXT, + started_at INTEGER, + completed_at INTEGER, + input_tokens INTEGER, + output_tokens INTEGER, + reasoning_tokens INTEGER, + cache_creation_input_tokens INTEGER, + cache_read_input_tokens INTEGER, + query_source TEXT + ) + `); + const insert = db.prepare(` + INSERT INTO model_usage ( + id, session_id, turn_id, model_id, status, started_at, completed_at, + input_tokens, output_tokens, reasoning_tokens, + cache_creation_input_tokens, cache_read_input_tokens, query_source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const row of rows) { + insert.run( + row.id, + row.sessionId ?? "session-1", + "turn-1", + row.modelId ?? "kimi-k3", + row.status ?? "completed", + row.startedAt ?? 1_786_000_000_000, + row.completedAt === undefined ? 1_786_000_001_000 : row.completedAt, + row.inputTokens ?? 0, + row.outputTokens ?? 0, + row.reasoningTokens ?? 0, + row.cacheCreationInputTokens ?? 0, + row.cacheReadInputTokens ?? 0, + "main_turn", + ); + } + db.close(); + return { + dbPath, + cleanup: () => NodeFS.rmSync(dir, { recursive: true, force: true }), + }; +} + +describe("readTranscriptRecords for zcode", () => { + it("maps completed model_usage rows to usage records", async () => { + const { dbPath, cleanup } = createZcodeDb([ + { + id: "usage_model_main_turn_msg_1_0", + sessionId: "session-a", + modelId: "kimi-k3", + startedAt: 1_786_000_000_000, + completedAt: 1_786_000_002_500, + inputTokens: 1_050, + outputTokens: 45, + reasoningTokens: 12, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 900, + }, + ]); + try { + const records = await readTranscriptRecords(dbPath, "zcode"); + + expect(records).toEqual([ + { + provider: "zcode", + timestampMs: 1_786_000_002_500, + model: "kimi-k3", + sessionId: "session-a", + totals: { + uncachedInputTokens: 120, + cachedInputTokens: 900, + cacheCreationTokens: 30, + outputTokens: 45, + reasoningTokens: 12, + }, + reportedCostUsd: null, + dedupeKey: "usage_model_main_turn_msg_1_0", + }, + ]); + } finally { + cleanup(); + } + }); + + it("counts only completed attempts and falls back to started_at", async () => { + const { dbPath, cleanup } = createZcodeDb([ + { id: "row-failed", status: "failed", outputTokens: 10 }, + { id: "row-running", status: "running", outputTokens: 10 }, + { + id: "row-done", + status: "completed", + startedAt: 1_786_000_004_000, + completedAt: null, + outputTokens: 10, + }, + ]); + try { + const records = await readTranscriptRecords(dbPath, "zcode"); + + expect(records).toHaveLength(1); + expect(records?.[0]?.dedupeKey).toBe("row-done"); + expect(records?.[0]?.timestampMs).toBe(1_786_000_004_000); + } finally { + cleanup(); + } + }); + + it("does not turn read failures into cacheable empty records", async () => { + const missing = await readTranscriptRecords( + NodePath.join(NodeOS.tmpdir(), "t3-zcode-usage-no-such-dir", "db.sqlite"), + "zcode", + ); + expect(missing).toBeNull(); + + const corruptDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-zcode-corrupt-")); + const corruptPath = NodePath.join(corruptDir, "db.sqlite"); + NodeFS.writeFileSync(corruptPath, "not a sqlite database"); + try { + expect(await readTranscriptRecords(corruptPath, "zcode")).toBeNull(); + } finally { + NodeFS.rmSync(corruptDir, { recursive: true, force: true }); + } + }); + + it("yields zero records for an older db without model_usage", async () => { + // A db without model_usage (an older or foreign layout) is zero usage, + // never a failed scan. + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-zcode-usage-empty-")); + const dbPath = NodePath.join(dir, "db.sqlite"); + new NodeSqlite.DatabaseSync(dbPath).close(); + try { + expect(await readTranscriptRecords(dbPath, "zcode")).toEqual([]); + } finally { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("includes the sqlite WAL in the scan fingerprint", async () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-zcode-usage-wal-")); + const dbPath = NodePath.join(dir, "db.sqlite"); + const db = new NodeSqlite.DatabaseSync(dbPath); + try { + db.exec("PRAGMA journal_mode = WAL; CREATE TABLE usage (id TEXT)"); + db.prepare("INSERT INTO usage VALUES (?)").run("new-row"); + + const dbStats = NodeFS.statSync(dbPath); + const walStats = NodeFS.statSync(`${dbPath}-wal`); + const [file] = await statSqliteUsageStore(dbPath, 0); + + expect(file).toEqual({ + path: dbPath, + size: dbStats.size + walStats.size, + mtimeMs: Math.max(dbStats.mtimeMs, walStats.mtimeMs), + }); + } finally { + db.close(); + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index c72f0c24db65..9e0fe7f0484b 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -14,6 +14,7 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeReadline from "node:readline"; +import * as NodeSqlite from "node:sqlite"; import type { UsageProviderKind } from "@t3tools/contracts"; @@ -22,6 +23,7 @@ import { mightCarryUsage, parseClaudeLine, parseCodexLine, + parseZcodeUsageRow, type UsageRecord, } from "./usageTranscripts.ts"; @@ -73,6 +75,26 @@ export async function listTranscriptFiles( return found; } +/** + * Stats a sqlite usage store, applying the same mtime prefilter as the jsonl + * walk. The WAL participates in the fingerprint because active ZCode writes + * can leave the main db's size and mtime unchanged until a checkpoint. + */ +export async function statSqliteUsageStore( + filePath: string, + sinceMs: number, +): Promise { + try { + const stats = await NodeFSP.stat(filePath); + const walStats = await NodeFSP.stat(`${filePath}-wal`).catch(() => null); + const size = stats.size + (walStats?.size ?? 0); + const mtimeMs = Math.max(stats.mtimeMs, walStats?.mtimeMs ?? 0); + return mtimeMs >= sinceMs ? [{ path: filePath, size, mtimeMs }] : []; + } catch { + return []; + } +} + /** * Filesystem identity of a directory, as `device:inode`. * @@ -89,6 +111,43 @@ export async function readDirectoryVolumeId(path: string): Promise { } } +/** + * Reads every usage row from ZCode's sqlite store. + * + * The whole table is read rather than windowed in SQL because the scan cache + * memoises per `(size, mtime)` independently of the requested window; + * out-of-window rows are dropped by the aggregator. An older schema without + * `model_usage` yields zero records; other read failures return `null` so the + * caller does not cache a transient failure as an empty store. + */ +async function readZcodeUsageRecords(filePath: string): Promise { + let db: NodeSqlite.DatabaseSync | undefined; + try { + db = new NodeSqlite.DatabaseSync(filePath, { readOnly: true }); + const rows = db + .prepare( + `SELECT id, session_id, model_id, status, started_at, completed_at, + input_tokens, output_tokens, reasoning_tokens, + cache_creation_input_tokens, cache_read_input_tokens + FROM model_usage + WHERE status = 'completed'`, + ) + .all(); + const records: UsageRecord[] = []; + for (const row of rows) { + const record = parseZcodeUsageRow(row); + if (record !== null) records.push(record); + } + return records; + } catch (error) { + return error instanceof Error && error.message.includes("no such table: model_usage") + ? [] + : null; + } finally { + db?.close(); + } +} + /** * Streams one transcript and returns the usage records it contains, or `null` * when the file could not be read. @@ -101,11 +160,15 @@ export async function readDirectoryVolumeId(path: string): Promise { * Codex carries the active model on `turn_context` lines that hold no usage of * their own, so those still have to pass through the reducer to keep model * attribution correct. + * + * ZCode never reaches line parsing: its store is sqlite, handled above. */ export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, ): Promise { + if (provider === "zcode") return readZcodeUsageRecords(filePath); + const records: UsageRecord[] = []; const codexState = initialCodexScanState(); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 49f9a1935ccc..8f2372f72732 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -68,7 +68,16 @@ export function totalTokens(totals: UsageTokenTotals): number { * an order of magnitude. */ export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { - return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); + switch (provider) { + case "claude": + return line.includes('"usage"'); + case "codex": + return line.includes('"token_count"'); + case "zcode": + // ZCode usage is read from its sqlite store, never line-parsed, so this + // gate is unreachable for it. + return false; + } } /* -------------------------------------------------------------------------- */ @@ -297,4 +306,48 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord }; } +/* -------------------------------------------------------------------------- */ +/* ZCode */ +/* -------------------------------------------------------------------------- */ + +/** + * Maps one row of ZCode's `model_usage` sqlite table to a usage record. + * + * Each row is one model request attempt; only `completed` attempts carried + * real traffic. `completed_at` stamps the record, falling back to + * `started_at`. ZCode stores no cost, so pricing falls to the rate table. + */ +export function parseZcodeUsageRow(row: Record): UsageRecord | null { + if (row["status"] !== "completed") return null; + + const timestampMs = int(row["completed_at"]) || int(row["started_at"]); + if (timestampMs === 0) return null; + + const model = typeof row["model_id"] === "string" ? row["model_id"] : ""; + if (model.length === 0) return null; + + const id = row["id"]; + const inputTokens = int(row["input_tokens"]); + const cachedInputTokens = int(row["cache_read_input_tokens"]); + const cacheCreationTokens = int(row["cache_creation_input_tokens"]); + + return { + provider: "zcode", + timestampMs, + model, + sessionId: typeof row["session_id"] === "string" ? row["session_id"] : "", + totals: { + // ZCode's input_tokens includes both cache categories. + uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), + cachedInputTokens, + cacheCreationTokens, + outputTokens: int(row["output_tokens"]), + reasoningTokens: int(row["reasoning_tokens"]), + }, + reportedCostUsd: null, + // The row id is unique per request attempt, so it keys de-duplication. + dedupeKey: typeof id === "string" && id.length > 0 ? id : null, + }; +} + export { EMPTY_TOTALS }; diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 1c91ab1b42ef..f54bd3834cda 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -85,6 +85,7 @@ describe("buildDayColumns", () => { expect(first?.bands).toEqual([ { provider: "codex", value: 10 }, { provider: "claude", value: 20 }, + { provider: "zcode", value: 0 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 615980cd460a..1751766288b1 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,6 +1,6 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { ClaudeAI, type Icon, OpenAI } from "../Icons"; +import { ClaudeAI, type Icon, OpenAI, Zed } from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -24,6 +24,11 @@ export const PROVIDER_PRESENTATION = { color: "#d97757", mark: ClaudeAI, }, + zcode: { + label: "ZCode", + color: "#6366f1", + mark: Zed, + }, } satisfies Record; /** Stable provider reading order across charts, summaries, tables, and hover rows. */ diff --git a/docs/user/usage.md b/docs/user/usage.md index 72d19ba77f37..774d27b1cae6 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -1,9 +1,9 @@ # Review usage -The Usage page combines Codex and Claude Code activity from your connected environments. It reads -the providers' local session history and shows API-equivalent token cost, processed tokens, cache -savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token -cost shown here. +The Usage page combines Codex, Claude Code, and ZCode activity from your connected environments. It +reads the providers' local usage history and shows API-equivalent token cost, processed tokens, +cache savings, provider shares, and model breakdowns. Subscription billing is separate from the raw +token cost shown here. Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index cde888a6153e..c7e1fb1ec16b 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -1,11 +1,11 @@ /** * Usage reporting contract. * - * Each environment scans the provider CLIs' own on-disk session transcripts - * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than - * relying on T3 Code's own orchestration projections, so usage stays complete - * even for turns that were never driven through T3 Code. This mirrors the - * approach `ccusage` takes. + * Each environment scans the provider CLIs' own on-disk usage stores + * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`, ZCode's + * `~/.zcode/cli/db/db.sqlite`) rather than relying on T3 Code's own + * orchestration projections, so usage stays complete even for turns that were + * never driven through T3 Code. This mirrors the approach `ccusage` takes. * * Environments return pre-aggregated `(day, hourStart?, provider, model)` * buckets. Raw transcript records never cross the wire. @@ -23,7 +23,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; */ export const USAGE_CONTRACT_VERSION = 4 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +export const UsageProviderKind = Schema.Literals(["claude", "codex", "zcode"]); export type UsageProviderKind = typeof UsageProviderKind.Type; /** From 2463e569ed0d6fb35520b32d5d03f961ca860b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sat, 22 Aug 2026 14:42:14 +0800 Subject: [PATCH 02/11] fix(usage): use the ZCode brand mark --- apps/web/src/components/Icons.tsx | 10 ++++++++++ apps/web/src/components/usage/usageProviders.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..3299ae15cae2 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -484,6 +484,16 @@ export const Zed: Icon = (props) => { ); }; +export const ZCode: Icon = (props) => ( + + + + +); + export const OpenAI: Icon = ({ className, ...props }) => ( ; From 2bf521c72dd7bfdd6e8b95019fa4908c57f9f4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sat, 22 Aug 2026 20:19:52 +0800 Subject: [PATCH 03/11] fix(usage): version ZCode provider contract --- apps/web/src/components/usage/UsagePage.test.tsx | 12 ++++++++---- packages/contracts/src/usage.ts | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 5755e73b1bd4..66760d8ff795 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -54,19 +54,21 @@ vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })) vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); vi.mock("./usageProviders", () => ({ - PROVIDER_ORDER: ["codex", "claude"], + PROVIDER_ORDER: ["codex", "claude", "zcode"], PROVIDER_PRESENTATION: { codex: { color: "white", label: "Codex", mark: "span" }, claude: { color: "orange", label: "Claude Code", mark: "span" }, + zcode: { color: "indigo", label: "ZCode", mark: "span" }, }, })); import { UsagePage } from "./UsagePage"; -const providerTotals = (codex: number, claude: number) => +const providerTotals = (codex: number, claude: number, zcode: number) => new Map([ ["codex", { costUsd: codex, totalTokens: codex * 1_000 }], ["claude", { costUsd: claude, totalTokens: claude * 1_000 }], + ["zcode", { costUsd: zcode, totalTokens: zcode * 1_000 }], ] as const); beforeEach(() => { @@ -79,14 +81,14 @@ beforeEach(() => { hourStart: "2026-08-10T13:37:00.000Z", costUsd: 13, totalTokens: 13_000, - byProvider: providerTotals(7, 6), + byProvider: providerTotals(7, 6, 0), }, { day: "2026-08-11", hourStart: "2026-08-11T11:37:00.000Z", costUsd: 11, totalTokens: 11_000, - byProvider: providerTotals(6, 5), + byProvider: providerTotals(6, 4, 1), }, ], }, @@ -103,8 +105,10 @@ describe("UsagePage hourly breakdown", () => { const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; expect(body.match(/
Date: Sat, 22 Aug 2026 20:30:24 +0800 Subject: [PATCH 04/11] fix(usage): harden ZCode database scans --- apps/server/src/usage/UsageService.ts | 49 +++++++++++++++---- apps/server/src/usage/usageScanCache.ts | 6 +-- .../src/usage/usageTranscriptReader.test.ts | 24 +++++++-- .../server/src/usage/usageTranscriptReader.ts | 33 ++++++++----- 4 files changed, 85 insertions(+), 27 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 5618e7bb03f4..9bfddca61df4 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -284,7 +284,11 @@ export const make = Effect.gen(function* () { size: number, mtimeMs: number, provider: UsageProviderKind, - ): Effect.Effect => + readSinceMs: number, + ): Effect.Effect<{ + readonly records: readonly UsageRecord[]; + readonly failed: boolean; + }> => Effect.gen(function* () { const cached = fileCache.get(filePath); // Provider is part of the identity: if both providers were ever pointed @@ -295,20 +299,22 @@ export const make = Effect.gen(function* () { cached.mtimeMs === mtimeMs && cached.provider === provider ) { - return cached.records; + return { records: cached.records, failed: false }; } - const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + const parsed = yield* Effect.promise(() => + readTranscriptRecords(filePath, provider, readSinceMs), + ); // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. - if (parsed === null) return []; + if (parsed === null) return { records: [], failed: true }; // Stored already de-duplicated within the file, which is 99% of all // duplicates. The aggregator still runs the cross-file dedupe pass. const records = dedupeWithinFile(parsed); fileCache.set(filePath, { size, mtimeMs, provider, records }); cacheDirty = true; - return records; + return { records, failed: false }; }); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { @@ -344,6 +350,11 @@ export const make = Effect.gen(function* () { } const startedAtMs = yield* Clock.currentTimeMillis; + const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; + // A request may start just before the retained window and complete inside + // it. Reuse the file-mtime boundary slack so the indexed ZCode query keeps + // that request while remaining bounded. + const retainedReadStartMs = retentionCutoffMs - MTIME_SLACK_MS; yield* ensureRates(); yield* ensureScanCacheLoaded; @@ -402,13 +413,26 @@ export const make = Effect.gen(function* () { ); let scannedFiles = 0; let skippedFiles = 0; + let readFailures = 0; // Distinct per directory. Buckets carry per-cell session counts, but a // session spans days and models, so clients total this figure instead. const sessionIds = new Set(); for (const file of files) { livePaths.add(file.path); - const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + const result = yield* readFileRecords( + file.path, + file.size, + file.mtimeMs, + provider, + retainedReadStartMs, + ); + if (result.failed) { + readFailures += 1; + skippedFiles += 1; + continue; + } + const { records } = result; if (records.length === 0) { skippedFiles += 1; continue; @@ -423,14 +447,21 @@ export const make = Effect.gen(function* () { } } + const status = + readFailures === 0 ? "ok" : readFailures === files.length ? "failed" : "partial"; sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, - status: "ok", + status, scannedFiles, skippedFiles, malformedRecords: 0, distinctSessions: sessionIds.size, - message: null, + message: + status === "ok" + ? null + : status === "failed" + ? "Usage files could not be read." + : "Some usage files could not be read.", }); } @@ -438,7 +469,7 @@ export const make = Effect.gen(function* () { livePaths, walkedRoots, windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + retentionCutoffMs, }); if (pruned > 0) cacheDirty = true; yield* persistScanCache(); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 7b803eb88cad..ad318440484e 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -18,9 +18,9 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import type { UsageRecord } from "./usageTranscripts.ts"; -// v2: Codex fork-copy suppression changed what a file parses to, so v1 -// entries would keep serving double-counted records forever. -export const USAGE_SCAN_CACHE_VERSION = 2 as const; +// v3: ZCode scans became retention-bounded, so v2 entries may contain lifetime +// history and must not remain resident indefinitely. +export const USAGE_SCAN_CACHE_VERSION = 3 as const; export interface CachedFile { readonly size: number; diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 76dea557c304..f84af80ba36b 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -92,7 +92,7 @@ describe("readTranscriptRecords for zcode", () => { }, ]); try { - const records = await readTranscriptRecords(dbPath, "zcode"); + const records = await readTranscriptRecords(dbPath, "zcode", 0); expect(records).toEqual([ { @@ -129,7 +129,7 @@ describe("readTranscriptRecords for zcode", () => { }, ]); try { - const records = await readTranscriptRecords(dbPath, "zcode"); + const records = await readTranscriptRecords(dbPath, "zcode", 0); expect(records).toHaveLength(1); expect(records?.[0]?.dedupeKey).toBe("row-done"); @@ -143,6 +143,7 @@ describe("readTranscriptRecords for zcode", () => { const missing = await readTranscriptRecords( NodePath.join(NodeOS.tmpdir(), "t3-zcode-usage-no-such-dir", "db.sqlite"), "zcode", + 0, ); expect(missing).toBeNull(); @@ -150,7 +151,7 @@ describe("readTranscriptRecords for zcode", () => { const corruptPath = NodePath.join(corruptDir, "db.sqlite"); NodeFS.writeFileSync(corruptPath, "not a sqlite database"); try { - expect(await readTranscriptRecords(corruptPath, "zcode")).toBeNull(); + expect(await readTranscriptRecords(corruptPath, "zcode", 0)).toBeNull(); } finally { NodeFS.rmSync(corruptDir, { recursive: true, force: true }); } @@ -163,12 +164,27 @@ describe("readTranscriptRecords for zcode", () => { const dbPath = NodePath.join(dir, "db.sqlite"); new NodeSqlite.DatabaseSync(dbPath).close(); try { - expect(await readTranscriptRecords(dbPath, "zcode")).toEqual([]); + expect(await readTranscriptRecords(dbPath, "zcode", 0)).toEqual([]); } finally { NodeFS.rmSync(dir, { recursive: true, force: true }); } }); + it("reads only rows within the retained history", async () => { + const cutoffMs = 1_786_000_000_000; + const { dbPath, cleanup } = createZcodeDb([ + { id: "row-before-cutoff", startedAt: cutoffMs - 1, completedAt: cutoffMs }, + { id: "row-at-cutoff", startedAt: cutoffMs, completedAt: cutoffMs + 1 }, + ]); + try { + const records = await readTranscriptRecords(dbPath, "zcode", cutoffMs); + + expect(records?.map((record) => record.dedupeKey)).toEqual(["row-at-cutoff"]); + } finally { + cleanup(); + } + }); + it("includes the sqlite WAL in the scan fingerprint", async () => { const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-zcode-usage-wal-")); const dbPath = NodePath.join(dir, "db.sqlite"); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 9e0fe7f0484b..bd2068d54c0d 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -27,6 +27,9 @@ import { type UsageRecord, } from "./usageTranscripts.ts"; +/** Wait through brief writer locks without stalling the server indefinitely. */ +const ZCODE_BUSY_TIMEOUT_MS = 1_000; + export interface TranscriptFile { readonly path: string; readonly size: number; @@ -112,27 +115,34 @@ export async function readDirectoryVolumeId(path: string): Promise { } /** - * Reads every usage row from ZCode's sqlite store. + * Reads retained usage rows from ZCode's sqlite store. * - * The whole table is read rather than windowed in SQL because the scan cache - * memoises per `(size, mtime)` independently of the requested window; - * out-of-window rows are dropped by the aggregator. An older schema without - * `model_usage` yields zero records; other read failures return `null` so the - * caller does not cache a transient failure as an empty store. + * `sinceMs` is the service's maximum retention cutoff, not the requested view, + * so one cached result remains valid for the 24-hour through 90-day windows. + * The predicate uses ZCode's `model_usage_started_model_idx` instead of + * materialising lifetime history. An older schema without `model_usage` yields + * zero records; other read failures return `null` so the caller can mark the + * source incomplete and avoid caching a transient failure as an empty store. */ -async function readZcodeUsageRecords(filePath: string): Promise { +async function readZcodeUsageRecords( + filePath: string, + sinceMs: number, +): Promise { let db: NodeSqlite.DatabaseSync | undefined; try { - db = new NodeSqlite.DatabaseSync(filePath, { readOnly: true }); + db = new NodeSqlite.DatabaseSync(filePath, { + readOnly: true, + timeout: ZCODE_BUSY_TIMEOUT_MS, + }); const rows = db .prepare( `SELECT id, session_id, model_id, status, started_at, completed_at, input_tokens, output_tokens, reasoning_tokens, cache_creation_input_tokens, cache_read_input_tokens FROM model_usage - WHERE status = 'completed'`, + WHERE status = 'completed' AND started_at >= ?`, ) - .all(); + .iterate(sinceMs); const records: UsageRecord[] = []; for (const row of rows) { const record = parseZcodeUsageRow(row); @@ -166,8 +176,9 @@ async function readZcodeUsageRecords(filePath: string): Promise { - if (provider === "zcode") return readZcodeUsageRecords(filePath); + if (provider === "zcode") return readZcodeUsageRecords(filePath, sinceMs); const records: UsageRecord[] = []; const codexState = initialCodexScanState(); From 9cf9bdd0836a390ed58f29746386a1b058c67580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sat, 22 Aug 2026 20:37:28 +0800 Subject: [PATCH 05/11] fix(usage): report missing ZCode stores --- apps/server/src/usage/UsageService.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 9bfddca61df4..c11852c376fe 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -79,9 +79,9 @@ const RatesCacheFile = Schema.Struct({ /** * One provider's usage store. * - * `dir` is stat'd for existence and the source fingerprint's volume id, and - * walked for `*.jsonl` transcripts — unless `file` names a single-file store - * (ZCode's sqlite db), which is read instead of walking. + * `dir` supplies the source fingerprint's volume id and is walked for + * `*.jsonl` transcripts — unless `file` names a single-file store (ZCode's + * sqlite db), which is checked for existence and read instead. */ interface TranscriptSource { readonly provider: UsageProviderKind; @@ -389,7 +389,7 @@ export const make = Effect.gen(function* () { const { provider, dir } = source; const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); const exists = yield* fileSystem - .exists(dir) + .exists(source.file ?? dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); if (!exists) { @@ -400,7 +400,10 @@ export const make = Effect.gen(function* () { skippedFiles: 0, malformedRecords: 0, distinctSessions: 0, - message: "No transcript directory on this environment.", + message: + source.file === undefined + ? "No transcript directory on this environment." + : "No usage store on this environment.", }); continue; } From 0429a3ea56ee97627a1f1d85be60328a6bc51e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sat, 22 Aug 2026 20:42:57 +0800 Subject: [PATCH 06/11] fix(usage): surface incomplete provider coverage --- .../src/features/usage/UsageRouteScreen.tsx | 11 ++++ .../src/components/usage/UsagePage.test.tsx | 25 ++++++++ apps/web/src/components/usage/UsagePage.tsx | 26 ++++++-- packages/shared/src/usageMerge.test.ts | 64 ++++++++++++++++++- packages/shared/src/usageMerge.ts | 29 ++++++++- 5 files changed, 145 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 817e6d7f9543..31167922aca2 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -463,10 +463,12 @@ function UsageCoverageNotice(props: { props.merged.staleEnvironments.includes(environment.environmentId), ); const duplicateSources = props.merged.duplicateSources; + const incompleteSources = props.merged.incompleteSources; if ( failed.length === 0 && stale.length === 0 && duplicateSources.length === 0 && + incompleteSources.length === 0 && !props.isPartial ) { return null; @@ -489,6 +491,15 @@ function UsageCoverageNotice(props: { {environment.label} runs an older server version and is excluded from totals. ))} + {incompleteSources.map((source) => ( + + {source.environmentLabel}'s {PROVIDER_LABEL[source.provider]} usage{" "} + {source.status === "failed" ? "could not be read." : "is incomplete."} + + ))} {duplicateSources.length > 0 ? ( Counted once across environments sharing a transcript directory:{" "} diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 66760d8ff795..3779dc7ab970 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -111,4 +111,29 @@ describe("UsagePage hourly breakdown", () => { expect(body).toContain("$1.00"); expect(body.indexOf("$11.00")).toBeLessThan(body.indexOf("$13.00")); }); + + it("warns when a provider source could not be read", () => { + testState.useUsage.mockReturnValue({ + merged: { + ...mergeUsage([], USAGE_CONTRACT_VERSION), + incompleteSources: [ + { + environmentId: "env-a", + environmentLabel: "Local", + provider: "zcode", + status: "failed", + message: "Usage files could not be read.", + }, + ], + }, + environments: [], + isPending: false, + isPartial: false, + refresh: vi.fn(), + }); + + expect(renderToStaticMarkup()).toContain( + "Local's ZCode usage could not be read.", + ); + }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 3c99271c1b2b..c9435b649093 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -2,7 +2,7 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; -import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; +import type { DailyTotals, HourlyTotals, MergedUsage } from "@t3tools/shared/usageMerge"; import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; @@ -207,6 +207,7 @@ export function UsagePage() { @@ -457,25 +458,32 @@ function Metric({ label, value }: { readonly label: string; readonly value: stri } /** - * Says plainly when the totals are incomplete: an environment that failed, or - * one whose transcripts another environment already reported. Environments - * that are still answering never reach this notice; the page shows the - * loading skeleton until every one is terminal. + * Says plainly when the totals are incomplete: an environment or provider + * source failed, or another environment already reported the same transcripts. + * Environments that are still answering never reach this notice; the page + * shows the loading skeleton until every one is terminal. */ function UsageCoverageNotice({ environments, duplicateSources, + incompleteSources, staleEnvironments, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly duplicateSources: readonly string[]; + readonly incompleteSources: MergedUsage["incompleteSources"]; readonly staleEnvironments: readonly string[]; }) { const failed = environments.filter((environment) => environment.error !== null); const stale = environments.filter((environment) => staleEnvironments.includes(environment.environmentId), ); - if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0) { + if ( + failed.length === 0 && + stale.length === 0 && + duplicateSources.length === 0 && + incompleteSources.length === 0 + ) { return null; } @@ -489,6 +497,12 @@ function UsageCoverageNotice({ {environment.label} runs an older server version and is excluded from totals. ))} + {incompleteSources.map((source) => ( +
+ {source.environmentLabel}'s {PROVIDER_PRESENTATION[source.provider].label} usage{" "} + {source.status === "failed" ? "could not be read." : "is incomplete."} + + ))} {duplicateSources.length > 0 ? ( Counted once across environments sharing a transcript directory:{" "} diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 3bee4a9bdc02..31ef2c020889 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -4,6 +4,7 @@ import { type UsageBucket, type UsageDay, type UsageProviderKind, + type UsageSourceStatus, type UsageSummary, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -40,6 +41,8 @@ function summary( homePath: string; volumeId?: string; distinctSessions?: number; + status?: UsageSourceStatus; + message?: string | null; }[], contractVersion: number = USAGE_CONTRACT_VERSION, ): UsageSummary { @@ -57,12 +60,12 @@ function summary( resolvedHomePath: source.homePath, volumeId: source.volumeId ?? `vol-${source.hostId}`, }, - status: "ok" as const, + status: source.status ?? "ok", scannedFiles: 1, skippedFiles: 0, malformedRecords: 0, distinctSessions: source.distinctSessions ?? 1, - message: null, + message: source.message ?? null, })), pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 }, scanDurationMs: 1, @@ -169,6 +172,63 @@ describe("mergeUsage", () => { expect(merged.staleEnvironments).toEqual(["env-b"]); }); + it("reports incomplete sources and excludes a fully failed provider", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ provider: "zcode", model: "glm-5.2", costUsd: 7 }), + bucket({ provider: "claude", costUsd: 3 }), + ], + [ + { + provider: "zcode", + hostId: "mac", + homePath: "/a/.zcode/cli/db", + status: "failed", + message: "Usage files could not be read.", + }, + { + provider: "claude", + hostId: "mac", + homePath: "/a/.claude", + status: "partial", + message: "Some usage files could not be read.", + }, + { + provider: "codex", + hostId: "mac", + homePath: "/a/.codex", + status: "missing", + }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(3); + expect(merged.incompleteSources).toEqual([ + { + environmentId: "env-a", + environmentLabel: "env-a", + provider: "zcode", + status: "failed", + message: "Usage files could not be read.", + }, + { + environmentId: "env-a", + environmentLabel: "env-a", + provider: "claude", + status: "partial", + message: "Some usage files could not be read.", + }, + ]); + }); + it("derives provider shares and cost quality", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 954139b4e10f..d83f45707794 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -61,6 +61,14 @@ export interface CostQuality { readonly cacheSavingsUsd: number; } +export interface IncompleteUsageSource { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly provider: UsageProviderKind; + readonly status: "partial" | "failed"; + readonly message: string | null; +} + export interface MergedUsage { readonly costUsd: number; readonly uncachedInputTokens: number; @@ -78,6 +86,8 @@ export interface MergedUsage { readonly costQuality: CostQuality; /** Environments whose data was dropped as a duplicate of another's. */ readonly duplicateSources: readonly string[]; + /** Provider stores that could not be read completely. */ + readonly incompleteSources: readonly IncompleteUsageSource[]; readonly contributingEnvironments: readonly EnvironmentId[]; readonly staleEnvironments: readonly EnvironmentId[]; } @@ -119,7 +129,7 @@ function claimSources(environments: readonly EnvironmentUsage[]): { for (const environment of ordered) { for (const source of environment.summary.sources) { - if (source.status === "missing") continue; + if (source.status === "missing" || source.status === "failed") continue; const key = fingerprintKey(source.fingerprint); if (ownerByFingerprint.has(key)) { duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); @@ -143,7 +153,7 @@ function ownedContribution( const ownedProviders = new Set(); const sessionsByProvider = new Map(); for (const source of environment.summary.sources) { - if (source.status === "missing") continue; + if (source.status === "missing" || source.status === "failed") continue; const key = fingerprintKey(source.fingerprint); if (ownerByFingerprint.get(key) === environment.environmentId) { const provider = source.fingerprint.provider; @@ -193,6 +203,7 @@ const EMPTY_MERGED: MergedUsage = { cacheSavingsUsd: 0, }, duplicateSources: [], + incompleteSources: [], contributingEnvironments: [], staleEnvironments: [], }; @@ -221,6 +232,19 @@ export function mergeUsage( } const { ownerByFingerprint, duplicates } = claimSources(current); + const incompleteSources: IncompleteUsageSource[] = []; + for (const environment of current) { + for (const source of environment.summary.sources) { + if (source.status !== "partial" && source.status !== "failed") continue; + incompleteSources.push({ + environmentId: environment.environmentId, + environmentLabel: environment.label, + provider: source.fingerprint.provider, + status: source.status, + message: source.message, + }); + } + } let costUsd = 0; let uncachedInputTokens = 0; @@ -411,6 +435,7 @@ export function mergeUsage( cacheSavingsUsd, }, duplicateSources: duplicates, + incompleteSources, contributingEnvironments, staleEnvironments, }; From 93a6e36238c8ddc5a6435bb7229f24efa3f84036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sat, 22 Aug 2026 20:48:34 +0800 Subject: [PATCH 07/11] fix(usage): prefer complete duplicate sources --- packages/shared/src/usageMerge.test.ts | 32 +++++++++++++++++++++ packages/shared/src/usageMerge.ts | 39 ++++++++++++++++---------- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 31ef2c020889..cdebb416691e 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -229,6 +229,38 @@ describe("mergeUsage", () => { ]); }); + it("prefers a complete duplicate without reporting a false coverage gap", () => { + const shared = { + provider: "zcode" as const, + hostId: "mac", + homePath: "/a/.zcode/cli/db", + volumeId: "16777220:1234", + }; + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket({ provider: "zcode", model: "glm-5.2", costUsd: 3 })], + [{ ...shared, status: "partial" }], + ), + ), + environment( + "env-b", + summary( + [bucket({ provider: "zcode", model: "glm-5.2", costUsd: 7 })], + [{ ...shared, status: "ok" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(7); + expect(merged.contributingEnvironments).toEqual(["env-b"]); + expect(merged.incompleteSources).toEqual([]); + }); + it("derives provider shares and cost quality", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index d83f45707794..f2ca837bc7b5 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -113,10 +113,10 @@ function fingerprintKey(fingerprint: UsageSourceFingerprint): string { * Decides which environment owns each physical transcript directory. * * Several environments on one machine (worktree servers, for instance) resolve - * the same provider home and would otherwise double count every token. The - * first environment in a stable order claims a fingerprint; the rest have that - * provider's buckets dropped. Environments are sorted by id so the winner does - * not change between renders. + * the same provider home and would otherwise double count every token. A complete + * source wins over a partial duplicate; ties are sorted by + * environment id so the owner does not change between renders. Fully failed + * and missing sources cannot own a fingerprint. */ function claimSources(environments: readonly EnvironmentUsage[]): { readonly ownerByFingerprint: ReadonlyMap; @@ -125,18 +125,25 @@ function claimSources(environments: readonly EnvironmentUsage[]): { const ownerByFingerprint = new Map(); const duplicates: string[] = []; - const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId)); - - for (const environment of ordered) { - for (const source of environment.summary.sources) { - if (source.status === "missing" || source.status === "failed") continue; - const key = fingerprintKey(source.fingerprint); - if (ownerByFingerprint.has(key)) { - duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); - continue; - } - ownerByFingerprint.set(key, environment.environmentId); + const candidates = environments + .flatMap((environment) => + environment.summary.sources.flatMap((source) => + source.status === "missing" || source.status === "failed" ? [] : [{ environment, source }], + ), + ) + .sort((a, b) => { + const statusOrder = + Number(a.source.status === "partial") - Number(b.source.status === "partial"); + return statusOrder || a.environment.environmentId.localeCompare(b.environment.environmentId); + }); + + for (const { environment, source } of candidates) { + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.has(key)) { + duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); + continue; } + ownerByFingerprint.set(key, environment.environmentId); } return { ownerByFingerprint, duplicates }; @@ -236,6 +243,8 @@ export function mergeUsage( for (const environment of current) { for (const source of environment.summary.sources) { if (source.status !== "partial" && source.status !== "failed") continue; + const owner = ownerByFingerprint.get(fingerprintKey(source.fingerprint)); + if (owner !== undefined && owner !== environment.environmentId) continue; incompleteSources.push({ environmentId: environment.environmentId, environmentLabel: environment.label, From 728bb335bd50aedfef2b723536ba597959da92f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sat, 22 Aug 2026 20:53:10 +0800 Subject: [PATCH 08/11] fix(web): span empty usage table dynamically --- apps/web/src/components/usage/UsagePage.test.tsx | 15 +++++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 3779dc7ab970..6a708c4ee834 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -136,4 +136,19 @@ describe("UsagePage hourly breakdown", () => { "Local's ZCode usage could not be read.", ); }); + + it("spans the empty time table across every provider column", () => { + testState.useUsage.mockReturnValue({ + merged: mergeUsage([], USAGE_CONTRACT_VERSION), + environments: [], + isPending: false, + isPartial: false, + refresh: vi.fn(), + }); + + const markup = renderToStaticMarkup(); + expect(markup).toMatch( + /No activity in this window\.<\/td>/, + ); + }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index c9435b649093..ddf758e521ae 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -391,7 +391,10 @@ export function UsagePage() { {breakdownPeriods.length === 0 ? ( - + No activity in this window. From 1a46109240185ef429746a9c09fbfa0aa8d4479d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sat, 22 Aug 2026 20:40:42 +0800 Subject: [PATCH 09/11] fix(usage): make ZCode scans window-aware --- apps/server/src/usage/UsageService.test.ts | 23 ++++++ apps/server/src/usage/UsageService.ts | 70 +++++++++--------- apps/server/src/usage/usageScanCache.test.ts | 72 ++++++++++++++++++- apps/server/src/usage/usageScanCache.ts | 27 ++++++- .../src/usage/usageTranscriptReader.test.ts | 39 +++++++++- .../server/src/usage/usageTranscriptReader.ts | 15 ++-- apps/server/src/usage/usageTranscripts.ts | 7 +- 7 files changed, 199 insertions(+), 54 deletions(-) create mode 100644 apps/server/src/usage/UsageService.test.ts diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts new file mode 100644 index 000000000000..10d8389aad6f --- /dev/null +++ b/apps/server/src/usage/UsageService.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { summarizeSourceReadFailures } from "./UsageService.ts"; + +describe("summarizeSourceReadFailures", () => { + it("reports a healthy source when every file was readable", () => { + expect(summarizeSourceReadFailures(2, 0)).toEqual({ status: "ok", message: null }); + }); + + it("reports partial coverage when only some files failed", () => { + expect(summarizeSourceReadFailures(2, 1)).toEqual({ + status: "partial", + message: "1 usage file could not be read.", + }); + }); + + it("reports a failed source when every file failed", () => { + expect(summarizeSourceReadFailures(2, 2)).toEqual({ + status: "failed", + message: "2 usage files could not be read.", + }); + }); +}); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index c11852c376fe..3800088e776c 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -49,6 +49,7 @@ import { decodeScanCache, dedupeWithinFile, encodeScanCache, + isReusableCachedFile, pruneScanCache, type ScanCache, } from "./usageScanCache.ts"; @@ -107,6 +108,17 @@ export class UsageService extends Context.Service< } >()("t3/usage/UsageService") {} +export function summarizeSourceReadFailures( + totalFiles: number, + failedFiles: number, +): Pick { + if (failedFiles === 0) return { status: "ok", message: null }; + return { + status: failedFiles === totalFiles ? "failed" : "partial", + message: `${failedFiles} usage file${failedFiles === 1 ? "" : "s"} could not be read.`, + }; +} + /** Empty summary, for suites that only need the RPC surface to resolve. */ export const layerTest = Layer.succeed( UsageService, @@ -284,37 +296,35 @@ export const make = Effect.gen(function* () { size: number, mtimeMs: number, provider: UsageProviderKind, - readSinceMs: number, - ): Effect.Effect<{ - readonly records: readonly UsageRecord[]; - readonly failed: boolean; - }> => + zcodeSinceMs: number, + ): Effect.Effect => Effect.gen(function* () { const cached = fileCache.get(filePath); // Provider is part of the identity: if both providers were ever pointed // at one directory, a hit parsed by the other parser must not be reused. - if ( - cached && - cached.size === size && - cached.mtimeMs === mtimeMs && - cached.provider === provider - ) { - return { records: cached.records, failed: false }; + if (cached && isReusableCachedFile(cached, { size, mtimeMs, provider }, zcodeSinceMs)) { + return cached.records; } const parsed = yield* Effect.promise(() => - readTranscriptRecords(filePath, provider, readSinceMs), + readTranscriptRecords(filePath, provider, zcodeSinceMs), ); // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. - if (parsed === null) return { records: [], failed: true }; + if (parsed === null) return null; // Stored already de-duplicated within the file, which is 99% of all // duplicates. The aggregator still runs the cross-file dedupe pass. const records = dedupeWithinFile(parsed); - fileCache.set(filePath, { size, mtimeMs, provider, records }); + fileCache.set(filePath, { + size, + mtimeMs, + provider, + completeFromMs: provider === "zcode" ? zcodeSinceMs : null, + records, + }); cacheDirty = true; - return { records, failed: false }; + return records; }); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { @@ -351,10 +361,6 @@ export const make = Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; - // A request may start just before the retained window and complete inside - // it. Reuse the file-mtime boundary slack so the indexed ZCode query keeps - // that request while remaining bounded. - const retainedReadStartMs = retentionCutoffMs - MTIME_SLACK_MS; yield* ensureRates(); yield* ensureScanCacheLoaded; @@ -416,26 +422,24 @@ export const make = Effect.gen(function* () { ); let scannedFiles = 0; let skippedFiles = 0; - let readFailures = 0; + let failedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a // session spans days and models, so clients total this figure instead. const sessionIds = new Set(); for (const file of files) { livePaths.add(file.path); - const result = yield* readFileRecords( + const records = yield* readFileRecords( file.path, file.size, file.mtimeMs, provider, - retainedReadStartMs, + windowStartMs, ); - if (result.failed) { - readFailures += 1; - skippedFiles += 1; + if (records === null) { + failedFiles += 1; continue; } - const { records } = result; if (records.length === 0) { skippedFiles += 1; continue; @@ -450,21 +454,15 @@ export const make = Effect.gen(function* () { } } - const status = - readFailures === 0 ? "ok" : readFailures === files.length ? "failed" : "partial"; + const readHealth = summarizeSourceReadFailures(files.length, failedFiles); sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, - status, + status: readHealth.status, scannedFiles, skippedFiles, malformedRecords: 0, distinctSessions: sessionIds.size, - message: - status === "ok" - ? null - : status === "failed" - ? "Usage files could not be read." - : "Some usage files could not be read.", + message: readHealth.message, }); } diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index a8895787a579..f77d590e7414 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -4,6 +4,7 @@ import { decodeScanCache, dedupeWithinFile, encodeScanCache, + isReusableCachedFile, pruneScanCache, type ScanCache, } from "./usageScanCache.ts"; @@ -31,7 +32,13 @@ function record(overrides: Partial = {}): UsageRecord { function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache { const cache: ScanCache = new Map(); for (const [path, mtimeMs, records] of entries) { - cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records }); + cache.set(path, { + size: records.length * 10, + mtimeMs, + provider: "claude", + completeFromMs: null, + records, + }); } return cache; } @@ -60,7 +67,13 @@ describe("scan cache round trip", () => { const original: ScanCache = new Map([ [ "/home/user/.zcode/cli/db/db.sqlite", - { size: 42, mtimeMs: 200, provider: "zcode", records: [zcodeRecord] }, + { + size: 42, + mtimeMs: 200, + provider: "zcode", + completeFromMs: 1_786_000_000_000, + records: [zcodeRecord], + }, ], ]); @@ -71,6 +84,32 @@ describe("scan cache round trip", () => { ); }); + it("drops a ZCode entry whose coverage bound is missing", () => { + const zcodeRecord = record({ provider: "zcode", dedupeKey: "usage-row-1" }); + const encoded = encodeScanCache( + new Map([ + [ + "/db.sqlite", + { + size: 42, + mtimeMs: 200, + provider: "zcode" as const, + completeFromMs: 1_786_000_000_000, + records: [zcodeRecord], + }, + ], + ]), + ); + const withoutCoverage = { + ...encoded, + files: { "/db.sqlite": { ...encoded.files["/db.sqlite"]!, c: undefined } }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(withoutCoverage))).has("/db.sqlite")).toBe( + false, + ); + }); + it("interns repeated model and session strings", () => { const encoded = encodeScanCache( cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), @@ -129,6 +168,35 @@ describe("scan cache round trip", () => { }); }); +describe("isReusableCachedFile", () => { + const zcodeEntry = { + size: 42, + mtimeMs: 200, + provider: "zcode" as const, + completeFromMs: 1_000, + records: [record({ provider: "zcode" })], + }; + const fingerprint = { size: 42, mtimeMs: 200, provider: "zcode" as const }; + + it("reuses a broad ZCode read for a narrower window", () => { + expect(isReusableCachedFile(zcodeEntry, fingerprint, 2_000)).toBe(true); + }); + + it("re-reads ZCode when the requested window starts before cached coverage", () => { + expect(isReusableCachedFile(zcodeEntry, fingerprint, 500)).toBe(false); + }); + + it("always reuses a matching whole-file transcript", () => { + expect( + isReusableCachedFile( + { ...zcodeEntry, provider: "claude", completeFromMs: null }, + { ...fingerprint, provider: "claude" }, + 0, + ), + ).toBe(true); + }); +}); + describe("pruneScanCache", () => { const retentionCutoffMs = 1000; diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index ad318440484e..0d047c1e2049 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -18,19 +18,34 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import type { UsageRecord } from "./usageTranscripts.ts"; -// v3: ZCode scans became retention-bounded, so v2 entries may contain lifetime -// history and must not remain resident indefinitely. +// v3: ZCode scans became window-bounded and carry a per-entry coverage bound; +// v2 entries cannot prove which rows they include. export const USAGE_SCAN_CACHE_VERSION = 3 as const; export interface CachedFile { readonly size: number; readonly mtimeMs: number; readonly provider: UsageProviderKind; + /** Null for whole-file formats; earliest row included for windowed stores. */ + readonly completeFromMs: number | null; readonly records: readonly UsageRecord[]; } export type ScanCache = Map; +export function isReusableCachedFile( + entry: CachedFile, + fingerprint: Pick, + requestedFromMs: number, +): boolean { + return ( + entry.size === fingerprint.size && + entry.mtimeMs === fingerprint.mtimeMs && + entry.provider === fingerprint.provider && + (entry.completeFromMs === null || entry.completeFromMs <= requestedFromMs) + ); +} + /** * Row layout for the serialised form. Positional and interned rather than * object-per-record: on a 30-day window that is the difference between a file @@ -53,6 +68,7 @@ interface SerializedFile { readonly s: number; readonly m: number; readonly p: UsageProviderKind; + readonly c?: number; readonly r: readonly SerializedRecord[]; } @@ -85,6 +101,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { s: entry.size, m: entry.mtimeMs, p: entry.provider, + ...(entry.completeFromMs === null ? {} : { c: entry.completeFromMs }), r: entry.records.map((record) => [ record.timestampMs, intern(models, modelIndex, record.model), @@ -138,6 +155,10 @@ export function decodeScanCache(document: unknown): ScanCache { if (!isRecordArray(entry.r)) continue; const provider: UsageProviderKind = entry.p; + const completeFromMs = typeof entry.c === "number" && Number.isFinite(entry.c) ? entry.c : null; + // ZCode was introduced after cache v2 shipped, so a v2 entry without its + // coverage bound is foreign or incomplete and must be read again. + if (provider === "zcode" && completeFromMs === null) continue; const records: UsageRecord[] = []; // Any corrupt row disqualifies the whole entry. Keeping the survivors // under the original (size, mtime) would read as a valid warm hit and the @@ -194,7 +215,7 @@ export function decodeScanCache(document: unknown): ScanCache { } if (corrupt) continue; - cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); + cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, completeFromMs, records }); } return cache; diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index f84af80ba36b..87a38e9900e4 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -97,7 +97,7 @@ describe("readTranscriptRecords for zcode", () => { expect(records).toEqual([ { provider: "zcode", - timestampMs: 1_786_000_002_500, + timestampMs: 1_786_000_000_000, model: "kimi-k3", sessionId: "session-a", totals: { @@ -116,7 +116,7 @@ describe("readTranscriptRecords for zcode", () => { } }); - it("counts only completed attempts and falls back to started_at", async () => { + it("counts only completed attempts and timestamps them by started_at", async () => { const { dbPath, cleanup } = createZcodeDb([ { id: "row-failed", status: "failed", outputTokens: 10 }, { id: "row-running", status: "running", outputTokens: 10 }, @@ -139,6 +139,41 @@ describe("readTranscriptRecords for zcode", () => { } }); + it("reads only rows inside the requested prefilter window", async () => { + const { dbPath, cleanup } = createZcodeDb([ + { + id: "row-before-cutoff", + startedAt: 1_785_999_999_000, + completedAt: 1_785_999_999_500, + outputTokens: 10, + }, + { id: "row-at-cutoff", startedAt: 1_786_000_000_000, outputTokens: 20 }, + ]); + try { + const records = await readTranscriptRecords(dbPath, "zcode", 1_786_000_000_000); + + expect(records?.map((record) => record.dedupeKey)).toEqual(["row-at-cutoff"]); + } finally { + cleanup(); + } + }); + + it("uses started_at as the cutoff even when completion crosses it", async () => { + const { dbPath, cleanup } = createZcodeDb([ + { + id: "row-crossing-cutoff", + startedAt: 1_785_999_999_999, + completedAt: 1_786_000_000_001, + outputTokens: 10, + }, + ]); + try { + expect(await readTranscriptRecords(dbPath, "zcode", 1_786_000_000_000)).toEqual([]); + } finally { + cleanup(); + } + }); + it("does not turn read failures into cacheable empty records", async () => { const missing = await readTranscriptRecords( NodePath.join(NodeOS.tmpdir(), "t3-zcode-usage-no-such-dir", "db.sqlite"), diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index bd2068d54c0d..77ddfaebbb97 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -117,12 +117,11 @@ export async function readDirectoryVolumeId(path: string): Promise { /** * Reads retained usage rows from ZCode's sqlite store. * - * `sinceMs` is the service's maximum retention cutoff, not the requested view, - * so one cached result remains valid for the 24-hour through 90-day windows. - * The predicate uses ZCode's `model_usage_started_model_idx` instead of - * materialising lifetime history. An older schema without `model_usage` yields - * zero records; other read failures return `null` so the caller can mark the - * source incomplete and avoid caching a transient failure as an empty store. + * `sinceMs` is a conservative indexed prefilter. The caller includes mtime + * slack, and the aggregator applies the exact requested boundary after parsing. + * An older schema without `model_usage` yields zero records; other read + * failures return `null` so the caller does not cache a transient failure as an + * empty store. */ async function readZcodeUsageRecords( filePath: string, @@ -176,9 +175,9 @@ async function readZcodeUsageRecords( export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, - sinceMs: number, + zcodeSinceMs = 0, ): Promise { - if (provider === "zcode") return readZcodeUsageRecords(filePath, sinceMs); + if (provider === "zcode") return readZcodeUsageRecords(filePath, zcodeSinceMs); const records: UsageRecord[] = []; const codexState = initialCodexScanState(); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 8f2372f72732..776623828e87 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -314,13 +314,14 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord * Maps one row of ZCode's `model_usage` sqlite table to a usage record. * * Each row is one model request attempt; only `completed` attempts carried - * real traffic. `completed_at` stamps the record, falling back to - * `started_at`. ZCode stores no cost, so pricing falls to the rate table. + * real traffic. `started_at` stamps the record so the indexed SQLite window + * prefilter and the aggregator use the same boundary. ZCode stores no cost, so + * pricing falls to the rate table. */ export function parseZcodeUsageRow(row: Record): UsageRecord | null { if (row["status"] !== "completed") return null; - const timestampMs = int(row["completed_at"]) || int(row["started_at"]); + const timestampMs = int(row["started_at"]); if (timestampMs === 0) return null; const model = typeof row["model_id"] === "string" ? row["model_id"] : ""; From 5f72a3038c13497e7d809e3464f764fe4294bba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sat, 22 Aug 2026 20:52:52 +0800 Subject: [PATCH 10/11] docs(usage): explain incomplete coverage --- apps/server/src/usage/UsageService.ts | 4 ++-- docs/user/usage.md | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 3800088e776c..5813a9da9454 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -246,8 +246,8 @@ export const make = Effect.gen(function* () { const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); // ZCode has no settings-driven home override; its usage store is always - // the app's own sqlite db. A missing install simply resolves to a dir - // that does not exist, which the scan reports as a missing source. + // the app's own sqlite db. A missing directory or db file is reported as a + // missing source. const zcodeDbDir = path.join(NodeOS.homedir(), ".zcode", "cli", "db"); const sources: readonly TranscriptSource[] = [ diff --git a/docs/user/usage.md b/docs/user/usage.md index 774d27b1cae6..599cf7b865db 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -8,3 +8,7 @@ token cost shown here. Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the headline and chart, and refreshing rescans every connected environment. + +If an environment cannot read one of its provider stores, the page keeps any available usage and +shows that coverage is incomplete. A healthy environment reading the same physical store can +supply the missing coverage without double counting it. From 8ab9122b8d2ad5b7da991d327ca1052b369239c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sat, 22 Aug 2026 21:18:01 +0800 Subject: [PATCH 11/11] fix(usage): negotiate response contract versions --- apps/mobile/src/state/usage.ts | 2 ++ apps/server/src/usage/UsageService.test.ts | 14 +++++++- apps/server/src/usage/UsageService.ts | 24 +++++++++++-- apps/web/src/state/usage.ts | 2 ++ packages/contracts/src/usage.test.ts | 39 ++++++++++++++++++++++ packages/contracts/src/usage.ts | 5 +++ packages/shared/src/usageFormat.test.ts | 5 +++ packages/shared/src/usageFormat.ts | 9 ++++- 8 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 packages/contracts/src/usage.test.ts diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index cce91b65a6d0..bc200569d1e1 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -78,6 +78,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { const windowKey = useMemo( () => JSON.stringify({ + contractVersion: input.contractVersion, sinceDay: input.sinceDay, untilDay: input.untilDay, timeZone: input.timeZone, @@ -86,6 +87,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { untilTime: input.untilTime, }), [ + input.contractVersion, input.sinceDay, input.untilDay, input.timeZone, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 10d8389aad6f..1d2a31ffc4aa 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from "@effect/vitest"; -import { summarizeSourceReadFailures } from "./UsageService.ts"; +import { negotiateUsageContractVersion, summarizeSourceReadFailures } from "./UsageService.ts"; + +describe("negotiateUsageContractVersion", () => { + it("keeps the v4 response shape for clients that do not advertise support", () => { + expect(negotiateUsageContractVersion(undefined)).toBe(4); + expect(negotiateUsageContractVersion(4)).toBe(4); + }); + + it("serves the current response shape to compatible clients", () => { + expect(negotiateUsageContractVersion(5)).toBe(5); + expect(negotiateUsageContractVersion(6)).toBe(5); + }); +}); describe("summarizeSourceReadFailures", () => { it("reports a healthy source when every file was readable", () => { diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 5813a9da9454..36ed376e05d1 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -68,6 +68,9 @@ const RATES_TTL_MS = 24 * 60 * 60 * 1000; const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; +/** Clients predating ZCode omit their supported response contract. */ +const PRE_ZCODE_USAGE_CONTRACT_VERSION = 4 as const; + /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; @@ -119,13 +122,21 @@ export function summarizeSourceReadFailures( }; } +export function negotiateUsageContractVersion( + requestedVersion: number | undefined, +): typeof PRE_ZCODE_USAGE_CONTRACT_VERSION | typeof USAGE_CONTRACT_VERSION { + return requestedVersion !== undefined && requestedVersion >= USAGE_CONTRACT_VERSION + ? USAGE_CONTRACT_VERSION + : PRE_ZCODE_USAGE_CONTRACT_VERSION; +} + /** Empty summary, for suites that only need the RPC surface to resolve. */ export const layerTest = Layer.succeed( UsageService, UsageService.of({ readSummary: (input) => Effect.succeed({ - contractVersion: USAGE_CONTRACT_VERSION, + contractVersion: negotiateUsageContractVersion(input.contractVersion), readAt: "1970-01-01T00:00:00.000Z", timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -328,6 +339,7 @@ export const make = Effect.gen(function* () { }); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const contractVersion = negotiateUsageContractVersion(input.contractVersion); if (input.sinceDay > input.untilDay) { return yield* new UsageReadError({ reason: "invalidWindow", @@ -367,7 +379,13 @@ export const make = Effect.gen(function* () { const hostId = NodeOS.hostname(); // The home resolvers ask for `Path` themselves; satisfy them from the // instance we already hold so `readSummary` stays context-free. - const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const resolvedDirs = yield* resolveTranscriptDirs().pipe( + Effect.provideService(Path.Path, path), + ); + const dirs = + contractVersion >= USAGE_CONTRACT_VERSION + ? resolvedDirs + : resolvedDirs.filter((source) => source.provider !== "zcode"); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ @@ -480,7 +498,7 @@ export const make = Effect.gen(function* () { const finishedAtMs = yield* Clock.currentTimeMillis; return { - contractVersion: USAGE_CONTRACT_VERSION, + contractVersion, readAt: DateTime.formatIso(readAt), timeZone: input.timeZone, sinceDay: input.sinceDay, diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index ba78a61d8a88..93d1a12768d3 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -75,6 +75,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { const windowKey = useMemo( () => JSON.stringify({ + contractVersion: input.contractVersion, sinceDay: input.sinceDay, untilDay: input.untilDay, timeZone: input.timeZone, @@ -83,6 +84,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { untilTime: input.untilTime, }), [ + input.contractVersion, input.sinceDay, input.untilDay, input.timeZone, diff --git a/packages/contracts/src/usage.test.ts b/packages/contracts/src/usage.test.ts new file mode 100644 index 000000000000..2963ce8114d2 --- /dev/null +++ b/packages/contracts/src/usage.test.ts @@ -0,0 +1,39 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { UsageDay, UsageSummaryInput } from "./usage.ts"; + +const LegacyUsageSummaryInput = Schema.Struct({ + sinceDay: UsageDay, + untilDay: UsageDay, + timeZone: Schema.String, +}); +const decodeUsageSummaryInput = Schema.decodeUnknownSync(UsageSummaryInput); +const decodeLegacyUsageSummaryInput = Schema.decodeUnknownSync(LegacyUsageSummaryInput); + +describe("UsageSummaryInput version negotiation", () => { + it("accepts a legacy request with no advertised contract", () => { + const decoded = decodeUsageSummaryInput({ + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + timeZone: "UTC", + }); + + expect(decoded.contractVersion).toBeUndefined(); + }); + + it("lets an old server ignore a new client's advertised contract", () => { + const decoded = decodeLegacyUsageSummaryInput({ + contractVersion: 5, + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + timeZone: "UTC", + }); + + expect(decoded).toEqual({ + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + timeZone: "UTC", + }); + }); +}); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 94b9400bb309..976de62df118 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -161,6 +161,11 @@ export const UsagePricing = Schema.Struct({ export type UsagePricing = typeof UsagePricing.Type; export const UsageSummaryInput = Schema.Struct({ + /** + * Highest response contract the client can decode. Omitted clients receive + * the pre-ZCode v4 shape so rolling upgrades remain wire-compatible. + */ + contractVersion: Schema.optional(Schema.Number), /** Inclusive first day of the window, in `timeZone`. */ sinceDay: UsageDay, /** Inclusive last day of the window, in `timeZone`. */ diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index fb231fbacb20..664c9f758ed5 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics globalDate:off -- A fixed instant keeps calendar-window assertions deterministic. +import { USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { @@ -50,6 +51,10 @@ describe("hourly usage formatting", () => { it("builds an exact minute-aligned 24-hour request", () => { const window = makeWindow(1, new Date("2026-08-11T12:37:42.123Z"), "hour"); + expect(window.contractVersion).toBe(USAGE_CONTRACT_VERSION); + expect(makeWindow(30, new Date("2026-08-11T12:37:42.123Z")).contractVersion).toBe( + USAGE_CONTRACT_VERSION, + ); expect(window.resolution).toBe("hour"); expect(window.sinceTime).toBe("2026-08-10T12:37:00.000Z"); expect(window.untilTime).toBe("2026-08-11T12:37:00.000Z"); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index bd751829dd87..f094203804c8 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -4,7 +4,12 @@ * * @module usageFormat */ -import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts"; +import { + USAGE_CONTRACT_VERSION, + UsageDay, + type UsageResolution, + type UsageSummaryInput, +} from "@t3tools/contracts"; const CURRENCY = new Intl.NumberFormat("en-US", { style: "currency", @@ -208,6 +213,7 @@ export function makeWindow( const sinceTime = new Date(sinceTimeMs); const untilTime = new Date(untilTimeMs); return { + contractVersion: USAGE_CONTRACT_VERSION, sinceDay: UsageDay.make(format.format(sinceTime)), untilDay: UsageDay.make(format.format(untilTime)), timeZone, @@ -224,6 +230,7 @@ export function makeWindow( .map((part) => Number.parseInt(part, 10)); const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1))); return { + contractVersion: USAGE_CONTRACT_VERSION, sinceDay: UsageDay.make(start.toISOString().slice(0, 10)), untilDay: UsageDay.make(untilDay), timeZone,