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/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 9a9ec5f2282d..8a283f5f3aae 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", "mcode"]; export const PROVIDER_LABEL: Record = { claude: "Claude Code", codex: "Codex", + mcode: "MCode", }; /** * 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. MCode's + * official sky blue remains legible in both themes. */ export function useProviderColors(): Record { const { themeAppearance: scheme } = useAppearancePreferences(); return { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + mcode: scheme === "dark" ? "#7DC6FF" : "#2563EB", }; } 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 new file mode 100644 index 000000000000..a08cea7be830 --- /dev/null +++ b/apps/server/src/usage/UsageService.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + chooseMcodeUsageStore, + classifyUsageSourceExistence, + negotiateUsageContractVersion, + resolveMcodeDataDir, + summarizeSourceReadFailures, +} from "./UsageService.ts"; + +describe("classifyUsageSourceExistence", () => { + it("keeps I/O failures distinct from missing sources", () => { + expect(classifyUsageSourceExistence(true)).toBe("present"); + expect(classifyUsageSourceExistence(false)).toBe("missing"); + expect(classifyUsageSourceExistence(null)).toBe("failed"); + }); +}); + +describe("resolveMcodeDataDir", () => { + it("prefers the current MCode override and accepts the legacy name", () => { + expect(resolveMcodeDataDir({ MINIMAX_DATA_DIR: "/custom/mcode" }, "/home/user/.minimax")).toBe( + "/custom/mcode", + ); + expect(resolveMcodeDataDir({ MAVIS_DATA_DIR: "/legacy/mavis" }, "/home/user/.minimax")).toBe( + "/legacy/mavis", + ); + }); + + it("uses the shared TUI and desktop directory by default", () => { + expect(resolveMcodeDataDir({}, "/home/user/.minimax")).toBe("/home/user/.minimax"); + }); +}); + +describe("chooseMcodeUsageStore", () => { + it("uses the alternate when the primary is only an empty compatibility stub", () => { + expect(chooseMcodeUsageStore("primary.sqlite", "absent", "alternate.sqlite", "ready")).toBe( + "alternate.sqlite", + ); + }); + + it("keeps the canonical primary when both stores have accounting", () => { + expect(chooseMcodeUsageStore("primary.sqlite", "ready", "alternate.sqlite", "ready")).toBe( + "primary.sqlite", + ); + }); + + it("keeps the primary fingerprint when its probe fails transiently", () => { + expect(chooseMcodeUsageStore("primary.sqlite", "failed", "alternate.sqlite", "ready")).toBe( + "primary.sqlite", + ); + }); + + it("keeps the alternate path when it is the only store but its probe fails", () => { + expect(chooseMcodeUsageStore("primary.sqlite", "absent", "alternate.sqlite", "failed")).toBe( + "alternate.sqlite", + ); + }); +}); + +describe("negotiateUsageContractVersion", () => { + it("keeps v4 responses decodable for legacy clients", () => { + expect(negotiateUsageContractVersion(undefined)).toBe(4); + expect(negotiateUsageContractVersion(4)).toBe(4); + }); + + it("serves MCode only to compatible clients", () => { + expect(negotiateUsageContractVersion(5)).toBe(5); + expect(negotiateUsageContractVersion(6)).toBe(5); + }); +}); + +describe("summarizeSourceReadFailures", () => { + it("distinguishes healthy, partial, and failed stores", () => { + expect(summarizeSourceReadFailures(1, 0)).toEqual({ status: "ok", message: null }); + expect(summarizeSourceReadFailures(2, 1)).toEqual({ + status: "partial", + message: "1 usage file could not be read.", + }); + expect(summarizeSourceReadFailures(1, 1)).toEqual({ + status: "failed", + message: "1 usage file could not be read.", + }); + }); +}); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0bf131ac973b..b0f0460e4cbc 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -41,13 +41,17 @@ import { UsageAggregator } from "./usageAggregation.ts"; import { parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, + type McodeUsageStoreProbe, + probeMcodeUsageStore, readDirectoryVolumeId, readTranscriptRecords, + statSqliteUsageStore, } from "./usageTranscriptReader.ts"; import { decodeScanCache, dedupeWithinFile, encodeScanCache, + isReusableCachedFile, pruneScanCache, type ScanCache, } from "./usageScanCache.ts"; @@ -66,6 +70,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 MCode omit their supported response contract. */ +const PRE_MCODE_USAGE_CONTRACT_VERSION = 4 as const; + /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; @@ -74,6 +81,12 @@ const RatesCacheFile = Schema.Struct({ fetchedAtMs: Schema.Number, document: Schema.Unknown, }); + +interface TranscriptSource { + readonly provider: UsageProviderKind; + readonly dir: string; + readonly file?: string; +} const decodeRatesCache = Schema.decodeUnknownEffect( Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), ); @@ -93,13 +106,59 @@ export class UsageService extends Context.Service< } >()("t3/usage/UsageService") {} +export function resolveMcodeDataDir( + environment: Readonly>, + defaultDataDir: string, +): string { + return ( + environment["MINIMAX_DATA_DIR"]?.trim() || + environment["MAVIS_DATA_DIR"]?.trim() || + defaultDataDir + ); +} + +export function chooseMcodeUsageStore( + primaryPath: string, + primaryProbe: McodeUsageStoreProbe, + alternatePath: string, + alternateProbe: McodeUsageStoreProbe, +): string { + if (primaryProbe !== "absent") return primaryPath; + return alternateProbe === "absent" ? primaryPath : alternatePath; +} + +export function negotiateUsageContractVersion( + requestedVersion: number | undefined, +): typeof PRE_MCODE_USAGE_CONTRACT_VERSION | typeof USAGE_CONTRACT_VERSION { + return requestedVersion !== undefined && requestedVersion >= USAGE_CONTRACT_VERSION + ? USAGE_CONTRACT_VERSION + : PRE_MCODE_USAGE_CONTRACT_VERSION; +} + +export type UsageSourceExistence = "present" | "missing" | "failed"; + +export function classifyUsageSourceExistence(exists: boolean | null): UsageSourceExistence { + return exists === null ? "failed" : exists ? "present" : "missing"; +} + +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, 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, @@ -219,10 +278,25 @@ 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") }, + const mcodeDataDir = resolveMcodeDataDir(process.env, path.join(NodeOS.homedir(), ".minimax")); + const primaryMcodeDb = path.join(mcodeDataDir, "v2", "sqlite", "runtime-state.sqlite"); + const alternateMcodeDb = path.join(mcodeDataDir, "v2", "chats", "local-runtime.sqlite"); + const [primaryProbe, alternateProbe] = yield* Effect.promise(() => + Promise.all([probeMcodeUsageStore(primaryMcodeDb), probeMcodeUsageStore(alternateMcodeDb)]), + ); + const mcodeDb = chooseMcodeUsageStore( + primaryMcodeDb, + primaryProbe, + alternateMcodeDb, + alternateProbe, + ); + + const sources: readonly TranscriptSource[] = [ + { provider: "claude", dir: claudeDir }, + { provider: "codex", dir: path.join(codexLayout.sharedHomePath, "sessions") }, + { provider: "mcode", dir: path.dirname(mcodeDb), file: mcodeDb }, ]; + return sources; }); /** @@ -263,34 +337,39 @@ export const make = Effect.gen(function* () { size: number, mtimeMs: number, provider: UsageProviderKind, - ): Effect.Effect => + mcodeSinceMs: 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 - ) { + if (cached && isReusableCachedFile(cached, { size, mtimeMs, provider }, mcodeSinceMs)) { return cached.records; } - const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + const parsed = yield* Effect.promise(() => + readTranscriptRecords(filePath, provider, mcodeSinceMs), + ); // 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 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 === "mcode" ? mcodeSinceMs : null, + records, + }); cacheDirty = true; return records; }); 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", @@ -323,13 +402,20 @@ export const make = Effect.gen(function* () { } const startedAtMs = yield* Clock.currentTimeMillis; + const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; yield* ensureRates(); yield* ensureScanCacheLoaded; 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 !== "mcode"); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ @@ -353,36 +439,63 @@ 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) - .pipe(Effect.catchCause(() => Effect.succeed(false))); + .exists(source.file ?? dir) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + const existence = classifyUsageSourceExistence(exists); - if (!exists) { + if (existence !== "present") { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, - status: "missing", + status: existence, scannedFiles: 0, skippedFiles: 0, malformedRecords: 0, distinctSessions: 0, - message: "No transcript directory on this environment.", + message: + existence === "failed" + ? source.file === undefined + ? "Transcript directory could not be inspected." + : "Usage store could not be inspected." + : source.file === undefined + ? "No transcript directory on this environment." + : "No usage store on this environment.", }); continue; } - walkedRoots.push(dir); - const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + const listing = yield* Effect.promise(async () => { + if (source.file === undefined) return listTranscriptFiles(dir, windowStartMs); + const files = await statSqliteUsageStore(source.file, windowStartMs); + return files === null ? { files: [], failedEntries: 1 } : { files, failedEntries: 0 }; + }); + const files = listing.files; + // Only a complete listing proves that an absent cached path was deleted. + // Any failure keeps the warm cache available for the next retry. + if (listing.failedEntries === 0) walkedRoots.push(dir); let scannedFiles = 0; let skippedFiles = 0; + let failedFiles = listing.failedEntries; // 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 records = yield* readFileRecords( + file.path, + file.size, + file.mtimeMs, + provider, + windowStartMs, + ); + if (records === null) { + failedFiles += 1; + continue; + } if (records.length === 0) { skippedFiles += 1; continue; @@ -397,14 +510,18 @@ export const make = Effect.gen(function* () { } } + const readHealth = summarizeSourceReadFailures( + files.length + listing.failedEntries, + failedFiles, + ); sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, - status: "ok", + status: readHealth.status, scannedFiles, skippedFiles, malformedRecords: 0, distinctSessions: sessionIds.size, - message: null, + message: readHealth.message, }); } @@ -412,7 +529,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(); @@ -422,7 +539,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/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 64673e96c090..b9bd9d4bc7a1 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; } @@ -59,6 +66,31 @@ describe("scan cache round trip", () => { expect(encoded.sessions).toEqual(["session-a"]); }); + it("round-trips an MCode SQLite entry with its coverage bound", () => { + const mcodeRecord = record({ + provider: "mcode", + model: "minimax/MiniMax-M3", + sessionId: "mcode-session", + dedupeKey: "mcode:1", + }); + const original: ScanCache = new Map([ + [ + "/home/user/.minimax/v2/sqlite/runtime-state.sqlite", + { + size: 42, + mtimeMs: 200, + provider: "mcode", + completeFromMs: 1_786_000_000_000, + records: [mcodeRecord], + }, + ], + ]); + + expect(decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original))))).toEqual( + original, + ); + }); + it("treats a corrupt or foreign document as an empty cache", () => { // A bad cache should cost one cold scan, never a broken page. expect(decodeScanCache(null).size).toBe(0); @@ -108,6 +140,28 @@ describe("scan cache round trip", () => { }); }); +describe("isReusableCachedFile", () => { + const entry = { + size: 42, + mtimeMs: 100, + provider: "mcode" as const, + completeFromMs: 1_000, + records: [record({ provider: "mcode" })], + }; + + it("reuses a broad MCode scan for a narrower request", () => { + expect(isReusableCachedFile(entry, { size: 42, mtimeMs: 100, provider: "mcode" }, 2_000)).toBe( + true, + ); + }); + + it("rejects a narrow MCode scan for a broader request", () => { + expect(isReusableCachedFile(entry, { size: 42, mtimeMs: 100, provider: "mcode" }, 500)).toBe( + false, + ); + }); +}); + describe("pruneScanCache", () => { const retentionCutoffMs = 1000; diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index cc15ee9cee62..d05cf94d92cf 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"; -// 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: MCode scans are 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), @@ -134,10 +151,12 @@ 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 !== "mcode") continue; if (!isRecordArray(entry.r)) continue; const provider: UsageProviderKind = entry.p; + const completeFromMs = typeof entry.c === "number" && Number.isFinite(entry.c) ? entry.c : null; + if (provider === "mcode" && 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 +213,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 new file mode 100644 index 000000000000..edeb716edfad --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -0,0 +1,206 @@ +// @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 { + listTranscriptFiles, + probeMcodeUsageStore, + readTranscriptRecords, + statSqliteUsageStore, +} from "./usageTranscriptReader.ts"; + +interface McodeRow { + readonly id: number; + readonly sessionId?: string; + readonly model?: string | null; + readonly timestampMs?: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly reasoningTokens?: number; + readonly cacheReadTokens?: number; + readonly cacheWriteTokens?: number; + readonly costUsd?: number | null; +} + +function createMcodeDb(rows: readonly McodeRow[]): { dbPath: string; cleanup: () => void } { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-mcode-usage-")); + const dbPath = NodePath.join(dir, "runtime-state.sqlite"); + const db = new NodeSqlite.DatabaseSync(dbPath); + db.exec(` + CREATE TABLE local_runtime_token_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + agent_name TEXT NOT NULL, + framework_type TEXT NOT NULL, + turn_id TEXT, + model TEXT, + ts INTEGER NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + reasoning_tokens INTEGER NOT NULL, + cache_read_tokens INTEGER NOT NULL, + cache_write_tokens INTEGER NOT NULL, + cost_usd REAL, + raw TEXT + ); + CREATE INDEX idx_local_runtime_token_usage_ts + ON local_runtime_token_usage(ts, id); + `); + const insert = db.prepare(` + INSERT INTO local_runtime_token_usage ( + id, session_id, agent_name, framework_type, turn_id, model, ts, + input_tokens, output_tokens, reasoning_tokens, + cache_read_tokens, cache_write_tokens, cost_usd, raw + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const row of rows) { + insert.run( + row.id, + row.sessionId ?? "session-1", + "general", + "pi-agent", + "turn-1", + row.model === undefined ? "minimax/MiniMax-M3" : row.model, + row.timestampMs ?? 1_786_000_000_000, + row.inputTokens ?? 0, + row.outputTokens ?? 0, + row.reasoningTokens ?? 0, + row.cacheReadTokens ?? 0, + row.cacheWriteTokens ?? 0, + row.costUsd ?? 0, + "{}", + ); + } + db.close(); + return { + dbPath, + cleanup: () => NodeFS.rmSync(dir, { recursive: true, force: true }), + }; +} + +describe("readTranscriptRecords for mcode", () => { + it("reads only token rows inside the requested window", async () => { + const cutoffMs = 1_786_000_000_000; + const { dbPath, cleanup } = createMcodeDb([ + { id: 1, timestampMs: cutoffMs - 1, outputTokens: 10 }, + { + id: 2, + sessionId: "session-a", + timestampMs: cutoffMs, + inputTokens: 120, + outputTokens: 45, + reasoningTokens: 12, + cacheReadTokens: 900, + cacheWriteTokens: 30, + }, + ]); + try { + expect(await readTranscriptRecords(dbPath, "mcode", cutoffMs)).toEqual([ + { + provider: "mcode", + timestampMs: cutoffMs, + model: "minimax/MiniMax-M3", + sessionId: "session-a", + totals: { + uncachedInputTokens: 120, + cachedInputTokens: 900, + cacheCreationTokens: 30, + outputTokens: 45, + reasoningTokens: 12, + }, + reportedCostUsd: null, + dedupeKey: "mcode:2", + }, + ]); + } finally { + cleanup(); + } + }); + + it("detects which compatibility database has canonical usage accounting", async () => { + const { dbPath, cleanup } = createMcodeDb([]); + const emptyPath = NodePath.join(NodePath.dirname(dbPath), "empty.sqlite"); + const incompatiblePath = NodePath.join(NodePath.dirname(dbPath), "incompatible.sqlite"); + const corruptPath = NodePath.join(NodePath.dirname(dbPath), "corrupt.sqlite"); + const missingPath = NodePath.join(NodePath.dirname(dbPath), "missing.sqlite"); + new NodeSqlite.DatabaseSync(emptyPath).close(); + const incompatible = new NodeSqlite.DatabaseSync(incompatiblePath); + incompatible.exec("CREATE TABLE local_runtime_token_usage (id INTEGER PRIMARY KEY)"); + incompatible.close(); + NodeFS.writeFileSync(corruptPath, "not a sqlite database"); + try { + expect(await probeMcodeUsageStore(dbPath)).toBe("ready"); + expect(await probeMcodeUsageStore(emptyPath)).toBe("absent"); + expect(await probeMcodeUsageStore(incompatiblePath)).toBe("absent"); + expect(await probeMcodeUsageStore(corruptPath)).toBe("failed"); + expect(await probeMcodeUsageStore(missingPath)).toBe("absent"); + } finally { + cleanup(); + } + }); + + it("does not turn transient failures into cacheable empty usage", async () => { + expect( + await readTranscriptRecords( + NodePath.join(NodeOS.tmpdir(), "t3-mcode-no-such-dir", "runtime-state.sqlite"), + "mcode", + 0, + ), + ).toBeNull(); + }); + + it("treats a pre-accounting database as an empty store", async () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-mcode-empty-")); + const dbPath = NodePath.join(dir, "runtime-state.sqlite"); + new NodeSqlite.DatabaseSync(dbPath).close(); + try { + expect(await readTranscriptRecords(dbPath, "mcode", 0)).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-mcode-wal-")); + const dbPath = NodePath.join(dir, "runtime-state.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`); + expect(await statSqliteUsageStore(dbPath, 0)).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 }); + } + }); + + it("distinguishes a stat failure from an empty in-window store", async () => { + expect( + await statSqliteUsageStore( + NodePath.join(NodeOS.tmpdir(), "t3-mcode-missing-stat", "runtime-state.sqlite"), + 0, + ), + ).toBeNull(); + }); + + it("reports a transcript root that disappears before the walk", async () => { + const missingRoot = NodePath.join(NodeOS.tmpdir(), "t3-usage-missing-transcript-root"); + expect(await listTranscriptFiles(missingRoot, 0)).toEqual({ + files: [], + failedEntries: 1, + }); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index c72f0c24db65..7c4b34ea49f9 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- Raw filesystem readers and the worker watchdog run below the Effect service boundary. /** * Raw filesystem access for transcript scanning. * @@ -14,6 +14,8 @@ 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 NodeTimers from "node:timers"; +import * as NodeWorkerThreads from "node:worker_threads"; import type { UsageProviderKind } from "@t3tools/contracts"; @@ -22,15 +24,157 @@ import { mightCarryUsage, parseClaudeLine, parseCodexLine, + parseMcodeUsageRow, type UsageRecord, } from "./usageTranscripts.ts"; +/** Wait through brief writer locks without stalling the server indefinitely. */ +const MCODE_BUSY_TIMEOUT_MS = 1_000; +/** Bound a corrupt or unexpectedly expensive store independently of SQLite locks. */ +const MCODE_WORKER_WALL_TIMEOUT_MS = 30_000; + +type McodeWorkerRequest = + | { readonly kind: "probe"; readonly filePath: string; readonly timeoutMs: number } + | { + readonly kind: "read"; + readonly filePath: string; + readonly sinceMs: number; + readonly timeoutMs: number; + }; + +type McodeWorkerResponse = + | { readonly kind: "probe"; readonly status: McodeUsageStoreProbe } + | { readonly kind: "rows"; readonly rows: readonly Record[] } + | { readonly kind: "failed" }; + +// SQLite's Node API is synchronous. Keep both the busy timeout and row +// iteration off the WebSocket server's event loop without adding a separate +// bundle entry: this constant worker program is embedded in the server chunk. +const MCODE_WORKER_SOURCE = String.raw` +const NodeFS = require("node:fs"); +const NodeSqlite = require("node:sqlite"); +const { parentPort, workerData } = require("node:worker_threads"); + +function probe() { + try { + NodeFS.statSync(workerData.filePath); + } catch (error) { + return { + kind: "probe", + status: error && error.code === "ENOENT" ? "absent" : "failed", + }; + } + + let db; + try { + db = new NodeSqlite.DatabaseSync(workerData.filePath, { + readOnly: true, + timeout: workerData.timeoutMs, + }); + db.prepare( + "SELECT id, session_id, model, ts, " + + "input_tokens, output_tokens, reasoning_tokens, " + + "cache_read_tokens, cache_write_tokens, cost_usd " + + "FROM local_runtime_token_usage LIMIT 0", + ); + return { kind: "probe", status: "ready" }; + } catch (error) { + const message = error instanceof Error ? error.message : ""; + return { + kind: "probe", + status: + message.includes("no such table: local_runtime_token_usage") || + message.includes("no such column:") + ? "absent" + : "failed", + }; + } finally { + db?.close(); + } +} + +function readRows() { + let db; + try { + db = new NodeSqlite.DatabaseSync(workerData.filePath, { + readOnly: true, + timeout: workerData.timeoutMs, + }); + const rows = []; + for (const row of db + .prepare( + "SELECT id, session_id, model, ts, " + + "input_tokens, output_tokens, reasoning_tokens, " + + "cache_read_tokens, cache_write_tokens, cost_usd " + + "FROM local_runtime_token_usage WHERE ts >= ? ORDER BY ts, id", + ) + .iterate(workerData.sinceMs)) { + rows.push({ ...row }); + } + return { kind: "rows", rows }; + } catch (error) { + return error instanceof Error && error.message.includes("no such table: local_runtime_token_usage") + ? { kind: "rows", rows: [] } + : { kind: "failed" }; + } finally { + db?.close(); + } +} + +try { + parentPort.postMessage(workerData.kind === "probe" ? probe() : readRows()); +} catch { + parentPort.postMessage({ kind: "failed" }); +} +`; + +function runMcodeWorker(request: McodeWorkerRequest): Promise { + return new Promise((resolve) => { + let settled = false; + let timeout: ReturnType | undefined; + const settle = (value: McodeWorkerResponse | null) => { + if (settled) return; + settled = true; + if (timeout !== undefined) NodeTimers.clearTimeout(timeout); + resolve(value); + }; + + try { + const worker = new NodeWorkerThreads.Worker(MCODE_WORKER_SOURCE, { + eval: true, + workerData: request, + }); + worker.once("message", (message: McodeWorkerResponse) => settle(message)); + worker.once("error", () => settle(null)); + worker.once("exit", () => settle(null)); + timeout = NodeTimers.setTimeout(() => { + void worker.terminate(); + settle(null); + }, MCODE_WORKER_WALL_TIMEOUT_MS); + timeout.unref(); + } catch { + settle(null); + } + }); +} + export interface TranscriptFile { readonly path: string; readonly size: number; readonly mtimeMs: number; } +export interface TranscriptListing { + readonly files: readonly TranscriptFile[]; + readonly failedEntries: number; +} + +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String((error as { readonly code?: unknown }).code) + : undefined; +} + /** * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. * @@ -41,14 +185,19 @@ export interface TranscriptFile { export async function listTranscriptFiles( root: string, sinceMs: number, -): Promise { +): Promise { const found: TranscriptFile[] = []; + let failedEntries = 0; - const walk = async (dir: string): Promise => { + const walk = async (dir: string, isRoot = false): Promise => { let entries; try { entries = await NodeFSP.readdir(dir, { withFileTypes: true }); - } catch { + } catch (error) { + // A nested entry may rotate away between its parent readdir and this + // walk. The root disappearing after the caller's existence check is a + // real source failure, as is any permission or I/O error. + if (isRoot || errorCode(error) !== "ENOENT") failedEntries += 1; return; } for (const entry of entries) { @@ -63,14 +212,53 @@ export async function listTranscriptFiles( if (stats.mtimeMs >= sinceMs) { found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); } - } catch { - // Vanished between readdir and stat. + } catch (error) { + // Vanishing between readdir and stat is benign rotation; other errors + // mean coverage is partial. + if (errorCode(error) !== "ENOENT") failedEntries += 1; } } }; - await walk(root); - return found; + await walk(root, true); + return { files: found, failedEntries }; +} + +/** + * Stats a SQLite usage store with its WAL so active writes invalidate cache + * entries even before the main database checkpoints. + */ +export async function statSqliteUsageStore( + filePath: string, + sinceMs: number, +): Promise { + try { + const stats = await NodeFSP.stat(filePath); + let walStats: Awaited> | null; + try { + walStats = await NodeFSP.stat(`${filePath}-wal`); + } catch (error) { + if (errorCode(error) !== "ENOENT") return null; + walStats = 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 null; + } +} + +export type McodeUsageStoreProbe = "ready" | "absent" | "failed"; + +/** Whether a candidate MCode database contains readable canonical accounting. */ +export async function probeMcodeUsageStore(filePath: string): Promise { + const result = await runMcodeWorker({ + kind: "probe", + filePath, + timeoutMs: MCODE_BUSY_TIMEOUT_MS, + }); + return result?.kind === "probe" ? result.status : "failed"; } /** @@ -89,6 +277,27 @@ export async function readDirectoryVolumeId(path: string): Promise { } } +/** Reads MCode's indexed, per-request token accounting rows. */ +async function readMcodeUsageRecords( + filePath: string, + sinceMs: number, +): Promise { + const result = await runMcodeWorker({ + kind: "read", + filePath, + sinceMs, + timeoutMs: MCODE_BUSY_TIMEOUT_MS, + }); + if (result?.kind !== "rows") return null; + + const records: UsageRecord[] = []; + for (const row of result.rows) { + const record = parseMcodeUsageRow(row); + if (record !== null) records.push(record); + } + return records; +} + /** * Streams one transcript and returns the usage records it contains, or `null` * when the file could not be read. @@ -105,7 +314,10 @@ export async function readDirectoryVolumeId(path: string): Promise { export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, + mcodeSinceMs = 0, ): Promise { + if (provider === "mcode") return readMcodeUsageRecords(filePath, mcodeSinceMs); + const records: UsageRecord[] = []; const codexState = initialCodexScanState(); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 8f86a3d836bd..25d86878dfad 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -4,6 +4,7 @@ import { initialCodexScanState, parseClaudeLine, parseCodexLine, + parseMcodeUsageRow, totalTokens, } from "./usageTranscripts.ts"; @@ -236,6 +237,73 @@ describe("parseCodexLine", () => { }); }); +describe("parseMcodeUsageRow", () => { + it("keeps uncached input separate from both cache categories", () => { + expect( + parseMcodeUsageRow({ + id: 17, + session_id: "session-a", + model: "minimax/MiniMax-M3", + ts: 1_786_000_000_000, + input_tokens: 120, + output_tokens: 45, + reasoning_tokens: 12, + cache_read_tokens: 900, + cache_write_tokens: 30, + cost_usd: 0, + }), + ).toEqual({ + provider: "mcode", + timestampMs: 1_786_000_000_000, + model: "minimax/MiniMax-M3", + sessionId: "session-a", + totals: { + uncachedInputTokens: 120, + cachedInputTokens: 900, + cacheCreationTokens: 30, + outputTokens: 45, + reasoningTokens: 12, + }, + reportedCostUsd: null, + dedupeKey: "mcode:17", + }); + }); + + it("keeps rows with missing historical model attribution", () => { + expect( + parseMcodeUsageRow({ + id: 18, + session_id: "session-b", + model: null, + ts: 1_786_000_001_000, + input_tokens: 10, + output_tokens: 5, + reasoning_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + cost_usd: 0.25, + })?.model, + ).toBe("unknown"); + }); + + it("keeps positive reported cost even when token counters are zero", () => { + expect( + parseMcodeUsageRow({ + id: 19, + session_id: "session-c", + model: "minimax/MiniMax-M3", + ts: 1_786_000_002_000, + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + cost_usd: 0.5, + })?.reportedCostUsd, + ).toBe(0.5); + }); +}); + describe("totalTokens", () => { it("does not add reasoning on top of output", () => { expect( diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 49f9a1935ccc..67a268c8cfe3 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 "mcode": + // MCode usage is read from its SQLite accounting table, never parsed as + // transcript lines. + return false; + } } /* -------------------------------------------------------------------------- */ @@ -297,4 +306,49 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord }; } +/* -------------------------------------------------------------------------- */ +/* MCode */ +/* -------------------------------------------------------------------------- */ + +/** Maps one row of MCode's `local_runtime_token_usage` table. */ +export function parseMcodeUsageRow(row: Record): UsageRecord | null { + const timestampMs = int(row["ts"]); + if (timestampMs === 0) return null; + + const inputTokens = int(row["input_tokens"]); + const cachedInputTokens = int(row["cache_read_tokens"]); + const cacheCreationTokens = int(row["cache_write_tokens"]); + const outputTokens = int(row["output_tokens"]); + const totals: UsageTokenTotals = { + // MCode stores uncached input separately from both cache categories. + uncachedInputTokens: inputTokens, + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens: Math.min(outputTokens, int(row["reasoning_tokens"])), + }; + const rawModel = typeof row["model"] === "string" ? row["model"].trim() : ""; + const rowId = row["id"]; + const cost = row["cost_usd"]; + const reportedCostUsd = + typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null; + if (totalTokens(totals) === 0 && reportedCostUsd === null) return null; + + return { + provider: "mcode", + timestampMs, + model: rawModel || "unknown", + sessionId: typeof row["session_id"] === "string" ? row["session_id"] : "", + totals, + // Subscription-backed MCode records commonly store zero here. Let the + // rate table price those rather than claiming they had no API-equivalent cost. + reportedCostUsd, + dedupeKey: + (typeof rowId === "number" && Number.isFinite(rowId)) || + (typeof rowId === "string" && rowId.length > 0) + ? `mcode:${String(rowId)}` + : null, + }; +} + export { EMPTY_TOTALS }; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..b2c7b286b612 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -484,6 +484,25 @@ export const Zed: Icon = (props) => { ); }; +/** MiniMax Code app mark, shared by the TUI and desktop surfaces. */ +export const MCode: Icon = (props) => ( + + + + + + +); + export const OpenAI: Icon = ({ className, ...props }) => ( ({ WorkspacePageContainer: "main" })) vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); vi.mock("./usageProviders", () => ({ - PROVIDER_ORDER: ["codex", "claude"], + PROVIDER_ORDER: ["codex", "claude", "mcode"], PROVIDER_PRESENTATION: { codex: { color: "white", label: "Codex", mark: "span" }, claude: { color: "orange", label: "Claude Code", mark: "span" }, + mcode: { color: "blue", label: "MCode", mark: "span" }, }, })); import { UsagePage } from "./UsagePage"; -const providerTotals = (codex: number, claude: number) => +const providerTotals = (codex: number, claude: number, mcode: number) => new Map([ ["codex", { costUsd: codex, totalTokens: codex * 1_000 }], ["claude", { costUsd: claude, totalTokens: claude * 1_000 }], + ["mcode", { costUsd: mcode, totalTokens: mcode * 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), }, ], }, @@ -105,6 +107,47 @@ describe("UsagePage hourly breakdown", () => { expect(body.match(/
{ + testState.useUsage.mockReturnValue({ + merged: { + ...mergeUsage([], USAGE_CONTRACT_VERSION), + incompleteSources: [ + { + environmentId: "env-a", + environmentLabel: "Local", + provider: "mcode", + status: "failed", + message: "1 usage file could not be read.", + }, + ], + }, + environments: [], + isPending: false, + isPartial: false, + refresh: vi.fn(), + }); + + expect(renderToStaticMarkup()).toContain( + "Local's MCode 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(), + }); + + expect(renderToStaticMarkup()).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 3c99271c1b2b..ddf758e521ae 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() { @@ -390,7 +391,10 @@ export function UsagePage() { {breakdownPeriods.length === 0 ? (
- + No activity in this window.
@@ -457,25 +461,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 +500,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/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 1c91ab1b42ef..0210a27245f4 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: "mcode", value: 0 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 615980cd460a..599ecddbcf66 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, MCode, OpenAI } from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -24,6 +24,11 @@ export const PROVIDER_PRESENTATION = { color: "#d97757", mark: ClaudeAI, }, + mcode: { + label: "MCode", + color: "var(--usage-provider-mcode)", + mark: MCode, + }, } satisfies Record; /** Stable provider reading order across charts, summaries, tables, and hover rows. */ diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 63751f589019..6ec3ba91e844 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -103,12 +103,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --workspace-native-controls-inset: 0px; --workspace-titlebar-control-size: 1.75rem; --workspace-titlebar-control-gap: 0.75rem; + --usage-provider-mcode: #2563eb; @variant dark { --app-scrollbar-thumb: rgb(255 255 255 / 8%); --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%); --glass-blur: 16px; --glass-saturation: 1.08; + --usage-provider-mcode: #7dc6ff; } } 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/docs/user/usage.md b/docs/user/usage.md index 72d19ba77f37..1a1f450108fb 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -1,10 +1,15 @@ # 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 MCode 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. MCode's shared local runtime covers activity from both its terminal UI and +desktop app. 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. 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 cde888a6153e..dfb4df6f9c43 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`, MCode's + * `~/.minimax/v2/sqlite/runtime-state.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. @@ -21,9 +21,9 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 4 as const; +export const USAGE_CONTRACT_VERSION = 5 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +export const UsageProviderKind = Schema.Literals(["claude", "codex", "mcode"]); export type UsageProviderKind = typeof UsageProviderKind.Type; /** @@ -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-MCode 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, diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 3bee4a9bdc02..8953fc0aaffd 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,73 @@ 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: "mcode", model: "minimax/MiniMax-M3", costUsd: 7 }), + bucket({ provider: "claude", costUsd: 3 }), + ], + [ + { + provider: "mcode", + hostId: "mac", + homePath: "/a/.minimax/v2/sqlite", + status: "failed", + message: "1 usage file could not be read.", + }, + { + provider: "claude", + hostId: "mac", + homePath: "/a/.claude", + status: "partial", + }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(3); + expect(merged.incompleteSources.map((source) => source.provider)).toEqual(["mcode", "claude"]); + }); + + it("prefers a complete MCode duplicate without a false coverage gap", () => { + const shared = { + provider: "mcode" as const, + hostId: "mac", + homePath: "/a/.minimax/v2/sqlite", + volumeId: "16777220:1234", + }; + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket({ provider: "mcode", model: "minimax/MiniMax-M3", costUsd: 3 })], + [{ ...shared, status: "partial" }], + ), + ), + environment( + "env-b", + summary( + [bucket({ provider: "mcode", model: "minimax/MiniMax-M3", 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 954139b4e10f..f2ca837bc7b5 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[]; } @@ -103,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; @@ -115,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") 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 }; @@ -143,7 +160,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 +210,7 @@ const EMPTY_MERGED: MergedUsage = { cacheSavingsUsd: 0, }, duplicateSources: [], + incompleteSources: [], contributingEnvironments: [], staleEnvironments: [], }; @@ -221,6 +239,21 @@ 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; + const owner = ownerByFingerprint.get(fingerprintKey(source.fingerprint)); + if (owner !== undefined && owner !== environment.environmentId) 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 +444,7 @@ export function mergeUsage( cacheSavingsUsd, }, duplicateSources: duplicates, + incompleteSources, contributingEnvironments, staleEnvironments, };