diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 17841cbd74fa..d3e718dab01c 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -11,8 +11,8 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { useMemo, useState } from "react"; -import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; @@ -25,6 +25,7 @@ import { UsageDailyChart } from "./UsageDailyChart"; import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; +import { refreshRebasedUsageWindow, usagePullRefreshTargets } from "./usagePullRefresh"; type UsageTab = "usage" | "limits"; const TAB_OPTIONS = [ @@ -62,6 +63,8 @@ export function UsageRouteScreen() { window: makeWindow(30), })); const [metric, setMetric] = useState("cost"); + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const refreshRequest = useRef(0); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); @@ -91,29 +94,57 @@ export function UsageRouteScreen() { [isPast24Hours, merged.daily, merged.hourly], ); - // The pull spinner tracks re-scans of environments that have answered - // before. The initial scan renders its own placeholder, and an unreachable - // environment stays pending forever — neither may pin the spinner on. - const refreshingUsage = environments.some((entry) => entry.isPending && entry.summary !== null); + const refreshingUsage = isPullRefreshing; const showingLimits = tab === "limits"; + useEffect( + () => () => { + refreshRequest.current += 1; + }, + [], + ); const selectWindow = (days: number) => { + refreshRequest.current += 1; + setIsPullRefreshing(false); setWindowSelection({ days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; const refreshWindow = () => { + const request = ++refreshRequest.current; + const targets = usagePullRefreshTargets(environments); + setIsPullRefreshing(targets.size > 0); + const completeRefresh = () => { + if (request !== refreshRequest.current) return false; + refreshRequest.current += 1; + setIsPullRefreshing(false); + return true; + }; + const failRefresh = (error: unknown) => { + if (!completeRefresh()) return; + Alert.alert( + "Could not refresh usage", + error instanceof Error ? error.message : "Usage could not be refreshed. Try again.", + ); + }; const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { - setWindowSelection({ days: windowDays, window: nextWindow }); + void refreshRebasedUsageWindow( + nextWindow, + refresh, + (refreshedWindow) => { + setWindowSelection({ days: windowDays, window: refreshedWindow }); + }, + () => request === refreshRequest.current, + ).then(completeRefresh, failRefresh); + return; } + void refresh().then(completeRefresh, failRefresh); }; return ( @@ -273,14 +304,14 @@ function ChartCard(props: { - {metric === "cost" ? "Raw token cost" : "Processed tokens"} + {metric === "cost" ? "Local public-list estimate" : "Processed tokens"} {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} {metric === "cost" - ? "* if billed at full API rate" + ? "* estimated from local transcripts at public list rates" : `Across ${formatCount(merged.sessions)} sessions`} @@ -417,7 +448,16 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H + ({ + environmentId: environmentId as EnvironmentId, + summary, + isPending, +}); + +describe("usage pull refresh", () => { + it("shows pull state for answered environments without waiting on initial reads", () => { + const targets = usagePullRefreshTargets([ + status("answered", { readAt: "before" }, false), + status("unreachable", null, true), + ]); + + expect([...targets]).toEqual(["answered"]); + }); + + it("tracks failed retries without waiting on already-pending environments", () => { + const targets = usagePullRefreshTargets([ + status("failed", null, false), + status("already-pending", null, true), + ]); + + expect([...targets]).toEqual(["failed"]); + expect(usagePullRefreshTargets([status("already-pending", null, true)]).size).toBe(0); + }); + + it("commits a rebased window only after its refreshed snapshot publishes", async () => { + const events: string[] = []; + let releaseRates!: () => void; + const rates = new Promise((resolve) => { + releaseRates = resolve; + }); + let releasePublication!: () => void; + const publication = new Promise((resolve) => { + releasePublication = resolve; + }); + const input = { sinceDay: "2026-09-04" } as UsageSummaryInput; + const operation = refreshRebasedUsageWindow( + input, + async () => { + events.push("rates-started"); + await rates; + events.push("rescan-started"); + await publication; + events.push("rescan-published"); + }, + () => events.push("window-committed"), + () => true, + ); + + expect(events).toEqual(["rates-started"]); + releaseRates(); + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual(["rates-started", "rescan-started"]); + releasePublication(); + await operation; + expect(events).toEqual([ + "rates-started", + "rescan-started", + "rescan-published", + "window-committed", + ]); + }); + + it("does not restore a rebased window after a newer selection", async () => { + let releaseRates!: () => void; + const rates = new Promise((resolve) => { + releaseRates = resolve; + }); + const rebased = { sinceDay: "2026-09-04" } as UsageSummaryInput; + const newer = { sinceDay: "2026-08-07" } as UsageSummaryInput; + let selected = rebased; + let activeRequest = 1; + const request = activeRequest; + const operation = refreshRebasedUsageWindow( + rebased, + async () => { + await rates; + }, + (input) => { + selected = input; + }, + () => request === activeRequest, + ); + + selected = newer; + activeRequest += 1; + releaseRates(); + await operation; + + expect(selected).toBe(newer); + }); + + it("does not commit a rebased window when refresh fails", async () => { + const failure = new Error("transcript scan failed"); + let committed = false; + + await expect( + refreshRebasedUsageWindow( + { sinceDay: "2026-09-04" } as UsageSummaryInput, + async () => Promise.reject(failure), + () => { + committed = true; + }, + () => true, + ), + ).rejects.toBe(failure); + expect(committed).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/usage/usagePullRefresh.ts b/apps/mobile/src/features/usage/usagePullRefresh.ts new file mode 100644 index 000000000000..848bf842e685 --- /dev/null +++ b/apps/mobile/src/features/usage/usagePullRefresh.ts @@ -0,0 +1,30 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { UsageSummaryInput } from "@t3tools/contracts"; + +interface UsageRefreshStatus { + readonly environmentId: EnvironmentId; + readonly isPending: boolean; + readonly summary: unknown | null; +} + +/** Selects environments for which pull-to-refresh should show its indicator. */ +export function usagePullRefreshTargets( + environments: readonly UsageRefreshStatus[], +): ReadonlySet { + return new Set( + environments.flatMap((environment) => + environment.summary === null && environment.isPending ? [] : [environment.environmentId], + ), + ); +} + +/** Starts the explicit rescan before committing a rebased window to the screen. */ +export async function refreshRebasedUsageWindow( + input: UsageSummaryInput, + refresh: (input: UsageSummaryInput) => Promise, + commit: (input: UsageSummaryInput) => void, + isCurrent: () => boolean, +): Promise { + await refresh(input); + if (isCurrent()) commit(input); +} diff --git a/apps/mobile/src/state/usage.test.tsx b/apps/mobile/src/state/usage.test.tsx new file mode 100644 index 000000000000..d9555b445a13 --- /dev/null +++ b/apps/mobile/src/state/usage.test.tsx @@ -0,0 +1,263 @@ +import { + USAGE_CONTRACT_VERSION, + UsageDay, + type UsageSummary, + type UsageSummaryInput, +} from "@t3tools/contracts"; +import { act, createElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + statuses: [] as readonly { + environmentId: string; + label: string; + isPending: boolean; + error: string | null; + summary: UsageSummary | null; + }[], + runAtomCommand: vi.fn(), + getAtom: vi.fn(() => ({ waiting: false })), + usageSummary: vi.fn((_request: { environmentId: string; input: UsageSummaryInput }) => ({})), + executeAtomQuery: vi.fn(), +})); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => mocks.statuses })); +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + executeAtomQuery: mocks.executeAtomQuery, + runAtomCommand: mocks.runAtomCommand, + squashAtomCommandFailure: (result: { cause: unknown }) => result.cause, +})); +vi.mock("../lib/uuid", () => ({ uuidv4: () => "refresh-attempt" })); +vi.mock("./atom-registry", () => ({ appAtomRegistry: { get: mocks.getAtom } })); +vi.mock("./presentation", () => ({ presentationsAtom: {} })); +vi.mock("./server", () => ({ + serverEnvironment: { + usageSummary: mocks.usageSummary, + refreshUsageRates: {}, + }, +})); +import { useUsage, type UsageView } from "./usage"; + +const WINDOW_A: UsageSummaryInput = { + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-31"), + timeZone: "UTC", + resolution: "day", +}; +const WINDOW_B: UsageSummaryInput = { + ...WINDOW_A, + sinceDay: UsageDay.make("2026-08-02"), + untilDay: UsageDay.make("2026-09-01"), +}; +const SUMMARY: UsageSummary = { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "2026-08-31T12:00:00.000Z", + timeZone: "UTC", + sinceDay: WINDOW_A.sinceDay, + untilDay: WINDOW_A.untilDay, + buckets: [], + sources: [], + pricing: { status: "unavailable", source: "test", fetchedAt: null, knownModels: 0 }, + scanDurationMs: 0, +}; + +class TestNode { + parentNode: TestNode | null = null; + childNodes: TestNode[] = []; + readonly nodeName: string; + readonly tagName: string; + readonly namespaceURI = "http://www.w3.org/1999/xhtml"; + readonly style = {}; + + constructor( + name: string, + readonly ownerDocument: TestNode | null = null, + readonly nodeType = 1, + ) { + this.nodeName = name.toUpperCase(); + this.tagName = this.nodeName; + } + + set textContent(_value: string) { + this.childNodes = []; + } + + appendChild(child: TestNode) { + child.parentNode = this; + this.childNodes.push(child); + return child; + } + + removeChild(child: TestNode) { + this.childNodes.splice(this.childNodes.indexOf(child), 1); + child.parentNode = null; + return child; + } + + createElement(name: string) { + return new TestNode(name, this); + } + + addEventListener() {} + removeEventListener() {} + setAttribute() {} +} + +function installTestDom() { + const document = new TestNode("#document", null, 9); + const window = { + document, + HTMLIFrameElement: TestNode, + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", window); + vi.stubGlobal("HTMLIFrameElement", window.HTMLIFrameElement); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + return document; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function Harness({ + input, + onView, +}: { + input: UsageSummaryInput; + onView: (view: UsageView) => void; +}) { + onView(useUsage(input)); + return null; +} + +async function renderHarness(input: UsageSummaryInput, onView: (view: UsageView) => void) { + const document = installTestDom(); + // The mobile app does not ship react-dom types, but the lightweight host + // renderer keeps this hook test independent from a native runtime. + // @ts-expect-error react-dom is only used by this test harness. + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.createElement("div") as unknown as Element); + await act(() => root.render(createElement(Harness, { input, onView }))); + return root; +} + +describe("mobile useUsage requested-window refresh", () => { + beforeEach(() => { + mocks.statuses = [ + { environmentId: "env-1", label: "Local", isPending: false, error: null, summary: SUMMARY }, + ]; + mocks.runAtomCommand.mockReset(); + mocks.getAtom.mockReset(); + mocks.getAtom.mockReturnValue({ waiting: false }); + mocks.usageSummary.mockClear(); + mocks.executeAtomQuery.mockReset(); + }); + + it("awaits the token scan and target-window publication before completing", async () => { + const rates = deferred<{ _tag: "Success" | "Failure" }>(); + const published = deferred<{ _tag: "Success" | "Failure" }>(); + mocks.runAtomCommand.mockReturnValue(rates.promise); + mocks.executeAtomQuery + .mockResolvedValueOnce({ _tag: "Success" }) + .mockReturnValueOnce(published.promise); + let view!: UsageView; + const root = await renderHarness(WINDOW_A, (nextView) => { + view = nextView; + }); + + try { + let completed = false; + const refresh = view.refresh(WINDOW_B).then(() => { + completed = true; + }); + await Promise.resolve(); + expect(mocks.executeAtomQuery).not.toHaveBeenCalled(); + expect(completed).toBe(false); + + rates.resolve({ _tag: "Success" }); + await rates.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect(mocks.executeAtomQuery).toHaveBeenCalledTimes(2); + const tokenInput = mocks.usageSummary.mock.calls[0]?.[0]?.input; + expect(tokenInput).toEqual({ + ...WINDOW_B, + refreshToken: expect.any(String), + }); + if (tokenInput?.refreshToken === undefined) throw new Error("missing refresh token"); + expect(JSON.parse(tokenInput.refreshToken)).toEqual([expect.any(String), "refresh-attempt"]); + expect(mocks.usageSummary).toHaveBeenNthCalledWith(2, { + environmentId: "env-1", + input: WINDOW_B, + }); + expect(completed).toBe(false); + + published.resolve({ _tag: "Success" }); + await refresh; + expect(completed).toBe(true); + } finally { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + } + }); + + it("rejects a failed retry without waiting on an environment still doing its initial read", async () => { + const failure = new Error("transcript scan failed"); + mocks.statuses = [ + { + environmentId: "failed", + label: "Failed", + isPending: false, + error: "This environment could not report usage.", + summary: null, + }, + { + environmentId: "initial", + label: "Initial", + isPending: true, + error: null, + summary: null, + }, + ]; + mocks.runAtomCommand.mockResolvedValue({ _tag: "Success" }); + mocks.executeAtomQuery.mockResolvedValue({ _tag: "Failure", cause: failure }); + let view!: UsageView; + const root = await renderHarness(WINDOW_A, (nextView) => { + view = nextView; + }); + + try { + await expect(view.refresh(WINDOW_B)).rejects.toBe(failure); + expect(mocks.runAtomCommand).toHaveBeenCalledOnce(); + expect(mocks.runAtomCommand.mock.calls[0]?.[2]).toEqual({ + environmentId: "failed", + input: {}, + }); + expect(mocks.usageSummary).toHaveBeenCalledOnce(); + expect(mocks.usageSummary.mock.calls[0]?.[0]).toEqual({ + environmentId: "failed", + input: { + ...WINDOW_B, + refreshToken: JSON.stringify(["unknown", "refresh-attempt"]), + }, + }); + } finally { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index f5bdc0d0858b..dadbb832c346 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,12 +16,24 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; -import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; +import { + executeAtomQuery, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + makeUsageRefreshToken, + mergeUsage, + retainUsageStatuses, + type EnvironmentUsage, + type MergedUsage, + type SettledUsageStatuses, +} from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; +import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; @@ -72,11 +84,11 @@ export interface UsageView { * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; - readonly refresh: () => void; + readonly refresh: (requestedInput?: UsageSummaryInput) => Promise; } export function useUsage(input: UsageSummaryInput): UsageView { - const windowKey = useMemo( + const rangeKey = useMemo( () => JSON.stringify({ sinceDay: input.sinceDay, @@ -95,8 +107,29 @@ export function useUsage(input: UsageSummaryInput): UsageView { input.untilTime, ], ); + const windowKey = rangeKey; const atom = usageByWindowAtom(windowKey); - const environments = useAtomValue(atom); + const currentEnvironments = useAtomValue(atom); + const settledStatuses = useRef | null>(null); + const retained = retainUsageStatuses(rangeKey, currentEnvironments, settledStatuses.current); + settledStatuses.current = retained.settled; + const environments = retained.visible; + + const answered = useMemo( + () => + environments.flatMap((environment) => + environment.summary === null + ? [] + : [ + { + environmentId: environment.environmentId, + label: environment.label, + summary: environment.summary, + }, + ], + ), + [environments], + ); // Refreshing only the derived atom would re-read the per-environment SWR // queries within their stale window and change nothing. Refresh each @@ -105,34 +138,59 @@ export function useUsage(input: UsageSummaryInput): UsageView { // Each environment refetches model pricing first, so a model released since // its last daily fetch gets priced by the rescan. The rescan runs whether or // not the refetch succeeds: an offline environment still recounts tokens. - const refresh = useCallback(() => { - const input = JSON.parse(windowKey) as UsageSummaryInput; - for (const environment of environments) { - const { environmentId } = environment; - const query = serverEnvironment.usageSummary({ environmentId, input }); - void runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ).finally(() => appAtomRegistry.refresh(query)); - } - }, [environments, windowKey]); + const refresh = useCallback( + (requestedInput?: UsageSummaryInput) => { + const currentInput = requestedInput ?? (JSON.parse(windowKey) as UsageSummaryInput); + const refreshEnvironments = environments.filter( + (environment) => environment.summary !== null || !environment.isPending, + ); + const refreshToken = JSON.stringify([makeUsageRefreshToken(answered) ?? "unknown", uuidv4()]); + const rateRefreshes = refreshEnvironments.map(({ environmentId }) => + runAtomCommand( + appAtomRegistry, + serverEnvironment.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ), + ); + return Promise.allSettled(rateRefreshes).then(async () => { + const refreshes = await Promise.allSettled( + refreshEnvironments.map(async ({ environmentId }) => { + const refreshed = await executeAtomQuery( + appAtomRegistry, + serverEnvironment.usageSummary({ + environmentId, + input: { ...currentInput, refreshToken }, + }), + { reportFailure: false, refresh: true }, + ); + if (refreshed._tag === "Failure") throw squashAtomCommandFailure(refreshed); + const baseAtom = serverEnvironment.usageSummary({ + environmentId, + input: currentInput, + }); + if (appAtomRegistry.get(baseAtom).waiting) { + await executeAtomQuery(appAtomRegistry, baseAtom, { + reportFailure: false, + }); + } + const published = await executeAtomQuery(appAtomRegistry, baseAtom, { + reportFailure: false, + refresh: true, + }); + if (published._tag === "Failure") throw squashAtomCommandFailure(published); + }), + ); + const failed = refreshes.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failed !== undefined) throw failed.reason; + }); + }, + [answered, environments, windowKey], + ); - const merged = useMemo(() => { - const answered: EnvironmentUsage[] = environments.flatMap((environment) => - environment.summary === null - ? [] - : [ - { - environmentId: environment.environmentId, - label: environment.label, - summary: environment.summary, - }, - ], - ); - return mergeUsage(answered, USAGE_CONTRACT_VERSION); - }, [environments]); + const merged = useMemo(() => mergeUsage(answered, USAGE_CONTRACT_VERSION), [answered]); const answeredCount = environments.filter((environment) => environment.summary !== null).length; const stillReporting = environments.filter( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 7bd1ed6c45f1..e18aa284f33a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -57,6 +57,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetUsageThreadBreakdown]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index eab723d7909a..302bf3f53d5c 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -224,6 +224,7 @@ export const make = Effect.gen(function* () { environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, + usageThreadFilter: true, threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 8b41bd3e518c..7ff3b5e693b8 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -11,6 +11,7 @@ import { type AgentSessionImportSource, } from "@t3tools/contracts"; import { assert, expect, it } from "@effect/vitest"; +import { assertSome } from "@effect/vitest/utils"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -21,6 +22,7 @@ import { SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; +import { readProviderResumeCursorHistory } from "../providerResumeCursorHistory.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; @@ -145,6 +147,91 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }), ); + it.effect("retains replaced provider resume cursors in runtime history", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = ThreadId.make("thread-resume-history"); + + yield* directory.upsert({ + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + threadId, + resumeCursor: { resume: "provider-session-old", resumeSessionAt: "turn-1", turnCount: 1 }, + runtimePayload: { cwd: "/tmp/project" }, + }); + yield* directory.upsert({ + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + threadId, + resumeCursor: { resume: "provider-session-old", resumeSessionAt: "turn-2", turnCount: 2 }, + }); + yield* directory.upsert({ + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + threadId, + resumeCursor: { resume: "provider-session-new", resumeSessionAt: "turn-1", turnCount: 1 }, + }); + yield* directory.upsert({ + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + threadId, + resumeCursor: { resume: "provider-session-new", resumeSessionAt: "turn-2", turnCount: 2 }, + }); + + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.deepEqual(readProviderResumeCursorHistory(runtime.value.runtimePayload), [ + { + providerName: "claudeAgent", + resumeCursor: { + resume: "provider-session-old", + resumeSessionAt: "turn-2", + turnCount: 2, + }, + }, + ]); + assert.equal( + (runtime.value.runtimePayload as Record)["cwd"], + "/tmp/project", + ); + } + }), + ); + + it.effect("retains a supported cursor when the replacement provider has no usage session", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = ThreadId.make("thread-provider-replacement-history"); + + yield* directory.upsert({ + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + threadId, + resumeCursor: { resume: "claude-session" }, + }); + yield* directory.upsert({ + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + threadId, + resumeCursor: { schemaVersion: 1, sessionId: "opencode-session" }, + }); + + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.deepEqual(readProviderResumeCursorHistory(runtime.value.runtimePayload), [ + { + providerName: "claudeAgent", + resumeCursor: { resume: "claude-session" }, + }, + ]); + } + }), + ); + it.effect("keeps the existing binding when an insert conflicts", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 29ec8d2ed168..5bc9b6baf308 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; +import { preservePreviousResumeCursor } from "../providerResumeCursorHistory.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; import { ProviderSessionDirectory, @@ -125,6 +126,16 @@ const makeProviderSessionDirectory = Effect.gen(function* () { issue: "providerInstanceId is required for provider session runtime bindings.", }); } + const runtimePayload = preservePreviousResumeCursor({ + previousProviderName: existingRuntime?.providerName ?? binding.provider, + nextProviderName: binding.provider, + previousResumeCursor: existingRuntime?.resumeCursor ?? null, + nextResumeCursor: binding.resumeCursor, + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }); yield* repository .upsert( { @@ -143,10 +154,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { binding.resumeCursor !== undefined ? binding.resumeCursor : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), + runtimePayload, }, options, ) diff --git a/apps/server/src/provider/providerResumeCursorHistory.ts b/apps/server/src/provider/providerResumeCursorHistory.ts new file mode 100644 index 000000000000..b94e233c0889 --- /dev/null +++ b/apps/server/src/provider/providerResumeCursorHistory.ts @@ -0,0 +1,87 @@ +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +const HISTORY_KEY = "_t3PreviousResumeCursors"; + +const ProviderResumeCursorHistoryEntry = Schema.Struct({ + providerName: Schema.String, + resumeCursor: Schema.Unknown, +}); +export type ProviderResumeCursorHistoryEntry = typeof ProviderResumeCursorHistoryEntry.Type; + +const decodeHistory = Schema.decodeUnknownOption(Schema.Array(ProviderResumeCursorHistoryEntry)); + +/** Reads the stable transcript-session identity from a provider resume cursor. */ +export function providerResumeCursorSessionId( + providerName: string, + resumeCursor: unknown, +): string | null { + if (!Predicate.isObject(resumeCursor)) return null; + const sessionId = + providerName === "claudeAgent" + ? resumeCursor["resume"] + : providerName === "codex" + ? resumeCursor["threadId"] + : providerName === "grok" + ? resumeCursor["sessionId"] + : null; + return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null; +} + +/** Reads the previous provider sessions retained inside a runtime payload. */ +export function readProviderResumeCursorHistory( + runtimePayload: unknown | null, +): readonly ProviderResumeCursorHistoryEntry[] { + if (!Predicate.isObject(runtimePayload)) return []; + return Option.getOrElse(decodeHistory(runtimePayload[HISTORY_KEY]), () => []); +} + +/** Retains the current cursor before a replacement provider session overwrites it. */ +export function preservePreviousResumeCursor(input: { + readonly previousProviderName: string; + readonly nextProviderName: string; + readonly previousResumeCursor: unknown | null; + readonly nextResumeCursor: unknown | undefined; + readonly runtimePayload: unknown | null; +}): unknown | null { + const previousSessionId = providerResumeCursorSessionId( + input.previousProviderName, + input.previousResumeCursor, + ); + const nextSessionId = providerResumeCursorSessionId( + input.nextProviderName, + input.nextResumeCursor, + ); + if (previousSessionId === null || input.nextResumeCursor === undefined) { + return input.runtimePayload; + } + const previousSessionKey = `${input.previousProviderName}:${previousSessionId}`; + if ( + nextSessionId !== null && + previousSessionKey === `${input.nextProviderName}:${nextSessionId}` + ) { + return input.runtimePayload; + } + + const history = readProviderResumeCursorHistory(input.runtimePayload); + if ( + history.some((entry) => { + const sessionId = providerResumeCursorSessionId(entry.providerName, entry.resumeCursor); + return sessionId !== null && `${entry.providerName}:${sessionId}` === previousSessionKey; + }) + ) { + return input.runtimePayload; + } + + return { + ...(Predicate.isObject(input.runtimePayload) ? input.runtimePayload : {}), + [HISTORY_KEY]: [ + ...history, + { + providerName: input.previousProviderName, + resumeCursor: input.previousResumeCursor, + }, + ], + }; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c0be0c444573..0c0b7100c51f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -28,6 +28,8 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import { ProjectionProjectRepositoryLive } from "./persistence/Layers/ProjectionProjects.ts"; +import { ProjectionThreadRepositoryLive } from "./persistence/Layers/ProjectionThreads.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; @@ -196,7 +198,14 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( Layer.provideMerge(ServerSettingsLayerLive), ); -const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); +const UsageLayerLive = UsageService.layer.pipe( + // Projects resolve each session's cwd to the project it ran in; threads and + // resume cursors attribute sessions to threads for the drill-down. + Layer.provide(ProjectionProjectRepositoryLive), + Layer.provide(ProjectionThreadRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), + Layer.provide(ServerSettingsLayerLive), +); const ResourceDiagnosticsLayerLive = Layer.mergeAll( ResourceTelemetryLayerLive, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9d728c88cf42..c4daa7d61f11 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -7,28 +7,41 @@ import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import { ThreadId, UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; +import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; +import { ProjectionThreadRepositoryLive } from "../persistence/Layers/ProjectionThreads.ts"; +import { ProjectionProjectRepository } from "../persistence/Services/ProjectionProjects.ts"; +import { PersistenceSqlError } from "../persistence/Errors.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; -function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { +function claudeLine( + id: number, + outputTokens: number, + model = "claude-fable-5", + cwd?: string, +): string { return `${JSON.stringify({ type: "assistant", timestamp: "2026-08-01T10:00:00Z", requestId: `req_${id}`, sessionId: "session-1", + ...(cwd === undefined ? {} : { cwd }), message: { id: `msg_${id}`, model, @@ -37,12 +50,43 @@ function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): })}\n`; } +function codexRollout(sessionId: string, cwd: string, outputTokens: number): string { + return [ + { + type: "session_meta", + timestamp: "2026-08-01T10:00:00Z", + payload: { type: "session_meta", id: sessionId, cwd }, + }, + { + type: "turn_context", + timestamp: "2026-08-01T10:00:01Z", + payload: { type: "turn_context", model: "gpt-5.2-codex" }, + }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:05Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 100, output_tokens: outputTokens } }, + }, + }, + ] + .map((line) => JSON.stringify(line)) + .join("\n"); +} + const WINDOW: UsageSummaryInput = { timeZone: "UTC", sinceDay: UsageDay.make("2026-07-31"), untilDay: UsageDay.make("2026-08-02"), }; +const NARROW_WINDOW: UsageSummaryInput = { + ...WINDOW, + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-01"), +}; + const setup = Effect.gen(function* () { const home = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-service-test-")), @@ -71,6 +115,8 @@ const serviceLayers = (input: { readonly onRatesFetch?: () => void; /** Defaults to an unparsable document so every scan retries the fetch. */ readonly ratesDocument?: unknown; + readonly projectRepository?: ProjectionProjectRepository["Service"]; + readonly runtimeRepository?: ProviderSessionRuntime.ProviderSessionRuntimeRepository["Service"]; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), @@ -91,6 +137,21 @@ const serviceLayers = (input: { Layer.provideMerge( Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }), ), + Layer.provideMerge( + Layer.mergeAll( + input.projectRepository === undefined + ? ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)) + : Layer.succeed(ProjectionProjectRepository, input.projectRepository), + ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + input.runtimeRepository === undefined + ? ProviderSessionRuntime.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)) + : Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + input.runtimeRepository, + ), + SqlitePersistenceMemory, + ), + ), ); function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { @@ -98,6 +159,142 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + for (const refreshToken of [undefined, "turn-refresh"]) { + it.live( + `does not parse unrelated Codex token content for a targeted thread read (${refreshToken ?? "initial"})`, + () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const sessionsDir = NodePath.join(home, "codex", "sessions", "2026", "08", "01"); + yield* Effect.promise(() => NodeFSP.mkdir(sessionsDir, { recursive: true })); + const targetPath = NodePath.join( + sessionsDir, + "rollout-2026-08-01T10-00-00-target-session.jsonl", + ); + const unrelatedPath = NodePath.join( + sessionsDir, + "rollout-2026-08-01T10-00-00-unrelated-session.jsonl", + ); + yield* Effect.promise(() => + Promise.all([ + NodeFSP.writeFile(targetPath, codexRollout("target-session", "/work/target", 7)), + NodeFSP.writeFile( + unrelatedPath, + codexRollout("unrelated-session", "/work/other", 999), + ), + ]), + ); + + yield* Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const runtimeRepository = + yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + yield* runtimeRepository.upsert({ + threadId: ThreadId.make("target-thread"), + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-08-01T10:00:00.000Z", + resumeCursor: { threadId: "target-session" }, + runtimePayload: null, + }); + const service = yield* UsageService.make; + const breakdown = yield* service.readThreadBreakdown({ + ...WINDOW, + threadId: ThreadId.make("target-thread"), + ...(refreshToken === undefined ? {} : { refreshToken }), + }); + assert.strictEqual(breakdown.rows.length, 1); + assert.strictEqual(breakdown.rows[0]?.totals.outputTokens, 7); + + const persisted = (yield* Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Unknown), + )( + yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(config.stateDir, "usage-scan-cache.json"), "utf8"), + ), + )) as { files: Record; identities: Record }; + assert.deepStrictEqual(Object.keys(persisted.files), [targetPath]); + assert.deepStrictEqual( + Object.keys(persisted.identities).sort(), + [targetPath, unrelatedPath].sort(), + ); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-target-prefilter-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + } + + it.live("filters a targeted thread from the summary's cached source snapshot", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const sessionsDir = NodePath.join(home, "codex", "sessions", "2026", "08", "01"); + yield* Effect.promise(() => NodeFSP.mkdir(sessionsDir, { recursive: true })); + const targetPath = NodePath.join(sessionsDir, "rollout-opaque-target.jsonl"); + const unrelatedPath = NodePath.join(sessionsDir, "rollout-opaque-unrelated.jsonl"); + yield* Effect.promise(() => + Promise.all([ + NodeFSP.writeFile(targetPath, codexRollout("target-session", "/work/target", 7)), + NodeFSP.writeFile(unrelatedPath, codexRollout("unrelated-session", "/work/other", 999)), + ]), + ); + + yield* Effect.gen(function* () { + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + yield* runtimeRepository.upsert({ + threadId: ThreadId.make("target-thread"), + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-08-01T10:00:00.000Z", + resumeCursor: { threadId: "target-session" }, + runtimePayload: null, + }); + const service = yield* UsageService.make; + const summary = yield* service.readSummary(WINDOW); + yield* Effect.promise(() => + Promise.all([ + NodeFSP.appendFile( + targetPath, + `\n${codexRollout("target-session", "/work/target", 70)}`, + ), + NodeFSP.appendFile( + unrelatedPath, + `\n${codexRollout("unrelated-session", "/work/other", 9_999)}`, + ), + ]), + ); + const breakdown = yield* service.readThreadBreakdown({ + ...WINDOW, + threadId: ThreadId.make("target-thread"), + }); + + assert.strictEqual(breakdown.rows.length, 1); + assert.strictEqual(breakdown.rows[0]?.totals.outputTokens, 7); + assert.strictEqual(breakdown.readAt, summary.readAt); + + const refreshed = yield* service.readThreadBreakdown({ + ...WINDOW, + threadId: ThreadId.make("target-thread"), + refreshToken: "completed-turn", + }); + assert.strictEqual(refreshed.rows.length, 1); + assert.strictEqual(refreshed.rows[0]?.totals.outputTokens, 77); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-target-cached-snapshot-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -150,15 +347,127 @@ describe("UsageService", () => { Effect.provide(serviceLayers({ prefix: "usage-service-grow-test", home, settings })), ); - const first = yield* service.readSummary(WINDOW); + const first = yield* service.readSummary(NARROW_WINDOW); assert.strictEqual(totalOutputTokens(first), 5); yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + // Expanding beyond the cached coverage requires a source update. The + // grown transcript resumes at its cached byte position. const second = yield* service.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(second), 12); }).pipe(Effect.scoped), ); + it.live("replaces a cached progressive snapshot when a transcript grows", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-progressive-test", home, settings })), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(1, 12))); + const second = yield* service.readSummary({ ...WINDOW, refreshToken: "progressive-final" }); + assert.strictEqual(totalOutputTokens(second), 12); + }).pipe(Effect.scoped), + ); + + it.live("keeps project attribution unknown when the project repository cannot be read", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => + NodeFSP.writeFile(transcript, claudeLine(1, 5, "claude-fable-5", "/work/app")), + ); + const repositoryFailure = Effect.fail( + new PersistenceSqlError({ operation: "ProjectionProjectRepository.listAll:test" }), + ); + const projectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => repositoryFailure, + getById: () => repositoryFailure, + listAll: () => repositoryFailure, + deleteById: () => repositoryFailure, + }; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-project-failure-test", + home, + settings, + projectRepository, + }), + ), + ); + + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(summary.buckets[0]?.projectAttribution, "unknown"); + }).pipe(Effect.scoped), + ); + + it.live("does not hide a project repository defect as unknown attribution", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const defect = new Error("project repository defect"); + const repositoryDefect = Effect.die(defect); + const projectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => repositoryDefect, + getById: () => repositoryDefect, + listAll: () => repositoryDefect, + deleteById: () => repositoryDefect, + }; + + const exit = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* Effect.exit(service.readSummary(WINDOW)); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-project-defect-test", + home, + settings, + projectRepository, + }), + ), + ); + + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) assert.strictEqual(Cause.squash(exit.cause), defect); + }).pipe(Effect.scoped), + ); + + it.live("returns a usage read error when provider runtime state cannot be read", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const repositoryFailure = Effect.die(new Error("runtime repository unavailable")); + const runtimeRepository: ProviderSessionRuntime.ProviderSessionRuntimeRepository["Service"] = + { + upsert: () => repositoryFailure, + recordImportedTranscript: () => repositoryFailure, + getByThreadId: () => repositoryFailure, + list: () => repositoryFailure, + deleteByThreadId: () => repositoryFailure, + }; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-runtime-failure-test", + home, + settings, + runtimeRepository, + }), + ), + ); + + const error = yield* service.readThreadBreakdown(WINDOW).pipe(Effect.flip); + assert.strictEqual(error.reason, "scanFailed"); + assert.strictEqual(error.detail, "Provider runtime state could not be read"); + }).pipe(Effect.scoped), + ); + it.live("does not share an in-flight scan after custom prices change", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -166,31 +475,14 @@ describe("UsageService", () => { yield* Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; - const fileSystem = yield* FileSystem.FileSystem; const firstScanStarted = yield* Deferred.make(); - const secondScanStarted = yield* Deferred.make(); const releaseRates = yield* Deferred.make(); - let homeProbes = 0; const service = yield* UsageService.make.pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fileSystem, - exists: (path) => - fileSystem.exists(path).pipe( - Effect.tap(() => { - if (path !== NodePath.join(home, "claude", ".claude", "projects")) - return Effect.void; - homeProbes += 1; - return Deferred.succeed( - homeProbes === 1 ? firstScanStarted : secondScanStarted, - undefined, - ); - }), - ), - }), Effect.provideService( HttpClient.HttpClient, HttpClient.make((request) => - Deferred.await(releaseRates).pipe( + Deferred.succeed(firstScanStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRates)), Effect.as(HttpClientResponse.fromWeb(request, Response.json({}))), ), ), @@ -205,7 +497,7 @@ describe("UsageService", () => { }, }); const second = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); - yield* Deferred.await(secondScanStarted); + yield* Effect.yieldNow; yield* Deferred.succeed(releaseRates, undefined); const original = yield* Fiber.join(first); @@ -244,8 +536,121 @@ describe("UsageService", () => { assert.deepStrictEqual(first, second); assert.strictEqual(ratesFetches, 1); - // A later request is fresh work again, not a stale cached answer. + // A later request within the freshness window reuses the source snapshot. yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 1); + }).pipe(Effect.scoped), + ); + + it.live("reuses a recent scan when only the date range changes", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-window-cache-test", + home, + settings, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + yield* service.readSummary(WINDOW); + const narrower = yield* service.readSummary(NARROW_WINDOW); + + assert.strictEqual(totalOutputTokens(narrower), 5); + assert.strictEqual(ratesFetches, 1); + }).pipe(Effect.scoped), + ); + + it.live("folds thread rows from the same source snapshot as the summary", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const summary = yield* service.readSummary(WINDOW); + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + const breakdown = yield* service.readThreadBreakdown(WINDOW); + + assert.strictEqual(totalOutputTokens(summary), 5); + assert.strictEqual( + breakdown.rows.reduce((total, row) => total + row.totals.outputTokens, 0), + 5, + ); + assert.strictEqual(breakdown.readAt, summary.readAt); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-thread-source-cache-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live("refreshes thread rows when the caller changes the refresh token", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + yield* service.readSummary(WINDOW); + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + + const breakdown = yield* service.readThreadBreakdown({ + ...WINDOW, + refreshToken: "thread-refresh-1", + }); + + assert.strictEqual( + breakdown.rows.reduce((total, row) => total + row.totals.outputTokens, 0), + 12, + ); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-thread-refresh-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live("updates fresh source data for a new manual refresh token", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-manual-refresh-test", + home, + settings, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + const refreshedInput = { ...WINDOW, refreshToken: "manual-refresh-1" }; + const refreshed = yield* service.readSummary(refreshedInput); + + assert.strictEqual(totalOutputTokens(refreshed), 12); + assert.strictEqual(ratesFetches, 2); + + yield* service.readSummary(refreshedInput); assert.strictEqual(ratesFetches, 2); }).pipe(Effect.scoped), ); @@ -373,4 +778,183 @@ describe("UsageService", () => { ); }).pipe(Effect.scoped), ); + + it.live("rejects exact thread windows longer than 24 hours", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-thread-window-test", home, settings }), + ), + ); + const reason = yield* service + .readThreadBreakdown({ + timeZone: "UTC", + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-02"), + sinceTime: "2026-08-01T00:00:00.000Z", + untilTime: "2026-08-02T01:00:00.000Z", + }) + .pipe( + Effect.match({ + onFailure: (error) => error.reason, + onSuccess: () => "success" as const, + }), + ); + + assert.strictEqual(reason, "invalidWindow"); + }).pipe(Effect.scoped), + ); +}); + +describe("isValidUsageDay", () => { + it("rejects impossible start and end dates instead of normalising them", () => { + assert.isTrue(UsageService.isValidUsageDay("2026-02-28")); + assert.isFalse(UsageService.isValidUsageDay("2026-02-29")); + assert.isFalse(UsageService.isValidUsageDay("2026-13-01")); + }); +}); + +describe("shortSessionLabel", () => { + it("never exposes a file-derived path", () => { + assert.strictEqual( + UsageService.shortSessionLabel("claude:file:session-dir:updates"), + "Untitled session", + ); + }); +}); + +describe("runtimeUsageSessionKey", () => { + it("maps every provider with usage transcripts to its persisted session cursor", () => { + assert.strictEqual( + UsageService.runtimeUsageSessionKey("claudeAgent", { resume: "claude-session" }), + "claude:claude-session", + ); + assert.strictEqual( + UsageService.runtimeUsageSessionKey("codex", { threadId: "codex-session" }), + "codex:codex-session", + ); + assert.strictEqual( + UsageService.runtimeUsageSessionKey("grok", { schemaVersion: 1, sessionId: "grok-session" }), + "grok:grok-session", + ); + }); + + it("includes provider sessions replaced by later model switches", () => { + assert.deepEqual( + UsageService.runtimeUsageSessionKeys( + "codex", + { threadId: "current-session" }, + { + _t3PreviousResumeCursors: [ + { providerName: "codex", resumeCursor: { threadId: "previous-session" } }, + ], + }, + ), + ["codex:current-session", "codex:previous-session"], + ); + }); + + it("ignores providers and cursors without a usage transcript session", () => { + assert.isNull(UsageService.runtimeUsageSessionKey("opencode", { sessionId: "session" })); + assert.isNull(UsageService.runtimeUsageSessionKey("grok", { sessionId: "" })); + assert.isNull(UsageService.runtimeUsageSessionKey("grok", null)); + }); +}); + +describe("transcriptFileMayMatchThread", () => { + const target: UsageService.ThreadTranscriptTarget = { + sessionIds: new Map([ + ["claude", new Set(["claude-session"])], + ["codex", new Set(["codex-session"])], + ["grok", new Set(["grok-session"])], + ]), + worktrees: new Set(["/work/app/.wt/thread-1"]), + }; + + const matches = ( + provider: "claude" | "codex" | "grok", + filePath: string, + root: string, + options?: { + readonly cached?: { readonly size: number; readonly mtimeMs: number }; + readonly identity?: { readonly sessionId: string; readonly cwd: string }; + }, + ) => + UsageService.transcriptFileMayMatchThread({ + path: NodePath, + provider, + filePath, + root, + target, + ...(options?.cached === undefined + ? {} + : { cached: { ...options.cached, records: [], tailRecords: [] } }), + ...(options?.identity === undefined ? {} : { identity: options.identity }), + }); + + it("selects provider files from current and historic session ids", () => { + assert.isTrue(matches("claude", "/claude/project/claude-session.jsonl", "/claude")); + assert.isTrue( + matches("claude", "/claude/project/claude-session/subagents/agent-a.jsonl", "/claude"), + ); + assert.isTrue( + matches("codex", "/codex/2026/09/rollout-2026-09-05T12-00-00-codex-session.jsonl", "/codex"), + ); + assert.isTrue(matches("grok", "/grok/cwd/grok-session/updates.jsonl", "/grok")); + assert.isFalse(matches("claude", "/claude/project/other-session.jsonl", "/claude")); + }); + + it("selects Claude and Grok files by their encoded dedicated worktree", () => { + assert.isTrue( + matches("claude", "/claude/-work-app--wt-thread-1/legacy-session.jsonl", "/claude"), + ); + assert.isTrue( + matches("grok", "/grok/%2Fwork%2Fapp%2F.wt%2Fthread-1/legacy-session/updates.jsonl", "/grok"), + ); + }); + + it("matches encoded Claude worktrees case-insensitively only for Windows paths", () => { + const windowsTarget: UsageService.ThreadTranscriptTarget = { + sessionIds: new Map(), + worktrees: new Set(["C:/Users/Alex/App/.wt/Thread-1"]), + }; + const matchesTarget = (filePath: string, target: UsageService.ThreadTranscriptTarget) => + UsageService.transcriptFileMayMatchThread({ + path: NodePath, + provider: "claude", + filePath, + root: "/claude", + target, + }); + + assert.isTrue( + matchesTarget("/claude/C--Users-Alex-App--wt-Thread-1/legacy-session.jsonl", windowsTarget), + ); + assert.isTrue( + matchesTarget("/claude/c--users-alex-app--wt-thread-1/legacy-session.jsonl", windowsTarget), + ); + assert.isTrue( + matchesTarget("/claude/C--Users-Alex-App--wt-Thread-1/legacy-session.jsonl", { + ...windowsTarget, + worktrees: new Set(["c:/users/alex/app/.wt/thread-1"]), + }), + ); + assert.isFalse(matchesTarget("/claude/-Work-App--wt-thread-1/legacy-session.jsonl", target)); + }); + + it("selects Codex rollouts from their bounded session metadata", () => { + const path = "/codex/2026/09/rollout-2026-09-05T12-00-00-other-session.jsonl"; + assert.isFalse(matches("codex", path, "/codex")); + assert.isTrue( + matches("codex", path, "/codex", { + identity: { sessionId: "other-session", cwd: "/work/app/.wt/thread-1" }, + }), + ); + assert.isFalse( + matches("codex", path, "/codex", { + identity: { sessionId: "other-session", cwd: "/work/app/.wt/thread-2" }, + }), + ); + }); }); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0e6b0c1eecd6..c4336883f9d4 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -22,6 +22,8 @@ import { type UsagePricing, type UsageSummary, type UsageSummaryInput, + type UsageThreadBreakdown, + type UsageThreadBreakdownInput, UsageReadError, } from "@t3tools/contracts"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; @@ -41,22 +43,36 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; +import { ProjectionProjectRepository } from "../persistence/Services/ProjectionProjects.ts"; +import { ProjectionThreadRepository } from "../persistence/Services/ProjectionThreads.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { + providerResumeCursorSessionId, + readProviderResumeCursorHistory, +} from "../provider/providerResumeCursorHistory.ts"; +import { makeProjectResolver, UsageAggregator } from "./usageAggregation.ts"; +import { dedicatedUsageWorktreePath, normalizeUsagePath } from "./usagePaths.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, + readCodexTranscriptIdentity, readDirectoryVolumeId, readTranscriptRecords, + readTranscriptTitle, } from "./usageTranscriptReader.ts"; +import { foldThreadRows, ThreadUsageAccumulator, type ThreadRef } from "./usageThreads.ts"; import { decodeScanCache, + decodeScanIdentityCache, dedupeWithinFile, encodeScanCache, + pruneScanIdentityCache, pruneScanCache, type ScanCache, + type ScanIdentityCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -76,9 +92,18 @@ const RATES_REFRESH_FLOOR_MS = 60 * 1000; const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; +/** Match the client query TTL so changing a date range does not rescan fresh sources. */ +const SOURCE_SCAN_TTL_MS = 60 * 1000; + /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; +/** + * Maximum rows sent per breakdown request, including grouped remainders. A + * window can hold thousands of sessions, so lower-cost rows fold together. + */ +const THREAD_ROW_CAP = 40; + /** On-disk shape of the rate snapshot. */ const RatesCacheFile = Schema.Struct({ fetchedAtMs: Schema.Number, @@ -95,11 +120,20 @@ const encodeRatesCache = Schema.encodeEffect( const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); +const encodeSourceKey = Schema.encodeSync(ScanCacheJson); + +export function isValidUsageDay(day: string): boolean { + const parsed = DateTime.make(`${day}T00:00:00Z`); + return Option.isSome(parsed) && DateTime.formatIso(parsed.value).slice(0, 10) === day; +} export class UsageService extends Context.Service< UsageService, { readonly readSummary: (input: UsageSummaryInput) => Effect.Effect; + readonly readThreadBreakdown: ( + input: UsageThreadBreakdownInput, + ) => Effect.Effect; /** Refetches the rate table ahead of its TTL. See `ensureRates`. */ readonly refreshRates: Effect.Effect; } @@ -128,6 +162,16 @@ export const layerTest = Layer.succeed( pricing: EMPTY_PRICING, scanDurationMs: 0, }), + readThreadBreakdown: (input) => + Effect.succeed({ + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "1970-01-01T00:00:00.000Z", + sinceDay: input.sinceDay, + untilDay: input.untilDay, + rows: [], + truncatedRows: 0, + scanDurationMs: 0, + }), refreshRates: Effect.succeed(EMPTY_PRICING), }), ); @@ -139,9 +183,15 @@ export const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcessEnvironment; + const projectRepository = yield* ProjectionProjectRepository; + const threadRepository = yield* ProjectionThreadRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const fileCache: ScanCache = new Map(); - let cacheDirty = false; + const fileIdentityCache: ScanIdentityCache = new Map(); + let cacheRevision = 0; + let persistedCacheRevision = 0; + const cachePersistSemaphore = yield* Semaphore.make(1); const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); @@ -271,6 +321,42 @@ export const make = Effect.gen(function* () { ]; }); + /** + * Builds the cwd → project-title resolver for one scan. + * + * Projects are re-read every scan so a project created or renamed since the + * last refresh attributes correctly. A repository failure degrades to "no + * attribution" rather than failing the page. + */ + const resolveProjects = Effect.fn("UsageService.resolveProjects")(function* () { + const projects = yield* projectRepository + .listAll() + .pipe(Effect.catch(() => Effect.succeed(null))); + if (projects === null) return undefined; + const projectRoots = yield* Effect.forEach( + projects, + Effect.fnUntraced(function* (project) { + const threads = yield* threadRepository + .listByProjectId({ projectId: project.projectId }) + .pipe(Effect.catchCause(() => Effect.succeed([]))); + const root = { + projectId: project.projectId, + workspaceRoot: project.workspaceRoot, + title: project.title, + deleted: project.deletedAt !== null, + }; + return [ + root, + ...threads.flatMap((thread) => + thread.worktreePath === null ? [] : [{ ...root, workspaceRoot: thread.worktreePath }], + ), + ]; + }), + { concurrency: 8 }, + ); + return makeProjectResolver(projectRoots.flat()); + }); + /** * Loads the persisted scan cache exactly once per process. * @@ -286,22 +372,25 @@ export const make = Effect.gen(function* () { ); if (document === null) return; for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); + for (const [path, entry] of decodeScanIdentityCache(document)) { + fileIdentityCache.set(path, entry); + } }), ); - const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { - if (!cacheDirty) return; - // Cleared only after the write lands, so a failed persist is retried on - // the next scan instead of leaving disk permanently stale. - yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe( + const persistScanCacheUnlocked = Effect.fn("UsageService.persistScanCacheUnlocked")(function* () { + if (cacheRevision === persistedCacheRevision) return; + const revision = cacheRevision; + yield* encodeScanCacheFile(encodeScanCache(fileCache, fileIdentityCache)).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), Effect.map(() => { - cacheDirty = false; + persistedCacheRevision = revision; }), // A cache we cannot write is a slower next start, not a failed read. Effect.catchCause(() => Effect.void), ); }); + const persistScanCache = () => cachePersistSemaphore.withPermits(1)(persistScanCacheUnlocked()); /** * Parses one transcript, reusing the cached result when it is unchanged. @@ -329,7 +418,7 @@ export const make = Effect.gen(function* () { ) { return cached.tailRecords.length === 0 ? cached.records - : [...cached.records, ...cached.tailRecords]; + : dedupeWithinFile([...cached.records, ...cached.tailRecords]); } // Only a strictly grown file may resume. Same size with a new mtime, or @@ -347,13 +436,11 @@ export const make = Effect.gen(function* () { if (parsed === null) return []; // Stored already de-duplicated within the file, which is 99% of all - // duplicates. The aggregator still runs the cross-file dedupe pass. One - // seen set spans the cached base, the new lines, and the tail so a - // resumed parse dedupes exactly like a full one. + // duplicates. The final snapshot wins so a resumed Claude parse can + // replace an earlier progressive snapshot from the cached base. const base = parsed.resumed && cached !== undefined ? cached.records : []; - const seen = new Set(); - const records = dedupeWithinFile([...base, ...parsed.records], seen); - const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + const records = dedupeWithinFile([...base, ...parsed.records]); + const tailRecords = dedupeWithinFile(parsed.tailRecords); fileCache.set(filePath, { size, @@ -363,21 +450,82 @@ export const make = Effect.gen(function* () { tailRecords, position: parsed.position, }); - cacheDirty = true; - return tailRecords.length === 0 ? records : [...records, ...tailRecords]; + if (provider === "codex") { + const state = parsed.position.codexState; + fileIdentityCache.set(filePath, { + size, + mtimeMs, + provider, + sessionId: state?.sessionId ?? "", + cwd: state?.cwd ?? "", + }); + } + cacheRevision += 1; + return tailRecords.length === 0 ? records : dedupeWithinFile([...records, ...tailRecords]); }); + /** Reads and caches the bounded Codex preamble used for thread prefiltering. */ + const readFileIdentity = Effect.fn("UsageService.readFileIdentity")(function* ( + filePath: string, + size: number, + mtimeMs: number, + provider: UsageProviderKind, + ) { + const cached = fileIdentityCache.get(filePath); + if ( + cached !== undefined && + cached.size === size && + cached.mtimeMs === mtimeMs && + cached.provider === provider + ) { + return cached; + } + if (provider !== "codex") return null; + + // I/O failures are not negative identities. Skip this read without + // caching it so the next request can retry an otherwise valid rollout. + const read = yield* Effect.tryPromise(() => readCodexTranscriptIdentity(filePath)).pipe( + Effect.option, + ); + if (Option.isNone(read)) return null; + + const identity = { + size, + mtimeMs, + provider, + sessionId: read.value?.sessionId ?? "", + cwd: read.value?.cwd ?? "", + } as const; + fileIdentityCache.set(filePath, identity); + cacheRevision += 1; + return identity; + }); + /** One provider directory's walk and parse, before rates are involved. */ interface ScannedDir { readonly provider: UsageProviderKind; readonly dir: string; readonly volumeId: string; + readonly allPaths: ReadonlySet; /** Parsed records per file, or `null` when the directory does not exist. */ readonly files: | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] | null; } + interface SourceSnapshot { + readonly completedAtMs: number; + readonly scanRevision: number; + readonly windowStartMs: number; + readonly sourceKey: string; + readonly dirs: readonly ScannedDir[]; + } + + let sourceSnapshot: SourceSnapshot | null = null; + let sourceScanRevision = 0; + let lastRefreshToken: string | null = null; + const sourceScanSemaphore = yield* Semaphore.make(1); + const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( windowStartMs: number, settings: ServerSettingsValue, @@ -394,22 +542,113 @@ export const make = Effect.gen(function* () { .exists(dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); if (!exists) { - scanned.push({ provider, dir, volumeId, files: null }); + scanned.push({ provider, dir, volumeId, allPaths: new Set(), files: null }); continue; } + const allPaths = new Set(); const files = yield* Effect.promise(() => - listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + listTranscriptFiles(dir, windowStartMs, { + ...(fileName === undefined ? {} : { fileName }), + onFile: (filePath) => allPaths.add(filePath), + }), ); const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; for (const file of files) { const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); parsedFiles.push({ path: file.path, records }); } - scanned.push({ provider, dir, volumeId, files: parsedFiles }); + scanned.push({ provider, dir, volumeId, allPaths, files: parsedFiles }); } return scanned; }); + const getSourceSnapshot = Effect.fn("UsageService.getSourceSnapshot")(function* ( + windowStartMs: number, + refreshToken: string | undefined, + settings: ServerSettingsValue, + ) { + return yield* sourceScanSemaphore.withPermits(1)( + Effect.gen(function* () { + const startedAtMs = yield* Clock.currentTimeMillis; + const currentSnapshot = sourceSnapshot; + const snapshotAgeMs = + currentSnapshot === null + ? Number.POSITIVE_INFINITY + : startedAtMs - currentSnapshot.completedAtMs; + const snapshotCoversWindow = + currentSnapshot !== null && currentSnapshot.windowStartMs <= windowStartMs; + const sourceKey = encodeSourceKey([ + settings.providers.claudeAgent, + settings.providers.codex, + ]); + const snapshotCoversSources = currentSnapshot?.sourceKey === sourceKey; + const manualRefresh = refreshToken !== undefined && refreshToken !== lastRefreshToken; + + if ( + !manualRefresh && + currentSnapshot !== null && + snapshotCoversWindow && + snapshotCoversSources && + snapshotAgeMs < SOURCE_SCAN_TTL_MS + ) { + return currentSnapshot; + } + + // Preserve the widest coverage already loaded. A stale narrow request + // should update changed files, not discard older records and force the + // next wider range to read them again. + const scanWindowStartMs = Math.min( + windowStartMs, + currentSnapshot?.windowStartMs ?? windowStartMs, + ); + + // Pricing only matters once records are aggregated, so the rate table + // loads while transcripts stream instead of gating them: a cold rates + // fetch on a slow network no longer delays the scan by its own timeout. + sourceScanRevision += 1; + const scanRevision = sourceScanRevision; + const [, dirs] = yield* Effect.all( + [ensureRates(false), collectDirs(scanWindowStartMs, settings)], + { concurrency: 2 }, + ); + const now = yield* Clock.currentTimeMillis; + const completedAtMs = Math.max(now, (currentSnapshot?.completedAtMs ?? now - 1) + 1); + const nextSnapshot = { + completedAtMs, + scanRevision, + windowStartMs: scanWindowStartMs, + sourceKey, + dirs, + } satisfies SourceSnapshot; + sourceSnapshot = nextSnapshot; + if (refreshToken !== undefined) lastRefreshToken = refreshToken; + return nextSnapshot; + }), + ); + }); + + const getReusableSourceSnapshot = Effect.fn("UsageService.getReusableSourceSnapshot")(function* ( + windowStartMs: number, + settings: ServerSettingsValue, + ) { + return yield* sourceScanSemaphore.withPermits(1)( + Effect.gen(function* () { + const currentSnapshot = sourceSnapshot; + if (currentSnapshot === null) return null; + const now = yield* Clock.currentTimeMillis; + const sourceKey = encodeSourceKey([ + settings.providers.claudeAgent, + settings.providers.codex, + ]); + return currentSnapshot.windowStartMs <= windowStartMs && + currentSnapshot.sourceKey === sourceKey && + now - currentSnapshot.completedAtMs < SOURCE_SCAN_TTL_MS + ? currentSnapshot + : null; + }), + ); + }); + const scanSummary = Effect.fn("UsageService.scanSummary")(function* ( input: UsageSummaryInput, settings: ServerSettingsValue, @@ -458,15 +697,11 @@ export const make = Effect.gen(function* () { } const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + const currentSnapshot = yield* getSourceSnapshot(windowStartMs, input.refreshToken, settings); + const scannedDirs = currentSnapshot.dirs; + const sourceReadAtMs = currentSnapshot.completedAtMs; - // Pricing only matters once records are aggregated, so the rate table - // loads while transcripts stream instead of gating them: a cold rates - // fetch on a slow network no longer delays the scan by its own timeout. - const [, scannedDirs] = yield* Effect.all( - [ensureRates(false), collectDirs(windowStartMs, settings)], - { concurrency: 2 }, - ); - + const resolveProject = yield* resolveProjects(); const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -474,14 +709,16 @@ export const make = Effect.gen(function* () { resolution: input.resolution ?? "day", ...hourlyWindow, rates, + ...(resolveProject === undefined ? {} : { resolveProject }), priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), }); const sources: UsageSource[] = []; const livePaths = new Set(); + const allPaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir, volumeId, files } of scannedDirs) { + for (const { provider, dir, volumeId, allPaths: dirPaths, files } of scannedDirs) { if (files === null) { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, @@ -496,12 +733,9 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); + for (const filePath of dirPaths) allPaths.add(filePath); let scannedFiles = 0; let skippedFiles = 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); if (file.records.length === 0) { @@ -510,11 +744,7 @@ export const make = Effect.gen(function* () { } scannedFiles += 1; for (const record of file.records) { - // Only sessions that contributed in-window count: the mtime slack - // admits boundary files whose records fall outside the range. - if (aggregator.add(record) && record.sessionId.length > 0) { - sessionIds.add(record.sessionId); - } + aggregator.add(record); } } @@ -524,27 +754,38 @@ export const make = Effect.gen(function* () { scannedFiles, skippedFiles, malformedRecords: 0, - distinctSessions: sessionIds.size, + // Read from the settled records so a progressive snapshot replacement + // cannot leave the source count attached to the superseded session. + distinctSessions: aggregator.distinctSessions(provider), message: null, }); } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheDirty = true; - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheRevision += 1; + const prunedIdentities = pruneScanIdentityCache(fileIdentityCache, { + livePaths: allPaths, + walkedRoots, + }); + if (prunedIdentities > 0) cacheRevision += 1; + yield* persistScanCache(); + } const aggregated = aggregator.finish(); - const readAt = yield* DateTime.now; const finishedAtMs = yield* Clock.currentTimeMillis; return { contractVersion: USAGE_CONTRACT_VERSION, - readAt: DateTime.formatIso(readAt), + readAt: DateTime.formatIso(DateTime.makeUnsafe(sourceReadAtMs)), timeZone: input.timeZone, sinceDay: input.sinceDay, untilDay: input.untilDay, @@ -573,6 +814,7 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, + input.refreshToken === undefined ? null : "refresh", priceOverrides, ]); @@ -606,7 +848,511 @@ export const make = Effect.gen(function* () { return yield* Deferred.await(deferred); }); - return { readSummary, refreshRates } as const; + /** + * Maps each thread's current provider session to the thread, from resume + * cursors. Historic sessions of the same thread attribute through the + * worktree map instead; sessions that never ran through T3 Code stay + * session-granular. + */ + const loadThreadAttribution = Effect.fn("UsageService.loadThreadAttribution")(function* () { + const sessionToThread = new Map(); + const worktreeToThread = new Map(); + const titles = new Map(); + + const projects = yield* projectRepository + .listAll() + .pipe(Effect.catch(() => Effect.succeed([]))); + const worktreeClaims = new Map(); + for (const project of projects) { + const threads = yield* threadRepository + .listByProjectId({ projectId: project.projectId }) + .pipe(Effect.catchCause(() => Effect.succeed([]))); + for (const thread of threads) { + const title = thread.title.trim(); + if (title.length > 0) titles.set(thread.threadId, title); + const worktree = dedicatedUsageWorktreePath(project.workspaceRoot, thread.worktreePath); + // The project root is not a dedicated worktree: interactive sessions + // run there too, and several threads usually share it. + if (worktree === null) continue; + const ref: ThreadRef = { threadId: thread.threadId, title: title || thread.threadId }; + const claim = worktreeClaims.get(worktree); + if (claim === undefined) worktreeClaims.set(worktree, { ref, shared: false }); + else claim.shared = true; + } + } + for (const [worktree, claim] of worktreeClaims) { + if (!claim.shared) worktreeToThread.set(worktree, claim.ref); + } + + const runtimes = yield* runtimeRepository.list().pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Provider runtime state could not be read", + cause: Cause.squash(cause), + }), + ), + ); + for (const runtime of runtimes) { + for (const sessionKey of runtimeUsageSessionKeys( + runtime.providerName, + runtime.resumeCursor, + runtime.runtimePayload, + )) { + sessionToThread.set(sessionKey, { + threadId: runtime.threadId, + title: titles.get(runtime.threadId) ?? runtime.threadId, + }); + } + } + + return { sessionToThread, worktreeToThread }; + }); + + const readThreadBreakdown = Effect.fn("UsageService.readThreadBreakdown")(function* ( + input: UsageThreadBreakdownInput, + ) { + if (input.sinceDay > input.untilDay) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`, + }); + } + const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); + const windowEnd = DateTime.make(`${input.untilDay}T00:00:00Z`); + if ( + Option.isNone(windowStart) || + Option.isNone(windowEnd) || + !isValidUsageDay(input.sinceDay) || + !isValidUsageDay(input.untilDay) + ) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Thread usage requires valid sinceDay and untilDay dates", + }); + } + + let exactWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null; + if (input.sinceTime !== undefined || input.untilTime !== undefined) { + const sinceTime = + input.sinceTime === undefined ? Option.none() : DateTime.make(input.sinceTime); + const untilTime = + input.untilTime === undefined ? Option.none() : DateTime.make(input.untilTime); + if (Option.isNone(sinceTime) || Option.isNone(untilTime)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Thread usage requires both valid sinceTime and untilTime instants", + }); + } + const sinceTimeMs = DateTime.toEpochMillis(sinceTime.value); + const untilTimeMs = DateTime.toEpochMillis(untilTime.value); + const durationMs = untilTimeMs - sinceTimeMs; + if (durationMs <= 0 || durationMs > MAX_HOURLY_WINDOW_MS) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Thread usage exact window must be greater than zero and at most 24 hours", + }); + } + exactWindow = { sinceTimeMs, untilTimeMs }; + } + + const startedAtMs = yield* Clock.currentTimeMillis; + const settings = yield* readSettings; + yield* ensureRates(false); + yield* ensureScanCacheLoaded; + const attribution = yield* loadThreadAttribution(); + const target = + input.threadId === undefined ? null : threadTranscriptTarget(attribution, input.threadId); + + const windowStartMs = + (exactWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + // Reuse a summary's fresh parsed snapshot when one exists, so a grown + // transcript cannot make its drill-down disagree during the source TTL. + // A thread-only read keeps the targeted identity scan below instead of + // cold-parsing the entire provider corpus. + // A manual single-thread refresh must retain the targeted body prefilter. + const threadScanRevision = sourceScanRevision; + const currentSnapshot = yield* input.refreshToken === undefined + ? getReusableSourceSnapshot(windowStartMs, settings) + : input.threadId === undefined + ? getSourceSnapshot(windowStartMs, input.refreshToken, settings) + : Effect.succeed(null); + + const resolveProject = yield* resolveProjects(); + const accumulator = new ThreadUsageAccumulator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + ...exactWindow, + rates, + ...(resolveProject === undefined ? {} : { resolveProject }), + priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), + }); + + // Preferred transcript per session for title extraction: the main file, + // never a subagent's. + const titleFiles = new Map< + string, + { readonly path: string; readonly provider: UsageProviderKind } + >(); + const livePaths = new Set(); + const allPaths = new Set(); + const walkedRoots: string[] = []; + + const addRecords = ( + provider: UsageProviderKind, + filePath: string, + records: readonly UsageRecord[], + ) => { + if (records.length === 0) return; + const isSubagent = + provider === "claude" && path.basename(path.dirname(filePath)) === "subagents"; + const agentId = isSubagent ? path.basename(filePath, ".jsonl") : null; + for (const record of records) { + const sessionKey = + record.sessionId.length > 0 + ? `${provider}:${record.sessionId}` + : `${provider}:file:${path.basename(path.dirname(filePath))}:${path.basename(filePath, ".jsonl")}`; + accumulator.add(record, { sessionKey, agentId }); + if (!isSubagent && !titleFiles.has(sessionKey)) { + titleFiles.set(sessionKey, { path: filePath, provider }); + } + } + }; + + if (currentSnapshot !== null) { + for (const { provider, dir, allPaths: dirPaths, files } of currentSnapshot.dirs) { + if (input.providers !== undefined && !input.providers.includes(provider)) continue; + if (files === null) continue; + walkedRoots.push(dir); + for (const filePath of dirPaths) allPaths.add(filePath); + for (const file of files) { + if ( + target !== null && + !transcriptFileMayMatchThread({ + path, + filePath: file.path, + root: dir, + provider, + target, + cached: { + records: file.records, + tailRecords: [], + }, + }) + ) { + continue; + } + livePaths.add(file.path); + addRecords(provider, file.path, file.records); + } + } + } else { + const dirs = yield* resolveTranscriptDirs(settings).pipe( + Effect.provideService(Path.Path, path), + ); + for (const { provider, dir, fileName } of dirs) { + if (input.providers !== undefined && !input.providers.includes(provider)) continue; + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (!exists) continue; + walkedRoots.push(dir); + + const files = yield* Effect.promise(() => + listTranscriptFiles(dir, windowStartMs, { + ...(fileName === undefined ? {} : { fileName }), + onFile: (filePath) => allPaths.add(filePath), + }), + ); + for (const file of files) { + const cached = fileCache.get(file.path); + const identity = + target !== null && provider === "codex" + ? yield* readFileIdentity(file.path, file.size, file.mtimeMs, provider) + : null; + if ( + target !== null && + !transcriptFileMayMatchThread({ + path, + filePath: file.path, + root: dir, + provider, + target, + ...(cached === undefined ? {} : { cached }), + ...(identity === null ? {} : { identity }), + }) + ) { + continue; + } + livePaths.add(file.path); + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + addRecords(provider, file.path, records); + } + } + } + + // A newer source walk may have populated files after a reused snapshot + // left the scan lane. Only the latest snapshot can prove that an unseen + // path disappeared. A targeted direct walk still persists its selected + // lifetime records when no reusable snapshot exists. + if ( + currentSnapshot === null + ? threadScanRevision === sourceScanRevision + : currentSnapshot.scanRevision === sourceScanRevision + ) { + // A filtered walk sees only one thread's candidates, so it cannot prove + // that other cached files disappeared. Keeping the selected lifetime + // records also prevents an old thread from being cold-parsed every turn. + if (target === null) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheRevision += 1; + } + const prunedIdentities = pruneScanIdentityCache(fileIdentityCache, { + livePaths: allPaths, + walkedRoots, + }); + if (prunedIdentities > 0) cacheRevision += 1; + // Persist selected lifetime records so a restart does not cold-parse the + // same old thread again. Unfiltered reads retain the normal bounded cache. + yield* persistScanCache(); + } + + const folded = foldThreadRows(accumulator.finish(), attribution, { + cap: THREAD_ROW_CAP, + ...(input.projectKey === undefined ? {} : { projectFilter: input.projectKey }), + ...(input.threadId === undefined ? {} : { threadFilter: input.threadId }), + }); + + // Transcript titles only for retained unattributed rows. Grouped remainder + // rows already carry a generated title. + const rows = yield* Effect.forEach( + folded.rows, + Effect.fnUntraced(function* ({ titleSessionKey, ...row }) { + if (row.title !== null) return { ...row, title: row.title }; + const source = titleFiles.get(titleSessionKey); + const transcriptTitle = + source === undefined + ? null + : yield* Effect.promise(() => readTranscriptTitle(source.path, source.provider)); + const fallback = row.key.startsWith("remainder:") + ? row.key + : shortSessionLabel(titleSessionKey); + return { ...row, title: transcriptTitle ?? fallback }; + }), + { concurrency: 8 }, + ); + + const readAt = + currentSnapshot === null + ? yield* DateTime.now + : DateTime.makeUnsafe(currentSnapshot.completedAtMs); + const finishedAtMs = yield* Clock.currentTimeMillis; + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: DateTime.formatIso(readAt), + sinceDay: input.sinceDay, + untilDay: input.untilDay, + rows, + truncatedRows: folded.truncatedRows, + scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + } satisfies UsageThreadBreakdown; + }); + + return { readSummary, readThreadBreakdown, refreshRates } as const; }); +/** `claude:8f14e45f-...` reads as `Session 8f14e45f`. */ +export function shortSessionLabel(sessionKey: string): string { + if (sessionKey.includes(":file:")) return "Untitled session"; + const sessionId = sessionKey.slice(sessionKey.lastIndexOf(":") + 1); + return sessionId.length > 8 ? `Session ${sessionId.slice(0, 8)}` : `Session ${sessionId}`; +} + +/** Maps a persisted provider cursor to the transcript session key it owns. */ +export function runtimeUsageSessionKey(providerName: string, cursor: unknown): string | null { + let provider: UsageProviderKind; + switch (providerName) { + case "claudeAgent": + provider = "claude"; + break; + case "codex": + provider = "codex"; + break; + case "grok": + provider = "grok"; + break; + default: + return null; + } + const sessionId = providerResumeCursorSessionId(providerName, cursor); + return sessionId === null ? null : `${provider}:${sessionId}`; +} + +/** Maps current and replaced provider cursors to every transcript session owned by a thread. */ +export function runtimeUsageSessionKeys( + providerName: string, + cursor: unknown, + runtimePayload: unknown | null, +): readonly string[] { + const keys = [ + runtimeUsageSessionKey(providerName, cursor), + ...readProviderResumeCursorHistory(runtimePayload).map((entry) => + runtimeUsageSessionKey(entry.providerName, entry.resumeCursor), + ), + ]; + return [...new Set(keys.filter((key): key is string => key !== null))]; +} + +export interface ThreadTranscriptTarget { + readonly sessionIds: ReadonlyMap>; + readonly worktrees: ReadonlySet; +} + +function threadTranscriptTarget( + attribution: { + readonly sessionToThread: ReadonlyMap; + readonly worktreeToThread: ReadonlyMap; + }, + threadId: string, +): ThreadTranscriptTarget { + const mutableSessionIds = new Map>(); + for (const [sessionKey, ref] of attribution.sessionToThread) { + if (ref.threadId !== threadId) continue; + const separator = sessionKey.indexOf(":"); + const provider = sessionKey.slice(0, separator); + const sessionId = sessionKey.slice(separator + 1); + if ( + separator <= 0 || + sessionId.length === 0 || + (provider !== "claude" && provider !== "codex" && provider !== "grok") + ) { + continue; + } + const ids = mutableSessionIds.get(provider) ?? new Set(); + ids.add(sessionId); + mutableSessionIds.set(provider, ids); + } + const worktrees = new Set(); + for (const [worktree, ref] of attribution.worktreeToThread) { + if (ref.threadId === threadId) worktrees.add(normalizeUsagePath(worktree)); + } + return { sessionIds: mutableSessionIds, worktrees }; +} + +function cwdMatchesTarget(cwd: string, worktrees: ReadonlySet): boolean { + if (cwd.length === 0) return false; + const normalizedCwd = normalizeUsagePath(cwd); + for (const worktree of worktrees) { + const prefix = worktree.endsWith("/") ? worktree : `${worktree}/`; + if (normalizedCwd === worktree || normalizedCwd.startsWith(prefix)) return true; + } + return false; +} + +function pathMatchesSession( + path: Pick, + filePath: string, + provider: UsageProviderKind, + sessionIds: ReadonlySet, +): boolean { + if (sessionIds.size === 0) return false; + if (provider === "grok") return sessionIds.has(path.basename(path.dirname(filePath))); + if (provider === "claude") { + const parent = path.dirname(filePath); + const sessionId = + path.basename(parent) === "subagents" + ? path.basename(path.dirname(parent)) + : path.basename(filePath, ".jsonl"); + return sessionIds.has(sessionId); + } + const name = path.basename(filePath, ".jsonl"); + for (const sessionId of sessionIds) { + if (name === sessionId || name.endsWith(`-${sessionId}`)) return true; + } + return false; +} + +function pathMatchesWorktree( + path: Pick, + filePath: string, + root: string, + provider: UsageProviderKind, + worktrees: ReadonlySet, +): boolean { + if (worktrees.size === 0 || provider === "codex") return false; + const firstSegment = path.relative(root, filePath).replaceAll("\\", "/").split("/")[0]; + if (firstSegment === undefined) return false; + if (provider === "claude") { + for (const worktree of worktrees) { + const encodedWorktree = worktree.replaceAll(/[^A-Za-z0-9]/g, "-"); + const isWindowsWorktree = /^[A-Za-z]:\//.test(worktree); + if ( + isWindowsWorktree + ? firstSegment.toLowerCase() === encodedWorktree.toLowerCase() + : firstSegment === encodedWorktree + ) { + return true; + } + } + return false; + } + try { + return cwdMatchesTarget(decodeURIComponent(firstSegment), worktrees); + } catch { + return false; + } +} + +export function transcriptFileMayMatchThread(input: { + readonly path: Pick; + readonly filePath: string; + readonly root: string; + readonly provider: UsageProviderKind; + readonly target: ThreadTranscriptTarget; + readonly cached?: { + readonly records: readonly UsageRecord[]; + readonly tailRecords: readonly UsageRecord[]; + }; + readonly identity?: { + readonly sessionId: string; + readonly cwd: string; + }; +}): boolean { + const sessionIds = input.target.sessionIds.get(input.provider) ?? new Set(); + if (pathMatchesSession(input.path, input.filePath, input.provider, sessionIds)) return true; + if ( + pathMatchesWorktree( + input.path, + input.filePath, + input.root, + input.provider, + input.target.worktrees, + ) + ) { + return true; + } + const cachedRecords = + input.cached === undefined ? [] : [...input.cached.records, ...input.cached.tailRecords]; + if ( + cachedRecords.some( + (record) => + sessionIds.has(record.sessionId) || cwdMatchesTarget(record.cwd, input.target.worktrees), + ) + ) { + return true; + } + return ( + input.identity !== undefined && + (sessionIds.has(input.identity.sessionId) || + cwdMatchesTarget(input.identity.cwd, input.target.worktrees)) + ); +} + export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 8da4e920ac06..52f4b9b1e810 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,6 +1,7 @@ +import { ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { makeProjectResolver, UsageAggregator } from "./usageAggregation.ts"; import type { RateTable } from "./usagePricing.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -12,6 +13,7 @@ const rates: RateTable = new Map([ outputCostPerToken: 5e-5, cacheReadCostPerToken: 1e-6, cacheCreationCostPerToken: 1.25e-5, + cacheCreation1hCostPerToken: 2e-5, }, ], ]); @@ -23,6 +25,7 @@ function record(overrides: Partial = {}): UsageRecord { timestampMs: Date.parse("2026-08-07T04:05:13.944Z"), model: "claude-fable-5", sessionId: "session-a", + cwd: "", totals: { uncachedInputTokens: 100, cachedInputTokens: 1000, @@ -87,6 +90,22 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.totals.outputTokens).toBe(50); }); + it("uses the final complete snapshot for a repeated dedupe key", () => { + const result = aggregate([ + record({ + dedupeKey: "msg_partial:", + totals: { ...record().totals, outputTokens: 1 }, + }), + record({ + dedupeKey: "msg_partial:", + totals: { ...record().totals, outputTokens: 310 }, + }), + ]); + + expect(result.buckets[0]?.records).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(310); + }); + it("still sums records that carry no dedupe key", () => { const result = aggregate([record(), record()]); @@ -94,6 +113,38 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.totals.outputTokens).toBe(100); }); + it("distinguishes project, outside, and unknown attribution", () => { + const projectId = ProjectId.make("project-app"); + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd === "/work/app" ? { projectId, title: "App" } : null), + }); + aggregator.add(record({ cwd: "/work/app" })); + aggregator.add(record({ cwd: "/work/app" })); + aggregator.add(record({ cwd: "/elsewhere" })); + aggregator.add(record({ cwd: "", model: "grok-4" })); + const { buckets } = aggregator.finish(); + + expect(buckets).toHaveLength(3); + const outside = buckets.find((bucket) => bucket.projectAttribution === "outside"); + expect(outside?.project).toBeUndefined(); + expect(outside?.records).toBe(1); + const project = buckets.find((bucket) => bucket.projectAttribution === "project"); + expect(project?.project).toBe("App"); + expect(project?.projectId).toBe(projectId); + expect(project?.records).toBe(2); + expect(buckets.some((bucket) => bucket.projectAttribution === "unknown")).toBe(true); + }); + + it("marks every bucket unknown when no project resolver is available", () => { + const result = aggregate([record({ cwd: "/work/app" })]); + + expect(result.buckets[0]?.projectAttribution).toBe("unknown"); + }); + it("buckets by the day in the requested time zone", () => { const utc = aggregate([record()], "UTC"); const losAngeles = aggregate([record()], "America/Los_Angeles"); @@ -154,6 +205,41 @@ describe("UsageAggregator", () => { // 100*1e-5 + 1000*1e-6 + 10*1.25e-5 + 50*5e-5 expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9); expect(result.buckets[0]?.costSource).toBe("modelPriced"); + // Cache writes priced at the cache-write rate: 10 * 1.25e-5. + expect(result.buckets[0]?.cacheWriteUsd).toBeCloseTo(1.25e-4, 12); + }); + + it("prices one-hour cache writes at their separate rate", () => { + const result = aggregate([ + record({ + totals: { + ...record().totals, + cacheCreationTokens: 30, + cacheCreation5mTokens: 10, + cacheCreation1hTokens: 20, + }, + }), + ]); + + expect(result.buckets[0]?.cacheWriteUsd).toBeCloseTo(10 * 1.25e-5 + 20 * 2e-5, 12); + }); + + it("distinguishes unavailable cache-write cost from write-free usage", () => { + const unpriced = aggregate([record({ model: "kimi-k3" })]); + expect(unpriced.buckets[0]?.cacheWriteUsd).toBeUndefined(); + + const writeFree = aggregate([ + record({ + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 0, + outputTokens: 50, + reasoningTokens: 0, + }, + }), + ]); + expect(writeFree.buckets[0]?.cacheWriteUsd).toBe(0); }); it("counts tokens but not cost for a model with no rate", () => { @@ -170,6 +256,7 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.costUsd).toBe(1.25); expect(result.buckets[0]?.costSource).toBe("providerReported"); + expect(result.buckets[0]?.cacheWriteUsd).toBeUndefined(); }); it("drops records outside the window", () => { @@ -179,7 +266,7 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(0); }); - it("reports whether a record contributed", () => { + it("reports whether a record falls in the window", () => { const aggregator = new UsageAggregator({ timeZone: "UTC", sinceDay: "2026-08-01", @@ -188,10 +275,41 @@ describe("UsageAggregator", () => { }); expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); - expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false); + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); }); + it("counts sessions from the final progressive snapshot", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + + aggregator.add(record({ dedupeKey: "msg_1:", sessionId: "partial-session" })); + aggregator.add(record({ dedupeKey: "msg_1:", sessionId: "final-session" })); + + expect(aggregator.distinctSessions("claude")).toBe(1); + expect(aggregator.finish().buckets[0]?.sessions).toBe(1); + }); + + it("applies the window to the final progressive snapshot", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + + aggregator.add(record({ dedupeKey: "msg_1:" })); + aggregator.add( + record({ dedupeKey: "msg_1:", timestampMs: Date.parse("2026-09-01T00:00:00Z") }), + ); + + expect(aggregator.finish()).toMatchObject({ buckets: [], outOfWindow: 1 }); + }); + it("separates providers and models into their own buckets", () => { const result = aggregate([ record(), @@ -202,3 +320,82 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(3); }); }); + +describe("makeProjectResolver", () => { + const appId = ProjectId.make("project-app"); + const vendoredId = ProjectId.make("project-vendored"); + const legacyDeletedId = ProjectId.make("project-legacy-deleted"); + const legacyId = ProjectId.make("project-legacy"); + const untitledId = ProjectId.make("project-untitled"); + const resolver = makeProjectResolver([ + { projectId: appId, workspaceRoot: "/work/app", title: "App", deleted: false }, + { + projectId: vendoredId, + workspaceRoot: "/work/app/vendored", + title: "Vendored", + deleted: false, + }, + { + projectId: legacyDeletedId, + workspaceRoot: "/work/legacy", + title: "Legacy Was Deleted", + deleted: true, + }, + { + projectId: legacyId, + workspaceRoot: "/work/legacy", + title: "Legacy", + deleted: false, + }, + { + projectId: untitledId, + workspaceRoot: "/work/untitled", + title: " ", + deleted: false, + }, + ]); + + it("matches the root itself and any path under it", () => { + expect(resolver("/work/app")).toEqual({ projectId: appId, title: "App" }); + expect(resolver("/work/app/src/deep")).toEqual({ projectId: appId, title: "App" }); + }); + + it("requires a path-segment boundary, not a bare prefix", () => { + expect(resolver("/work/app-sibling")).toBeNull(); + }); + + it("prefers the deepest matching root", () => { + expect(resolver("/work/app/vendored/lib")).toEqual({ + projectId: vendoredId, + title: "Vendored", + }); + }); + + it("prefers a live project over a deleted one sharing the root", () => { + expect(resolver("/work/legacy/src")).toEqual({ projectId: legacyId, title: "Legacy" }); + }); + + it("never attributes to a blank title or an empty cwd", () => { + expect(resolver("/work/untitled/src")).toBeNull(); + expect(resolver("")).toBeNull(); + }); + + it("matches descendants when the project root is the filesystem root", () => { + const rootId = ProjectId.make("project-root"); + const rootResolver = makeProjectResolver([ + { projectId: rootId, workspaceRoot: "/", title: "Root", deleted: false }, + ]); + + expect(rootResolver("/work/app")).toEqual({ projectId: rootId, title: "Root" }); + }); + + it("matches mixed slash styles and normalized segments", () => { + expect(resolver("\\work\\app\\src")).toEqual({ projectId: appId, title: "App" }); + expect(resolver("/work/app/other/../src")).toEqual({ projectId: appId, title: "App" }); + + const windowsResolver = makeProjectResolver([ + { projectId: appId, workspaceRoot: "C:\\Work\\App", title: "App", deleted: false }, + ]); + expect(windowsResolver("c:/work/app/src")).toEqual({ projectId: appId, title: "App" }); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 01a1195efb60..c926b49b896b 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -1,7 +1,7 @@ // @effect-diagnostics globalDate:off /** - * Folds parsed transcript records into `(day, hourStart?, provider, model)` - * buckets. + * Folds parsed transcript records into `(day, hourStart?, project, provider, + * model)` buckets. * * `Intl.DateTimeFormat` is the only reliable way to resolve a wall-clock day in * an arbitrary IANA zone, and it takes a `Date`. That is why the raw `Date` @@ -12,10 +12,17 @@ * * @module usageAggregation */ -import type { UsageBucket, UsageDay, UsageResolution, UsageTokenTotals } from "@t3tools/contracts"; +import type { + ProjectId, + UsageBucket, + UsageDay, + UsageResolution, + UsageTokenTotals, +} from "@t3tools/contracts"; +import { normalizeUsagePath } from "./usagePaths.ts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; -import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; +import { cacheSavingsUsd, cacheWriteUsd, priceUsage, type RateTable } from "./usagePricing.ts"; /** * Formats an instant as a `YYYY-MM-DD` day in `timeZone`. @@ -46,10 +53,66 @@ export function makeDayFormatter(timeZone: string): (timestampMs: number) => str const HOUR_MS = 60 * 60 * 1000; +export interface ProjectRoot { + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly title: string; + /** Soft-deleted projects still attribute: the spend happened while they existed. */ + readonly deleted: boolean; +} + +export interface ProjectAttribution { + readonly projectId: ProjectId; + readonly title: string; +} + +/** + * Builds the cwd → project resolver used by {@link AggregateOptions}. + * + * Deepest root wins, so a session in a project nested inside another + * attributes to the inner one. Live projects outrank deleted ones sharing a + * root, since deleting and re-creating a project leaves both rows. Results are + * memoised per cwd; a scan sees few distinct cwds but many records. + */ +export function makeProjectResolver( + projects: readonly ProjectRoot[], +): (cwd: string) => ProjectAttribution | null { + const roots = projects + .map((project) => ({ + projectId: project.projectId, + root: project.workspaceRoot.length === 0 ? "" : normalizeUsagePath(project.workspaceRoot), + title: project.title.trim(), + deleted: project.deleted, + })) + .filter((entry) => entry.root.length > 0 && entry.title.length > 0) + .sort((a, b) => b.root.length - a.root.length || Number(a.deleted) - Number(b.deleted)); + + const byCwd = new Map(); + return (cwd) => { + if (cwd.length === 0) return null; + const normalizedCwd = normalizeUsagePath(cwd); + if (byCwd.has(normalizedCwd)) return byCwd.get(normalizedCwd) ?? null; + let resolved: ProjectAttribution | null = null; + for (const { projectId, root, title } of roots) { + if ( + normalizedCwd === root || + (root === "/" ? normalizedCwd.startsWith("/") : normalizedCwd.startsWith(`${root}/`)) + ) { + resolved = { projectId, title }; + break; + } + } + byCwd.set(normalizedCwd, resolved); + return resolved; + }; +} + interface MutableBucket { totals: UsageTokenTotals; costUsd: number; cacheSavingsUsd: number; + cacheWriteUsd: number; + cacheWriteComplete: boolean; records: number; unpricedRecords: number; providerReportedRecords: number; @@ -65,13 +128,18 @@ export interface AggregateOptions { readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; + /** + * Maps a record's working directory to the project it ran in, or `null` when + * it ran outside every project. Omitting it leaves every bucket unattributed. + */ + readonly resolveProject?: (cwd: string) => ProjectAttribution | null; } export interface AggregateResult { readonly buckets: readonly UsageBucket[]; /** Records dropped because an earlier record carried the same dedupe key. */ readonly duplicatesDropped: number; - /** Records whose day fell outside the requested window. */ + /** Retained records whose day fell outside the requested window. */ readonly outOfWindow: number; } @@ -83,13 +151,12 @@ export interface AggregateResult { * the same `dedupeKey` legitimately appears in several transcripts. */ export class UsageAggregator { - readonly #buckets = new Map(); - readonly #seen = new Set(); + readonly #recordsByKey = new Map(); + readonly #unkeyedRecords: UsageRecord[] = []; readonly #toDay: (timestampMs: number) => string; readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; #duplicatesDropped = 0; - #outOfWindow = 0; constructor(options: AggregateOptions) { this.#options = options; @@ -107,26 +174,30 @@ export class UsageAggregator { } } - /** - * Folds one record in. Returns whether it actually contributed, so callers - * can derive per-window facts (distinct sessions, for one) from the records - * that landed rather than everything the mtime prefilter happened to admit. - */ + /** Retains one record and reports whether it falls in the requested window. */ add(record: UsageRecord): boolean { - if (record.dedupeKey !== null) { - if (this.#seen.has(record.dedupeKey)) { - this.#duplicatesDropped += 1; - return false; - } - this.#seen.add(record.dedupeKey); + const inWindow = this.#isInWindow(record); + if (record.dedupeKey === null) { + this.#unkeyedRecords.push(record); + return inWindow; + } + if (this.#recordsByKey.has(record.dedupeKey)) { + // Claude writes progressive snapshots for one response. The final copy + // is complete, so replace the earlier one without counting it twice. + this.#recordsByKey.set(record.dedupeKey, record); + this.#duplicatesDropped += 1; + return inWindow; } + this.#recordsByKey.set(record.dedupeKey, record); + return inWindow; + } + #isInWindow(record: UsageRecord): boolean { if ( this.#hourlyWindow !== null && (record.timestampMs < this.#hourlyWindow.sinceTimeMs || record.timestampMs >= this.#hourlyWindow.untilTimeMs) ) { - this.#outOfWindow += 1; return false; } @@ -135,9 +206,26 @@ export class UsageAggregator { this.#hourlyWindow === null && (day < this.#options.sinceDay || day > this.#options.untilDay) ) { - this.#outOfWindow += 1; return false; } + return true; + } + + /** Distinct in-window sessions retained after progressive snapshots settle. */ + distinctSessions(provider: UsageRecord["provider"]): number { + const sessionIds = new Set(); + const addSession = (record: UsageRecord): void => { + if (this.#isInWindow(record) && record.provider === provider && record.sessionId.length > 0) { + sessionIds.add(record.sessionId); + } + }; + for (const record of this.#unkeyedRecords) addSession(record); + for (const record of this.#recordsByKey.values()) addSession(record); + return sessionIds.size; + } + + #foldRecord(record: UsageRecord, buckets: Map): void { + const day = this.#toDay(record.timestampMs); const hourStart = this.#hourlyWindow === null @@ -146,19 +234,31 @@ export class UsageAggregator { this.#hourlyWindow.sinceTimeMs + Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, ).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}`; - let bucket = this.#buckets.get(key); + // The key is parsed back apart on NUL, which project fields must not carry. + const resolvedProject = this.#options.resolveProject?.(record.cwd) ?? null; + const projectAttribution = + resolvedProject !== null + ? "project" + : this.#options.resolveProject === undefined || record.cwd.length === 0 + ? "unknown" + : "outside"; + const projectId = resolvedProject?.projectId.replaceAll("\u0000", "") ?? ""; + const project = resolvedProject?.title.replaceAll("\u0000", "") ?? ""; + const key = `${day}\u0000${hourStart}\u0000${projectAttribution}\u0000${projectId}\u0000${project}\u0000${record.provider}\u0000${record.model}`; + let bucket = buckets.get(key); if (bucket === undefined) { bucket = { totals: EMPTY_TOTALS, costUsd: 0, cacheSavingsUsd: 0, + cacheWriteUsd: 0, + cacheWriteComplete: true, records: 0, unpricedRecords: 0, providerReportedRecords: 0, sessions: new Set(), }; - this.#buckets.set(key, bucket); + buckets.set(key, bucket); } const priced = priceUsage( @@ -177,25 +277,57 @@ export class UsageAggregator { record.totals, this.#options.priceOverrides, ); + if (priced.costSource === "modelPriced") { + bucket.cacheWriteUsd += cacheWriteUsd( + this.#options.rates, + record.model, + record.totals, + this.#options.priceOverrides, + ); + } else if (record.totals.cacheCreationTokens > 0) { + bucket.cacheWriteComplete = false; + } bucket.records += 1; if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); - return true; } finish(): AggregateResult { + const bucketsByKey = new Map(); + let outOfWindow = 0; + const foldIfInWindow = (record: UsageRecord): void => { + if (this.#isInWindow(record)) { + this.#foldRecord(record, bucketsByKey); + } else { + outOfWindow += 1; + } + }; + for (const record of this.#unkeyedRecords) foldIfInWindow(record); + for (const record of this.#recordsByKey.values()) foldIfInWindow(record); const buckets: UsageBucket[] = []; - for (const [key, bucket] of this.#buckets) { - const [day = "", hourStart = "", provider = "", model = ""] = key.split("\u0000"); + for (const [key, bucket] of bucketsByKey) { + const [ + day = "", + hourStart = "", + projectAttribution = "unknown", + projectId = "", + project = "", + provider = "", + model = "", + ] = key.split("\u0000"); buckets.push({ day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), + ...(project === "" ? {} : { project }), + ...(projectId === "" ? {} : { projectId: projectId as ProjectId }), + projectAttribution: projectAttribution as UsageBucket["projectAttribution"], provider: provider as UsageBucket["provider"], model, totals: bucket.totals, costUsd: bucket.costUsd, cacheSavingsUsd: bucket.cacheSavingsUsd, + ...(bucket.cacheWriteComplete ? { cacheWriteUsd: bucket.cacheWriteUsd } : {}), costSource: resolveCostSource(bucket), records: bucket.records, unpricedRecords: bucket.unpricedRecords, @@ -207,6 +339,8 @@ export class UsageAggregator { (a, b) => a.day.localeCompare(b.day) || (a.hourStart ?? "").localeCompare(b.hourStart ?? "") || + (a.project ?? "").localeCompare(b.project ?? "") || + (a.projectId ?? "").localeCompare(b.projectId ?? "") || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model), ); @@ -214,7 +348,7 @@ export class UsageAggregator { return { buckets, duplicatesDropped: this.#duplicatesDropped, - outOfWindow: this.#outOfWindow, + outOfWindow, }; } } diff --git a/apps/server/src/usage/usagePaths.test.ts b/apps/server/src/usage/usagePaths.test.ts new file mode 100644 index 000000000000..cb8d5c6c468a --- /dev/null +++ b/apps/server/src/usage/usagePaths.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { dedicatedUsageWorktreePath, normalizeUsagePath } from "./usagePaths.ts"; + +describe("usage path normalization", () => { + it("folds slash styles, trailing separators, and dot segments", () => { + expect(normalizeUsagePath("C:\\Work\\App\\other\\..\\src\\")).toBe("c:/work/app/src"); + }); + + it("does not treat another spelling of the project root as a dedicated worktree", () => { + expect(dedicatedUsageWorktreePath("C:\\Work\\App", "c:/work/app/")).toBeNull(); + }); + + it("returns one stable key for equivalent dedicated worktree paths", () => { + expect(dedicatedUsageWorktreePath("C:/work/app", "C:\\WORK\\APP\\.wt\\thread-1\\")).toBe( + "c:/work/app/.wt/thread-1", + ); + expect(dedicatedUsageWorktreePath("C:/work/app", "C:/work/app/other/../.wt/thread-1")).toBe( + "c:/work/app/.wt/thread-1", + ); + }); + + it("preserves case-sensitive POSIX comparisons", () => { + expect(normalizeUsagePath("/Work/App")).toBe("/Work/App"); + expect(normalizeUsagePath("/work/app")).toBe("/work/app"); + }); +}); diff --git a/apps/server/src/usage/usagePaths.ts b/apps/server/src/usage/usagePaths.ts new file mode 100644 index 000000000000..abf13b6c4cc7 --- /dev/null +++ b/apps/server/src/usage/usagePaths.ts @@ -0,0 +1,35 @@ +/** + * Normalizes persisted provider and worktree paths for usage attribution. + * + * Provider transcripts can retain paths written on another platform or with a + * different slash style, so attribution cannot rely on the host separator. + */ +export function normalizeUsagePath(value: string): string { + const isWindowsPath = /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); + const slashPath = value.replaceAll("\\", "/"); + const rooted = slashPath.startsWith("/"); + const segments: string[] = []; + for (const segment of slashPath.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (segments.length > 0 && segments.at(-1) !== "..") segments.pop(); + else if (!rooted) segments.push(segment); + continue; + } + segments.push(segment); + } + const normalized = `${rooted ? "/" : ""}${segments.join("/")}`; + const result = normalized === "" ? (rooted ? "/" : ".") : normalized; + return isWindowsPath ? result.toLowerCase() : result; +} + +/** Returns a normalized dedicated worktree, excluding the shared project root. */ +export function dedicatedUsageWorktreePath( + projectRoot: string, + worktree: string | null, +): string | null { + const candidate = worktree?.trim() ?? ""; + if (candidate.length === 0) return null; + const normalized = normalizeUsagePath(candidate); + return normalized === normalizeUsagePath(projectRoot) ? null : normalized; +} diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 713d860999cb..08b0650ee219 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from "@effect/vitest"; import { cacheSavingsUsd, + cacheWriteUsd, createOverrideRateTable, lookupRate, parseRateTable, priceUsage, + usageComponentCosts, } from "./usagePricing.ts"; const rate = (input: number, cacheRead?: number) => ({ @@ -130,4 +132,110 @@ describe("usage pricing", () => { expect(lookupRate(table, "provider-b/example-model")?.inputCostPerToken).toBe(3); expect(lookupRate(table, "example-model")).toBeNull(); }); + + it("keeps a bare name ambiguous when only the one-hour cache rate differs", () => { + const common = { + input_cost_per_token: 1, + output_cost_per_token: 5, + cache_read_input_token_cost: 0.1, + cache_creation_input_token_cost: 1.25, + }; + const table = parseRateTable({ + "provider-a/example-model": { + ...common, + cache_creation_input_token_cost_above_1hr: 2, + }, + "provider-b/example-model": { + ...common, + cache_creation_input_token_cost_above_1hr: 3, + }, + }); + + expect(lookupRate(table, "example-model")).toBeNull(); + }); + + it("keeps a bare name ambiguous when a context-length tier differs", () => { + const common = { + input_cost_per_token: 1, + output_cost_per_token: 5, + input_cost_per_token_above_272k_tokens: 2, + }; + const table = parseRateTable({ + "provider-a/example-model": { + ...common, + output_cost_per_token_above_272k_tokens: 7.5, + }, + "provider-b/example-model": { + ...common, + output_cost_per_token_above_272k_tokens: 10, + }, + }); + + expect(lookupRate(table, "example-model")).toBeNull(); + }); + + it("cannot price more TTL-specific tokens than total cache creation", () => { + const table = parseRateTable({ + "example-model": { + input_cost_per_token: 1, + output_cost_per_token: 5, + cache_creation_input_token_cost: 1.25, + cache_creation_input_token_cost_above_1hr: 2, + }, + }); + + expect( + cacheWriteUsd(table, "example-model", { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 10, + cacheCreation5mTokens: 20, + cacheCreation1hTokens: 20, + outputTokens: 0, + reasoningTokens: 0, + }), + ).toBe(20); + }); + + it("uses the public long-context tier only above its input threshold", () => { + const table = parseRateTable({ + "gpt-5.6-sol": { + input_cost_per_token: 4e-6, + output_cost_per_token: 20e-6, + cache_read_input_token_cost: 0.4e-6, + cache_creation_input_token_cost: 5e-6, + input_cost_per_token_above_272k_tokens: 8e-6, + output_cost_per_token_above_272k_tokens: 30e-6, + cache_read_input_token_cost_above_272k_tokens: 0.8e-6, + cache_creation_input_token_cost_above_272k_tokens: 10e-6, + }, + }); + const atThreshold = { + uncachedInputTokens: 1, + cachedInputTokens: 271_989, + cacheCreationTokens: 10, + outputTokens: 2, + reasoningTokens: 1, + }; + const aboveThreshold = { ...atThreshold, uncachedInputTokens: 2 }; + + expect(priceUsage(table, "gpt-5.6-sol", atThreshold, null).costUsd).toBeCloseTo( + 1 * 4e-6 + 271_989 * 0.4e-6 + 10 * 5e-6 + 2 * 20e-6, + 12, + ); + expect(priceUsage(table, "gpt-5.6-sol", aboveThreshold, null).costUsd).toBeCloseTo( + 2 * 8e-6 + 271_989 * 0.8e-6 + 10 * 10e-6 + 2 * 30e-6, + 12, + ); + expect(cacheWriteUsd(table, "gpt-5.6-sol", aboveThreshold)).toBeCloseTo(10 * 10e-6, 12); + expect(cacheSavingsUsd(table, "gpt-5.6-sol", aboveThreshold)).toBeCloseTo( + 271_989 * (8e-6 - 0.8e-6), + 12, + ); + expect(usageComponentCosts(table, "gpt-5.6-sol", aboveThreshold)).toEqual({ + cacheWriteUsd: 10 * 10e-6, + cacheReadUsd: 271_989 * 0.8e-6, + freshUsd: 2 * 8e-6 + 2 * 30e-6, + }); + }); }); diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 6c94be424827..9d6bc0832d1d 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -16,16 +16,25 @@ import type { /** * The subset of a LiteLLM entry we price against. All values are USD per token. * - * LiteLLM also publishes tiered variants (`*_above_272k_tokens`, `*_flex`, - * `*_priority`, `*_batches`). We deliberately price at the base tier: the - * transcripts don't record which tier served a request, so anything else would - * be a guess dressed up as precision. + * LiteLLM also publishes context-length tiers (`*_above_272k_tokens`) and + * service tiers (`*_flex`, `*_priority`, `*_batches`). Transcript token counts + * determine the context-length tier; service tiers remain unknown and use + * their base public-list rates. */ -export interface ModelRate { +interface TokenRate { readonly inputCostPerToken: number; readonly outputCostPerToken: number; readonly cacheReadCostPerToken: number; readonly cacheCreationCostPerToken: number; + readonly cacheCreation1hCostPerToken?: number; +} + +interface LongContextRate extends TokenRate { + readonly thresholdTokens: number; +} + +export interface ModelRate extends TokenRate { + readonly longContextRates?: readonly LongContextRate[]; } export type RateTable = ReadonlyMap; @@ -50,17 +59,53 @@ export function createOverrideRateTable( } /** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ -interface LiteLlmEntry { +interface LiteLlmEntry extends Record { readonly input_cost_per_token?: unknown; readonly output_cost_per_token?: unknown; readonly cache_read_input_token_cost?: unknown; readonly cache_creation_input_token_cost?: unknown; + readonly cache_creation_input_token_cost_above_1hr?: unknown; } function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } +function parseLongContextRates(entry: LiteLlmEntry, base: TokenRate): readonly LongContextRate[] { + const thresholds = new Set(); + for (const field of Object.keys(entry)) { + const match = /^input_cost_per_token_above_(\d+)k_tokens$/.exec(field); + const thousands = Number(match?.[1]); + if (Number.isSafeInteger(thousands) && thousands > 0) thresholds.add(thousands * 1000); + } + + return [...thresholds] + .sort((left, right) => left - right) + .flatMap((thresholdTokens) => { + const suffix = `${thresholdTokens / 1000}k_tokens`; + const input = finiteNumber(entry[`input_cost_per_token_above_${suffix}`]); + const output = finiteNumber(entry[`output_cost_per_token_above_${suffix}`]); + if (input === null || output === null) return []; + const cacheCreation1hCostPerToken = + finiteNumber(entry[`cache_creation_input_token_cost_above_1hr_above_${suffix}`]) ?? + base.cacheCreation1hCostPerToken; + return [ + { + thresholdTokens, + inputCostPerToken: input, + outputCostPerToken: output, + cacheReadCostPerToken: + finiteNumber(entry[`cache_read_input_token_cost_above_${suffix}`]) ?? + base.cacheReadCostPerToken, + cacheCreationCostPerToken: + finiteNumber(entry[`cache_creation_input_token_cost_above_${suffix}`]) ?? + base.cacheCreationCostPerToken, + ...(cacheCreation1hCostPerToken === undefined ? {} : { cacheCreation1hCostPerToken }), + }, + ]; + }); +} + /** * Projects the LiteLLM document into a rate table. * @@ -84,7 +129,7 @@ export function parseRateTable(document: unknown): RateTable { const key = normalizeRateKey(name); if (key.length === 0) continue; - table.set(key, { + const base: TokenRate = { inputCostPerToken: input, outputCostPerToken: output, // Anthropic bills cache reads at a discount and cache writes at a @@ -92,6 +137,15 @@ export function parseRateTable(document: unknown): RateTable { // input rather than as free. cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, + cacheCreation1hCostPerToken: + finiteNumber(entry.cache_creation_input_token_cost_above_1hr) ?? + finiteNumber(entry.cache_creation_input_token_cost) ?? + input, + }; + const longContextRates = parseLongContextRates(entry, base); + table.set(key, { + ...base, + ...(longContextRates.length === 0 ? {} : { longContextRates }), }); } @@ -115,11 +169,29 @@ export function parseRateTable(document: unknown): RateTable { } function sameRate(a: ModelRate, b: ModelRate): boolean { + if (!sameTokenRate(a, b)) return false; + const aLong = a.longContextRates ?? []; + const bLong = b.longContextRates ?? []; + return ( + aLong.length === bLong.length && + aLong.every((rate, index) => { + const other = bLong[index]; + return ( + other !== undefined && + rate.thresholdTokens === other.thresholdTokens && + sameTokenRate(rate, other) + ); + }) + ); +} + +function sameTokenRate(a: TokenRate, b: TokenRate): boolean { return ( a.inputCostPerToken === b.inputCostPerToken && a.outputCostPerToken === b.outputCostPerToken && a.cacheReadCostPerToken === b.cacheReadCostPerToken && - a.cacheCreationCostPerToken === b.cacheCreationCostPerToken + a.cacheCreationCostPerToken === b.cacheCreationCostPerToken && + a.cacheCreation1hCostPerToken === b.cacheCreation1hCostPerToken ); } @@ -170,6 +242,43 @@ export interface PricedUsage { readonly costSource: UsageCostSource; } +function rateForTotals(rate: ModelRate, totals: UsageTokenTotals): TokenRate { + const inputTokens = + totals.uncachedInputTokens + totals.cachedInputTokens + totals.cacheCreationTokens; + let selected: TokenRate = rate; + for (const tier of rate.longContextRates ?? []) { + if (inputTokens <= tier.thresholdTokens) break; + selected = tier; + } + return selected; +} + +function applicableRate( + table: RateTable, + model: string, + totals: UsageTokenTotals, + overrides?: RateTable, +): TokenRate | null { + const rate = overrides?.get(model.trim()) ?? lookupRate(table, model); + return rate === null ? null : rateForTotals(rate, totals); +} + +function cacheCreationCost(totals: UsageTokenTotals, rate: TokenRate): number { + const oneHour = Math.min( + totals.cacheCreationTokens, + Math.max(0, totals.cacheCreation1hTokens ?? 0), + ); + const fiveMinute = Math.min( + totals.cacheCreationTokens - oneHour, + Math.max(0, totals.cacheCreation5mTokens ?? 0), + ); + const unclassified = totals.cacheCreationTokens - fiveMinute - oneHour; + return ( + (unclassified + fiveMinute) * rate.cacheCreationCostPerToken + + oneHour * (rate.cacheCreation1hCostPerToken ?? rate.cacheCreationCostPerToken) + ); +} + /** * Prices a bucket's tokens. * @@ -188,13 +297,13 @@ export function priceUsage( return { costUsd: reportedCostUsd, costSource: "providerReported" }; } - const rate = override ?? lookupRate(table, model); + const rate = applicableRate(table, model, totals, overrides); if (rate === null) return { costUsd: 0, costSource: "unpriced" }; const costUsd = totals.uncachedInputTokens * rate.inputCostPerToken + totals.cachedInputTokens * rate.cacheReadCostPerToken + - totals.cacheCreationTokens * rate.cacheCreationCostPerToken + + cacheCreationCost(totals, rate) + totals.outputTokens * rate.outputCostPerToken; return { costUsd, costSource: "modelPriced" }; @@ -210,7 +319,57 @@ export function cacheSavingsUsd( totals: UsageTokenTotals, overrides?: RateTable, ): number { - const rate = overrides?.get(model.trim()) ?? lookupRate(table, model); + const rate = applicableRate(table, model, totals, overrides); if (rate === null) return 0; return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); } + +/** + * Estimates what this usage's cache writes cost at the model and TTL-specific rates. + * Cache creation is a billing category, not proof of an expiry rewrite. + */ +export function cacheWriteUsd( + table: RateTable, + model: string, + totals: UsageTokenTotals, + overrides?: RateTable, +): number { + const rate = applicableRate(table, model, totals, overrides); + if (rate === null) return 0; + return cacheCreationCost(totals, rate); +} + +export interface UsageComponentCosts { + readonly cacheWriteUsd: number; + readonly cacheReadUsd: number; + /** Fresh input plus output. */ + readonly freshUsd: number; +} + +const ZERO_COMPONENT_COSTS: UsageComponentCosts = { + cacheWriteUsd: 0, + cacheReadUsd: 0, + freshUsd: 0, +}; + +/** + * Splits model-priced usage into cache writes, cache reads, and everything + * else. Unpriced models contribute nothing here; token totals still include + * them. + */ +export function usageComponentCosts( + table: RateTable, + model: string, + totals: UsageTokenTotals, + overrides?: RateTable, +): UsageComponentCosts { + const rate = applicableRate(table, model, totals, overrides); + if (rate === null) return ZERO_COMPONENT_COSTS; + return { + cacheWriteUsd: cacheCreationCost(totals, rate), + cacheReadUsd: totals.cachedInputTokens * rate.cacheReadCostPerToken, + freshUsd: + totals.uncachedInputTokens * rate.inputCostPerToken + + totals.outputTokens * rate.outputCostPerToken, + }; +} diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index fdb0aabafa40..68bac9dccaca 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -2,11 +2,15 @@ import { describe, expect, it } from "@effect/vitest"; import { decodeScanCache, + decodeScanIdentityCache, dedupeWithinFile, encodeScanCache, + pruneScanIdentityCache, pruneScanCache, + USAGE_SCAN_CACHE_VERSION, type CachedFile, type ScanCache, + type ScanIdentityCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -16,10 +20,12 @@ function record(overrides: Partial = {}): UsageRecord { timestampMs: 1_786_000_000_000, model: "claude-fable-5", sessionId: "session-a", + cwd: "/home/theo/project", totals: { uncachedInputTokens: 2, cachedInputTokens: 1000, cacheCreationTokens: 10, + cacheCreation5mTokens: 10, outputTokens: 50, reasoningTokens: 0, }, @@ -55,6 +61,28 @@ function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]) } describe("scan cache round trip", () => { + it("restores positive and negative transcript identities", () => { + const identities: ScanIdentityCache = new Map([ + [ + "/target.jsonl", + { + size: 100, + mtimeMs: 200, + provider: "codex", + sessionId: "target-session", + cwd: "/work/target", + }, + ], + ["/unknown.jsonl", { size: 10, mtimeMs: 20, provider: "codex", sessionId: "", cwd: "" }], + ]); + + const restored = decodeScanIdentityCache( + JSON.parse(JSON.stringify(encodeScanCache(new Map(), identities))), + ); + + expect(restored).toEqual(identities); + }); + it("restores records unchanged", () => { const original = cacheWith([ ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], @@ -80,6 +108,7 @@ describe("scan cache round trip", () => { codexState: { model: "gpt-5.2-codex", sessionId: "session-c", + cwd: "/home/theo/codex-project", lastUsageSignature: '{"input_tokens":1}', sawSessionMeta: true, suppressingForkCopies: false, @@ -97,6 +126,25 @@ describe("scan cache round trip", () => { expect(restored.get("/codex.jsonl")).toEqual(original.get("/codex.jsonl")); }); + it("preserves partial TTL classification and its unclassified remainder", () => { + const partial = record({ + totals: { + uncachedInputTokens: 2, + cachedInputTokens: 10, + cacheCreationTokens: 60, + cacheCreation5mTokens: 20, + cacheCreation1hTokens: 10, + outputTokens: 12, + reasoningTokens: 0, + }, + }); + const original = cacheWith([["/partial.jsonl", 100, [partial]]]); + + const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); + + expect(restored.get("/partial.jsonl")?.records[0]?.totals).toEqual(partial.totals); + }); + it("drops an entry whose persisted parse state is corrupt", () => { // Resuming with a bad reducer state would attach appended usage to the // wrong model or replay fork-copied history; that entry must cold parse. @@ -125,7 +173,7 @@ describe("scan cache round trip", () => { it("rejects a document from the previous cache version", () => { const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const previous = { ...encoded, version: 2 }; + const previous = { ...encoded, version: USAGE_SCAN_CACHE_VERSION - 1 }; expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); @@ -186,6 +234,72 @@ describe("scan cache round trip", () => { const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); expect(restored.has("/a.jsonl")).toBe(false); }); + + it.each([0.5, 99])("drops an entry with invalid cwd index %s", (cwdIndex) => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const row = encoded.files["/a.jsonl"]!.r[0]!; + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [[...row.slice(0, 10), cwdIndex, ...row.slice(11)]], + }, + }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it.each([ + [20, 0], + [-1, 11], + [5.5, 4.5], + ])("drops an entry with invalid cache TTL counters %s + %s", (fiveMinute, oneHour) => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const row = encoded.files["/a.jsonl"]!.r[0]!; + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [[...row.slice(0, 11), fiveMinute, oneHour]], + }, + }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); +}); + +describe("pruneScanIdentityCache", () => { + it("keeps old live identities and removes only files proven deleted", () => { + const identities: ScanIdentityCache = new Map([ + [ + "/codex/sessions/live.jsonl", + { size: 10, mtimeMs: 1, provider: "codex", sessionId: "live", cwd: "/work/live" }, + ], + [ + "/codex/sessions/gone.jsonl", + { size: 10, mtimeMs: 1, provider: "codex", sessionId: "gone", cwd: "/work/gone" }, + ], + [ + "/other/sessions/unwalked.jsonl", + { size: 10, mtimeMs: 1, provider: "codex", sessionId: "other", cwd: "/work/other" }, + ], + ]); + + expect( + pruneScanIdentityCache(identities, { + livePaths: new Set(["/codex/sessions/live.jsonl"]), + walkedRoots: ["/codex/sessions"], + }), + ).toBe(1); + expect([...identities.keys()]).toEqual([ + "/codex/sessions/live.jsonl", + "/other/sessions/unwalked.jsonl", + ]); + }); }); describe("pruneScanCache", () => { @@ -281,7 +395,7 @@ describe("pruneScanCache with an unwalked root", () => { }); describe("dedupeWithinFile", () => { - it("keeps the first record per dedupe key", () => { + it("keeps the final record per dedupe key", () => { const kept = dedupeWithinFile([ record({ totals: { ...record().totals, outputTokens: 1 } }), record({ totals: { ...record().totals, outputTokens: 999 } }), @@ -289,7 +403,7 @@ describe("dedupeWithinFile", () => { ]); expect(kept).toHaveLength(2); - expect(kept[0]?.totals.outputTokens).toBe(1); + expect(kept[0]?.totals.outputTokens).toBe(999); }); it("keeps every record that has no dedupe key", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 102058a07d35..41d71384131e 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -26,7 +26,12 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // entries would keep serving double-counted records forever. // v3: entries carry the parse position and reducer state so a grown file // re-parses only its appended bytes instead of starting over. -export const USAGE_SCAN_CACHE_VERSION = 3 as const; +// v4: records carry the session's cwd for project attribution; v3 entries +// would pin every cached file to "no project" forever. +// v5: Claude records retain cache TTLs and expanded fallback iterations. +// v6: Claude iterations without their own model inherit the serving model. +// v7: a compact file identity index survives record-retention pruning. +export const USAGE_SCAN_CACHE_VERSION = 7 as const; export interface CachedFile { readonly size: number; @@ -45,6 +50,16 @@ export interface CachedFile { export type ScanCache = Map; +export interface CachedFileIdentity { + readonly size: number; + readonly mtimeMs: number; + readonly provider: UsageProviderKind; + readonly sessionId: string; + readonly cwd: string; +} + +export type ScanIdentityCache = Map; + /** * 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 @@ -61,6 +76,9 @@ type SerializedRecord = readonly [ reasoningTokens: number, dedupeKey: string | null, reportedCostUsd: number | null, + cwdIndex: number, + cacheCreation5mTokens: number, + cacheCreation1hTokens: number, ]; interface SerializedFile { @@ -82,15 +100,33 @@ interface SerializedCache { readonly version: number; readonly models: readonly string[]; readonly sessions: readonly string[]; + readonly cwds: readonly string[]; readonly files: Readonly>; + readonly identities: Readonly< + Record< + string, + readonly [ + size: number, + mtimeMs: number, + provider: UsageProviderKind, + sessionIndex: number, + cwdIndex: number, + ] + > + >; } -/** Serialises the cache, interning the repeated model and session strings. */ -export function encodeScanCache(cache: ScanCache): SerializedCache { +/** Serialises the cache, interning the repeated model, session and cwd strings. */ +export function encodeScanCache( + cache: ScanCache, + identityCache: ScanIdentityCache = new Map(), +): SerializedCache { const models: string[] = []; const sessions: string[] = []; + const cwds: string[] = []; const modelIndex = new Map(); const sessionIndex = new Map(); + const cwdIndex = new Map(); const intern = (table: string[], index: Map, value: string): number => { const existing = index.get(value); @@ -101,18 +137,31 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { return next; }; - const serializeRecord = (record: UsageRecord): SerializedRecord => [ - record.timestampMs, - intern(models, modelIndex, record.model), - intern(sessions, sessionIndex, record.sessionId), - record.totals.uncachedInputTokens, - record.totals.cachedInputTokens, - record.totals.cacheCreationTokens, - record.totals.outputTokens, - record.totals.reasoningTokens, - record.dedupeKey, - record.reportedCostUsd, - ]; + const serializeRecord = (record: UsageRecord): SerializedRecord => { + const oneHour = Math.min( + record.totals.cacheCreationTokens, + Math.max(0, record.totals.cacheCreation1hTokens ?? 0), + ); + const fiveMinute = Math.min( + record.totals.cacheCreationTokens - oneHour, + Math.max(0, record.totals.cacheCreation5mTokens ?? 0), + ); + return [ + record.timestampMs, + intern(models, modelIndex, record.model), + intern(sessions, sessionIndex, record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.totals.reasoningTokens, + record.dedupeKey, + record.reportedCostUsd, + intern(cwds, cwdIndex, record.cwd), + fiveMinute, + oneHour, + ]; + }; const files: Record = {}; for (const [path, entry] of cache) { @@ -129,13 +178,28 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { }; } - return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; + const identities: Record = {}; + for (const [path, identity] of identityCache) { + identities[path] = [ + identity.size, + identity.mtimeMs, + identity.provider, + intern(sessions, sessionIndex, identity.sessionId), + intern(cwds, cwdIndex, identity.cwd), + ]; + } + + return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, cwds, files, identities }; } function isRecordArray(value: unknown): value is readonly unknown[] { return Array.isArray(value); } +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + /** * Rebuilds the cache from a parsed document. * @@ -148,7 +212,9 @@ export function decodeScanCache(document: unknown): ScanCache { const root = document as Partial; if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache; - if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache; + if (!isRecordArray(root.models) || !isRecordArray(root.sessions) || !isRecordArray(root.cwds)) { + return cache; + } if (typeof root.files !== "object" || root.files === null) return cache; // The intern tables must be all strings: a numeric entry would pass the @@ -156,8 +222,10 @@ export function decodeScanCache(document: unknown): ScanCache { // at lookupRate. A corrupt table rejects the whole cache. if (!root.models.every((value) => typeof value === "string")) return cache; if (!root.sessions.every((value) => typeof value === "string")) return cache; + if (!root.cwds.every((value) => typeof value === "string")) return cache; const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; + const cwds = root.cwds as readonly string[]; // Any corrupt row disqualifies the whole entry. Keeping the survivors // under the original (size, mtime) would read as a valid warm hit and the @@ -168,7 +236,7 @@ export function decodeScanCache(document: unknown): ScanCache { ): UsageRecord[] | null => { const records: UsageRecord[] = []; for (const row of rows) { - if (!isRecordArray(row) || row.length < 10) return null; + if (!isRecordArray(row) || row.length < 13) return null; const [ timestampMs, modelIndex, @@ -180,18 +248,31 @@ export function decodeScanCache(document: unknown): ScanCache { reasoning, dedupeKey, reportedCostUsd, + cwdIndex, + cacheCreation5m, + cacheCreation1h, ] = row as SerializedRecord; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; + const session = typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined; + const cwd = typeof cwdIndex === "number" ? cwds[cwdIndex] : undefined; if ( typeof timestampMs !== "number" || !Number.isFinite(timestampMs) || model === undefined || - !Number.isFinite(uncached) || - !Number.isFinite(cached) || - !Number.isFinite(cacheCreation) || - !Number.isFinite(output) || - !Number.isFinite(reasoning) + !Number.isInteger(modelIndex) || + session === undefined || + !Number.isInteger(sessionIndex) || + cwd === undefined || + !Number.isInteger(cwdIndex) || + !isNonNegativeInteger(uncached) || + !isNonNegativeInteger(cached) || + !isNonNegativeInteger(cacheCreation) || + !isNonNegativeInteger(cacheCreation5m) || + !isNonNegativeInteger(cacheCreation1h) || + cacheCreation5m + cacheCreation1h > cacheCreation || + !isNonNegativeInteger(output) || + !isNonNegativeInteger(reasoning) ) { return null; } @@ -200,11 +281,14 @@ export function decodeScanCache(document: unknown): ScanCache { provider, timestampMs, model, - sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", + sessionId: session, + cwd, totals: { uncachedInputTokens: uncached, cachedInputTokens: cached, cacheCreationTokens: cacheCreation, + ...(cacheCreation5m === 0 ? {} : { cacheCreation5mTokens: cacheCreation5m }), + ...(cacheCreation1h === 0 ? {} : { cacheCreation1hTokens: cacheCreation1h }), outputTokens: output, reasoningTokens: reasoning, }, @@ -265,6 +349,71 @@ export function decodeScanCache(document: unknown): ScanCache { return cache; } +/** Restores the compact identities used to prefilter targeted thread reads. */ +export function decodeScanIdentityCache(document: unknown): ScanIdentityCache { + const cache: ScanIdentityCache = new Map(); + if (typeof document !== "object" || document === null) return cache; + const root = document as Partial; + if ( + root.version !== USAGE_SCAN_CACHE_VERSION || + !isRecordArray(root.sessions) || + !root.sessions.every((value) => typeof value === "string") || + !isRecordArray(root.cwds) || + !root.cwds.every((value) => typeof value === "string") || + typeof root.identities !== "object" || + root.identities === null + ) { + return cache; + } + const sessions = root.sessions as readonly string[]; + const cwds = root.cwds as readonly string[]; + for (const [path, value] of Object.entries(root.identities)) { + if (!isRecordArray(value) || value.length !== 5) continue; + const [size, mtimeMs, provider, sessionIndex, cwdIndex] = value; + const sessionId = typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined; + const cwd = typeof cwdIndex === "number" ? cwds[cwdIndex] : undefined; + if ( + !Number.isSafeInteger(size) || + (size as number) < 0 || + typeof mtimeMs !== "number" || + !Number.isFinite(mtimeMs) || + (provider !== "claude" && provider !== "codex" && provider !== "grok") || + !Number.isInteger(sessionIndex) || + sessionId === undefined || + !Number.isInteger(cwdIndex) || + cwd === undefined + ) { + continue; + } + cache.set(path, { size: size as number, mtimeMs, provider, sessionId, cwd }); + } + return cache; +} + +/** Drops identity rows only when a complete directory walk proves deletion. */ +export function pruneScanIdentityCache( + cache: ScanIdentityCache, + options: { readonly livePaths: ReadonlySet; readonly walkedRoots: readonly string[] }, +): number { + let removed = 0; + for (const path of cache.keys()) { + const underWalkedRoot = options.walkedRoots.some((root) => { + const relative = NodePath.relative(root, path); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${NodePath.sep}`) && + !NodePath.isAbsolute(relative)) + ); + }); + if (underWalkedRoot && !options.livePaths.has(path)) { + cache.delete(path); + removed += 1; + } + } + return removed; +} + /** * Validates a persisted Codex reducer state. Returns `undefined` for a corrupt * value, which disqualifies the entry: resuming with a bad state would attach @@ -277,6 +426,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { if ( typeof state.model !== "string" || typeof state.sessionId !== "string" || + typeof state.cwd !== "string" || (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || typeof state.sawSessionMeta !== "boolean" || typeof state.suppressingForkCopies !== "boolean" || @@ -288,6 +438,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { return { model: state.model, sessionId: state.sessionId, + cwd: state.cwd, lastUsageSignature: state.lastUsageSignature ?? null, sawSessionMeta: state.sawSessionMeta, suppressingForkCopies: state.suppressingForkCopies, @@ -343,22 +494,18 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number return removed; } -/** - * Within-file de-duplication, applied before an entry is cached. - * - * Callers stitching an incremental parse together pass one `seen` set across - * the line and tail record batches so the whole file stays deduplicated as a - * unit; the set is mutated in place. - */ -export function dedupeWithinFile( - records: readonly UsageRecord[], - seen: Set = new Set(), -): readonly UsageRecord[] { +/** Within-file de-duplication, retaining the final complete Claude snapshot. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { + const indexByKey = new Map(); const kept: UsageRecord[] = []; for (const record of records) { if (record.dedupeKey !== null) { - if (seen.has(record.dedupeKey)) continue; - seen.add(record.dedupeKey); + const existing = indexByKey.get(record.dedupeKey); + if (existing !== undefined) { + kept[existing] = record; + continue; + } + indexByKey.set(record.dedupeKey, kept.length); } kept.push(record); } diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts new file mode 100644 index 000000000000..0785962f3d5b --- /dev/null +++ b/apps/server/src/usage/usageThreads.test.ts @@ -0,0 +1,611 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { UsageAggregator } from "./usageAggregation.ts"; +import type { RateTable } from "./usagePricing.ts"; +import { foldThreadRows, ThreadUsageAccumulator, type ThreadAttribution } from "./usageThreads.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const rates: RateTable = new Map([ + [ + "claude-fable-5", + { + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheReadCostPerToken: 1e-6, + cacheCreationCostPerToken: 1.25e-5, + cacheCreation1hCostPerToken: 2e-5, + }, + ], +]); + +const PROJECT_ONE = { projectId: ProjectId.make("project-one"), title: "Project one" }; +const PROJECT_TWO = { projectId: ProjectId.make("project-two"), title: "Project two" }; + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + timestampMs: Date.parse("2026-08-07T04:05:13.944Z"), + model: "claude-fable-5", + sessionId: "session-a", + cwd: "/work/app", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: null, + ...overrides, + }; +} + +function accumulate( + entries: readonly (readonly [UsageRecord, { sessionKey: string; agentId: string | null }])[], +) { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + for (const [item, context] of entries) accumulator.add(item, context); + return accumulator.finish(); +} + +const NO_ATTRIBUTION: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map(), +}; + +describe("ThreadUsageAccumulator", () => { + it("groups records by session and splits subagent slices out", () => { + const main = { sessionKey: "claude:session-a", agentId: null }; + const agent = { sessionKey: "claude:session-a", agentId: "agent-1" }; + const groups = accumulate([ + [record(), main], + [record(), agent], + [record({ sessionId: "session-b" }), { sessionKey: "claude:session-b", agentId: null }], + ]); + + expect(groups).toHaveLength(2); + const sessionA = groups.find((group) => group.sessionKey === "claude:session-a"); + expect(sessionA?.totals.outputTokens).toBe(100); + expect(sessionA?.agents.get("agent-1")?.totals.outputTokens).toBe(50); + }); + + it("dedupes globally across files with the summary's semantics", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [record({ dedupeKey: "msg_1:" }), context], + [record({ dedupeKey: "msg_1:" }), context], + ]); + + expect(groups[0]?.totals.outputTokens).toBe(50); + }); + + it("uses the final complete snapshot across files", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [ + record({ dedupeKey: "msg_partial:", totals: { ...record().totals, outputTokens: 1 } }), + context, + ], + [ + record({ dedupeKey: "msg_partial:", totals: { ...record().totals, outputTokens: 310 } }), + context, + ], + ]); + + expect(groups[0]?.totals.outputTokens).toBe(310); + }); + + it("applies the window to the final complete snapshot", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [record({ dedupeKey: "msg_partial:" }), context], + [ + record({ + dedupeKey: "msg_partial:", + timestampMs: Date.parse("2026-09-01T00:00:00Z"), + }), + context, + ], + ]); + + expect(groups).toEqual([]); + }); + + it("splits each day's model-priced cost into cache components", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([[record(), context]]); + const day = groups[0]?.daily.get("2026-08-07"); + + expect(day?.cacheWriteUsd).toBeCloseTo(10 * 1.25e-5, 12); + expect(day?.cacheReadUsd).toBeCloseTo(1000 * 1e-6, 12); + expect(day?.freshUsd).toBeCloseTo(100 * 1e-5 + 50 * 5e-5, 12); + }); + + it("does not invent component costs for provider-reported totals", () => { + const context = { sessionKey: "claude:session-a", agentId: "agent-1" }; + const groups = accumulate([[record({ reportedCostUsd: 1.25 }), context]]); + const rows = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 40 }); + + expect(rows.rows[0]?.costUsd).toBe(1.25); + expect(rows.rows[0]?.cacheWriteUsd).toBeNull(); + expect(rows.rows[0]?.agents[0]?.cacheWriteUsd).toBeNull(); + expect(rows.rows[0]?.daily).toEqual([]); + }); + + it("uses custom prices for thread totals and component costs", () => { + const customRates: RateTable = new Map([ + [ + "claude-fable-5", + { + inputCostPerToken: 2e-5, + outputCostPerToken: 1e-4, + cacheReadCostPerToken: 2e-6, + cacheCreationCostPerToken: 2.5e-5, + }, + ], + ]); + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + priceOverrides: customRates, + }); + accumulator.add(record({ reportedCostUsd: 1.25 }), { + sessionKey: "claude:session-a", + agentId: null, + }); + + const group = accumulator.finish()[0]; + const day = group?.daily.get("2026-08-07"); + expect(group?.costUsd).toBeCloseTo(100 * 2e-5 + 1000 * 2e-6 + 10 * 2.5e-5 + 50 * 1e-4, 12); + expect(day?.cacheWriteUsd).toBeCloseTo(10 * 2.5e-5, 12); + expect(day?.cacheReadUsd).toBeCloseTo(1000 * 2e-6, 12); + expect(day?.freshUsd).toBeCloseTo(100 * 2e-5 + 50 * 1e-4, 12); + }); + + it("drops records outside the window", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [record({ timestampMs: Date.parse("2026-07-01T00:00:00Z") }), context], + ]); + + expect(groups).toHaveLength(0); + }); + + it("drops timestamps outside the JavaScript date range", () => { + const context = { sessionKey: "grok:session-a", agentId: null }; + expect(() => accumulate([[record({ timestampMs: 1e20 }), context]])).not.toThrow(); + expect(accumulate([[record({ timestampMs: 1e20 }), context]])).toEqual([]); + }); + + it("applies exact time bounds inside a shared calendar day", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-07", + untilDay: "2026-08-07", + sinceTimeMs: Date.parse("2026-08-07T04:00:00Z"), + untilTimeMs: Date.parse("2026-08-07T05:00:00Z"), + rates, + }); + const context = { sessionKey: "claude:session-a", agentId: null }; + accumulator.add(record({ timestampMs: Date.parse("2026-08-07T03:59:59Z") }), context); + accumulator.add(record({ timestampMs: Date.parse("2026-08-07T04:30:00Z") }), context); + accumulator.add(record({ timestampMs: Date.parse("2026-08-07T05:00:00Z") }), context); + + expect(accumulator.finish()[0]?.totals.outputTokens).toBe(50); + }); + + it("uses exact bounds without applying a second calendar-day filter", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-07", + untilDay: "2026-08-07", + sinceTimeMs: Date.parse("2026-08-08T04:00:00Z"), + untilTimeMs: Date.parse("2026-08-08T05:00:00Z"), + rates, + }); + + accumulator.add(record({ timestampMs: Date.parse("2026-08-08T04:30:00Z") }), { + sessionKey: "claude:exact-window", + agentId: null, + }); + + expect(accumulator.finish()[0]?.totals.outputTokens).toBe(50); + }); + + it("keeps separate cwd slices when one session crosses projects", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd.endsWith("one") ? PROJECT_ONE : PROJECT_TWO), + }); + const context = { sessionKey: "claude:session-a", agentId: null }; + accumulator.add(record({ cwd: "/work/one" }), context); + accumulator.add(record({ cwd: "/work/two" }), context); + + expect( + accumulator + .finish() + .map((group) => group.projectKey) + .toSorted(), + ).toEqual(["id:project-one", "id:project-two"]); + }); +}); + +describe("foldThreadRows", () => { + const threadId = ThreadId.make("11111111-1111-4111-8111-111111111111"); + + it("keeps only the requested thread before applying the row cap", () => { + const targetThreadId = ThreadId.make("thread-target"); + const groups = accumulate([ + [ + record({ sessionId: "expensive", totals: { ...record().totals, outputTokens: 1_000 } }), + { sessionKey: "claude:expensive", agentId: null }, + ], + [record({ sessionId: "target" }), { sessionKey: "claude:target", agentId: null }], + [ + record({ sessionId: "other", totals: { ...record().totals, outputTokens: 500 } }), + { sessionKey: "claude:other", agentId: null }, + ], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map([ + ["claude:expensive", { threadId: ThreadId.make("thread-expensive"), title: "Expensive" }], + ["claude:target", { threadId: targetThreadId, title: "Target" }], + ["claude:other", { threadId: ThreadId.make("thread-other"), title: "Other" }], + ]), + worktreeToThread: new Map(), + }; + + const result = foldThreadRows(groups, attribution, { cap: 1, threadFilter: targetThreadId }); + + expect(result.truncatedRows).toBe(0); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]?.threadId).toBe(targetThreadId); + }); + + it("folds sessions into one row per thread via cursor and worktree matches", () => { + const groups = accumulate([ + [record(), { sessionKey: "claude:session-a", agentId: null }], + [ + record({ sessionId: "session-b", cwd: "/work/app/.wt/thread-1" }), + { sessionKey: "claude:session-b", agentId: null }, + ], + [record({ sessionId: "session-c" }), { sessionKey: "claude:session-c", agentId: null }], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map([["claude:session-a", { threadId, title: "Fix the flaky test" }]]), + worktreeToThread: new Map([ + ["/work/app/.wt/thread-1", { threadId, title: "Fix the flaky test" }], + ]), + }; + + const { rows, truncatedRows } = foldThreadRows(groups, attribution, { cap: 40 }); + + expect(truncatedRows).toBe(0); + expect(rows).toHaveLength(2); + const threadRow = rows.find((row) => row.threadId === threadId); + expect(threadRow?.title).toBe("Fix the flaky test"); + expect(threadRow?.sessions).toBe(2); + const standalone = rows.find((row) => row.threadId === null); + // Standalone rows leave the title to the caller's transcript read. + expect(standalone?.title).toBeNull(); + expect(standalone?.key).toContain("claude:session-c"); + }); + + it("uses the deepest worktree ancestor for sessions run in subdirectories", () => { + const nestedThreadId = ThreadId.make("22222222-2222-4222-8222-222222222222"); + const groups = accumulate([ + [ + record({ sessionId: "nested", cwd: "/work/app/.wt/thread-1/packages/web" }), + { sessionKey: "claude:nested", agentId: null }, + ], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map([ + ["/work/app", { threadId, title: "Shared root" }], + ["/work/app/.wt/thread-1", { threadId: nestedThreadId, title: "Nested worktree" }], + ]), + }; + + const { rows } = foldThreadRows(groups, attribution, { cap: 40 }); + + expect(rows[0]?.threadId).toBe(nestedThreadId); + expect(rows[0]?.title).toBe("Nested worktree"); + }); + + it("matches worktrees across slash styles and normalized segments", () => { + const groups = accumulate([ + [ + record({ sessionId: "mixed", cwd: "\\work\\app\\.wt\\thread-1\\packages\\web" }), + { sessionKey: "claude:mixed", agentId: null }, + ], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map([ + ["/work/app/other/../.wt/thread-1/", { threadId, title: "Normalized worktree" }], + ]), + }; + + const { rows } = foldThreadRows(groups, attribution, { cap: 40 }); + + expect(rows[0]?.threadId).toBe(threadId); + expect(rows[0]?.title).toBe("Normalized worktree"); + }); + it("matches Windows worktrees without changing POSIX case sensitivity", () => { + const groups = accumulate([ + [ + record({ sessionId: "windows", cwd: "c:\\work\\app\\.wt\\thread-1\\src" }), + { sessionKey: "claude:windows", agentId: null }, + ], + [ + record({ sessionId: "posix", cwd: "/work/app/.wt/thread-1/src" }), + { sessionKey: "claude:posix", agentId: null }, + ], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map([ + ["C:\\Work\\App\\.wt\\thread-1", { threadId, title: "Windows worktree" }], + ["/Work/App/.wt/thread-1", { threadId, title: "Different POSIX worktree" }], + ]), + }; + + const { rows } = foldThreadRows(groups, attribution, { cap: 40 }); + + const threadRow = rows.find((row) => row.threadId === threadId); + expect(threadRow?.sessions).toBe(1); + expect(rows.some((row) => row.threadId === null && row.sessions === 1)).toBe(true); + }); + it("scopes one T3 thread by provider and project", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd.endsWith("one") ? PROJECT_ONE : PROJECT_TWO), + }); + const entries = [ + [record({ sessionId: "claude-one", cwd: "/work/one" }), "claude:claude-one"], + [record({ sessionId: "claude-two", cwd: "/work/two" }), "claude:claude-two"], + [ + record({ + provider: "codex", + model: "gpt-5.6-sol", + sessionId: "codex-one", + cwd: "/work/one", + }), + "codex:codex-one", + ], + ] as const; + for (const [item, sessionKey] of entries) { + accumulator.add(item, { sessionKey, agentId: null }); + } + const attribution: ThreadAttribution = { + sessionToThread: new Map( + entries.map(([, sessionKey]) => [sessionKey, { threadId, title: "Shared thread" }]), + ), + worktreeToThread: new Map(), + }; + + const { rows } = foldThreadRows(accumulator.finish(), attribution, { cap: 40 }); + + expect(rows).toHaveLength(3); + expect(rows.map((row) => [row.provider, row.project]).toSorted()).toEqual([ + ["claude", "Project one"], + ["claude", "Project two"], + ["codex", "Project one"], + ]); + expect(new Set(rows.map((row) => row.key)).size).toBe(3); + expect(rows.every((row) => row.threadId === threadId && row.title === "Shared thread")).toBe( + true, + ); + + const projectOne = foldThreadRows(accumulator.finish(), attribution, { + cap: 40, + projectFilter: "id:project-one", + }); + expect(projectOne.rows.map((row) => [row.provider, row.project]).toSorted()).toEqual([ + ["claude", "Project one"], + ["codex", "Project one"], + ]); + }); + + it("groups rows past the cap without losing their usage", () => { + const groups = accumulate( + Array.from({ length: 5 }, (_, index) => [ + record({ sessionId: `session-${index}` }), + { sessionKey: `claude:session-${index}`, agentId: null }, + ]), + ); + + const { rows, truncatedRows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 3 }); + + expect(rows).toHaveLength(3); + expect(truncatedRows).toBe(3); + expect(rows.find((row) => row.key.startsWith("remainder:"))?.title).toBe("Other threads (3)"); + expect(rows.find((row) => row.key.startsWith("remainder:"))?.groupedRows).toBe(3); + expect(rows.reduce((sum, row) => sum + row.totals.outputTokens, 0)).toBe(250); + }); + + it("keeps subagent slices when lower-cost rows fold into a remainder", () => { + const groups = accumulate([ + [ + record({ sessionId: "expensive", totals: { ...record().totals, outputTokens: 100 } }), + { sessionKey: "claude:expensive", agentId: null }, + ], + [ + record({ sessionId: "cheaper" }), + { sessionKey: "claude:cheaper", agentId: "agent-cheaper" }, + ], + ]); + + const { rows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 1 }); + const remainder = rows.find((row) => row.key.startsWith("remainder:")); + expect(remainder?.agents.map((agent) => agent.agentId)).toEqual(["agent-cheaper"]); + }); + + it("bounds and reconciles subagents folded into a remainder", () => { + const groups = accumulate([ + [ + record({ sessionId: "expensive", totals: { ...record().totals, outputTokens: 100 } }), + { sessionKey: "claude:expensive", agentId: null }, + ], + ...Array.from( + { length: 5 }, + (_, index) => + [ + record({ sessionId: `cheaper-${index}` }), + { sessionKey: `claude:cheaper-${index}`, agentId: `agent-${index}` }, + ] as const, + ), + ]); + + const { rows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 2 }); + const remainder = rows.find((row) => row.key.startsWith("remainder:")); + + expect(remainder?.agents).toHaveLength(2); + expect(remainder?.agents.some((agent) => agent.agentId === "Other subagents (4)")).toBe(true); + expect(remainder?.agents.reduce((sum, agent) => sum + agent.totals.outputTokens, 0)).toBe(250); + }); + + it("collapses overflow project scopes without exceeding the response cap", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => ({ + projectId: ProjectId.make(`project-${cwd.slice(-1)}`), + title: `Project ${cwd.slice(-1)}`, + }), + }); + for (let index = 0; index < 6; index += 1) { + accumulator.add(record({ sessionId: `session-${index}`, cwd: `/work/${index}` }), { + sessionKey: `claude:session-${index}`, + agentId: null, + }); + } + + const { rows } = foldThreadRows(accumulator.finish(), NO_ATTRIBUTION, { cap: 3 }); + + expect(rows.length).toBeLessThanOrEqual(3); + expect(rows.reduce((sum, row) => sum + row.totals.outputTokens, 0)).toBe(300); + }); + + it("reconciles every provider and project after lower-cost rows are grouped", () => { + const resolveProject = (cwd: string) => (cwd.endsWith("one") ? PROJECT_ONE : PROJECT_TWO); + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject, + }); + const summary = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + resolution: "day", + rates, + resolveProject, + }); + for (const [index, provider, project] of [ + [0, "claude", "one"], + [1, "claude", "one"], + [2, "claude", "two"], + [3, "codex", "one"], + [4, "codex", "two"], + ] as const) { + const item = record({ + provider, + model: provider === "claude" ? "claude-fable-5" : "gpt-5.6-sol", + sessionId: `session-${index}`, + cwd: `/work/${project}`, + }); + accumulator.add(item, { sessionKey: `${provider}:session-${index}`, agentId: null }); + summary.add(item); + } + const groups = accumulator.finish(); + + const { rows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 4 }); + expect(rows.length).toBeLessThanOrEqual(4); + const expected = new Map(); + for (const bucket of summary.finish().buckets) { + const key = `${bucket.provider}:${bucket.project ?? ""}`; + expected.set(key, (expected.get(key) ?? 0) + bucket.totals.outputTokens); + } + const actual = new Map(); + for (const row of rows) { + const key = `${row.provider}:${row.project ?? ""}`; + actual.set(key, (actual.get(key) ?? 0) + row.totals.outputTokens); + } + + expect(actual).toEqual(expected); + }); + + it("filters by project before capping", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd === "/work/app" ? PROJECT_ONE : null), + }); + accumulator.add(record(), { sessionKey: "claude:session-a", agentId: null }); + accumulator.add(record({ sessionId: "session-b", cwd: "/elsewhere" }), { + sessionKey: "claude:session-b", + agentId: null, + }); + const groups = accumulator.finish(); + + const app = foldThreadRows(groups, NO_ATTRIBUTION, { + cap: 40, + projectFilter: "id:project-one", + }); + expect(app.rows.map((row) => row.key)).toHaveLength(1); + expect(app.rows[0]?.key).toContain("claude:session-a"); + + const outside = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 40, projectFilter: null }); + expect(outside.rows.map((row) => row.key)).toHaveLength(1); + expect(outside.rows[0]?.key).toContain("claude:session-b"); + }); + + it("excludes unknown project attribution from the outside-project filter", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: () => null, + }); + accumulator.add(record({ sessionId: "outside", cwd: "/elsewhere" }), { + sessionKey: "claude:outside", + agentId: null, + }); + accumulator.add(record({ provider: "grok", sessionId: "unknown", cwd: "" }), { + sessionKey: "grok:unknown", + agentId: null, + }); + + const outside = foldThreadRows(accumulator.finish(), NO_ATTRIBUTION, { + cap: 40, + projectFilter: null, + }); + + expect(outside.rows).toHaveLength(1); + expect(outside.rows[0]?.key).toContain("claude:outside"); + }); +}); diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts new file mode 100644 index 000000000000..36f6e2935fd6 --- /dev/null +++ b/apps/server/src/usage/usageThreads.ts @@ -0,0 +1,619 @@ +/** + * Pure grouping behind the thread drill-down: transcript records fold into + * per-session groups, and session groups fold into thread rows using the + * attribution the caller extracted from its own state (resume cursors and + * dedicated worktrees). + * + * Pure, so grouping, de-duplication and attribution are testable without the + * filesystem or the database. + * + * @module usageThreads + */ +import type { + ProjectId, + ThreadId, + UsageAgentRow, + UsageProviderKind, + UsageThreadDayCost, + UsageThreadRow, + UsageTokenTotals, +} from "@t3tools/contracts"; +import { UsageDay } from "@t3tools/contracts"; + +import { makeDayFormatter, type ProjectAttribution } from "./usageAggregation.ts"; +import { normalizeUsagePath } from "./usagePaths.ts"; +import { cacheWriteUsd, priceUsage, usageComponentCosts, type RateTable } from "./usagePricing.ts"; +import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; + +const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000; + +/** How the caller identifies the transcript a record came from. */ +export interface ThreadRecordContext { + /** `provider:sessionId`, or a file-derived fallback when the id is empty. */ + readonly sessionKey: string; + /** Claude subagent id when the record came from a `subagents/agent-*.jsonl` file. */ + readonly agentId: string | null; +} + +interface MutableComponentCosts { + cacheWriteUsd: number; + cacheReadUsd: number; + freshUsd: number; +} + +interface MutableAgentSlice { + totals: UsageTokenTotals; + costUsd: number; + cacheWriteUsd: number; + cacheWriteComplete: boolean; +} + +export interface SessionUsageGroup { + readonly sessionKey: string; + readonly provider: UsageProviderKind; + readonly sessionId: string; + readonly cwd: string; + readonly projectId: ProjectId | null; + readonly projectKey: string | null; + readonly projectAttribution: "project" | "outside" | "unknown"; + readonly project: string; + readonly totals: UsageTokenTotals; + readonly costUsd: number; + readonly cacheWriteUsd: number; + readonly cacheWriteComplete: boolean; + readonly daily: ReadonlyMap; + readonly agents: ReadonlyMap; +} + +interface MutableSessionGroup { + sessionKey: string; + provider: UsageProviderKind; + sessionId: string; + cwd: string; + projectId: ProjectId | null; + projectKey: string | null; + projectAttribution: "project" | "outside" | "unknown"; + project: string; + totals: UsageTokenTotals; + costUsd: number; + cacheWriteUsd: number; + cacheWriteComplete: boolean; + daily: Map; + agents: Map; +} + +export interface ThreadUsageOptions { + readonly timeZone: string; + readonly sinceDay: string; + readonly untilDay: string; + readonly sinceTimeMs?: number; + readonly untilTimeMs?: number; + readonly rates: RateTable; + readonly priceOverrides?: RateTable; + /** Same stable project resolver the summary uses. */ + readonly resolveProject?: (cwd: string) => ProjectAttribution | null; +} + +/** + * Folds records into per-session groups with per-day component costs. + * + * De-duplication is global across the scan with the same semantics as the + * summary aggregator, so a thread's number here always reconciles with its + * share of the summary. + */ +export class ThreadUsageAccumulator { + readonly #recordsByKey = new Map< + string, + { readonly record: UsageRecord; readonly context: ThreadRecordContext } + >(); + readonly #unkeyedRecords: { + readonly record: UsageRecord; + readonly context: ThreadRecordContext; + }[] = []; + readonly #toDay: (timestampMs: number) => string; + readonly #options: ThreadUsageOptions; + + constructor(options: ThreadUsageOptions) { + this.#options = options; + this.#toDay = makeDayFormatter(options.timeZone); + } + + add(record: UsageRecord, context: ThreadRecordContext): boolean { + const inWindow = this.#isInWindow(record); + if (record.dedupeKey === null) { + this.#unkeyedRecords.push({ record, context }); + return inWindow; + } + if (this.#recordsByKey.has(record.dedupeKey)) { + this.#recordsByKey.set(record.dedupeKey, { record, context }); + return inWindow; + } + this.#recordsByKey.set(record.dedupeKey, { record, context }); + return inWindow; + } + + #isInWindow(record: UsageRecord): boolean { + if ( + !Number.isFinite(record.timestampMs) || + Math.abs(record.timestampMs) > MAX_DATE_TIMESTAMP_MS + ) { + return false; + } + if ( + this.#options.sinceTimeMs !== undefined && + this.#options.untilTimeMs !== undefined && + (record.timestampMs < this.#options.sinceTimeMs || + record.timestampMs >= this.#options.untilTimeMs) + ) { + return false; + } + const day = this.#toDay(record.timestampMs); + if ( + (this.#options.sinceTimeMs === undefined || this.#options.untilTimeMs === undefined) && + (day < this.#options.sinceDay || day > this.#options.untilDay) + ) + return false; + return true; + } + + #foldRecord( + record: UsageRecord, + context: ThreadRecordContext, + groups: Map, + ): void { + const day = this.#toDay(record.timestampMs); + const resolvedProject = this.#options.resolveProject?.(record.cwd) ?? null; + const projectAttribution = + resolvedProject !== null + ? "project" + : this.#options.resolveProject === undefined || record.cwd.length === 0 + ? "unknown" + : "outside"; + const projectKey = + resolvedProject === null ? null : `id:${resolvedProject.projectId.replaceAll("\u0000", "")}`; + const groupKey = JSON.stringify([context.sessionKey, record.cwd]); + let group = groups.get(groupKey); + if (group === undefined) { + group = { + sessionKey: context.sessionKey, + provider: record.provider, + sessionId: record.sessionId, + cwd: record.cwd, + projectId: resolvedProject?.projectId ?? null, + projectKey, + projectAttribution, + project: resolvedProject?.title ?? "", + totals: EMPTY_TOTALS, + costUsd: 0, + cacheWriteUsd: 0, + cacheWriteComplete: true, + daily: new Map(), + agents: new Map(), + }; + groups.set(groupKey, group); + } + + const priced = priceUsage( + this.#options.rates, + record.model, + record.totals, + record.reportedCostUsd, + this.#options.priceOverrides, + ); + const cacheWriteComplete = + priced.costSource === "modelPriced" || record.totals.cacheCreationTokens === 0; + const writeUsd = + priced.costSource === "modelPriced" + ? cacheWriteUsd( + this.#options.rates, + record.model, + record.totals, + this.#options.priceOverrides, + ) + : 0; + group.totals = addTotals(group.totals, record.totals); + group.costUsd += priced.costUsd; + group.cacheWriteUsd += writeUsd; + group.cacheWriteComplete &&= cacheWriteComplete; + + if (priced.costSource === "modelPriced") { + const components = usageComponentCosts( + this.#options.rates, + record.model, + record.totals, + this.#options.priceOverrides, + ); + let dayEntry = group.daily.get(day); + if (dayEntry === undefined) { + dayEntry = { cacheWriteUsd: 0, cacheReadUsd: 0, freshUsd: 0 }; + group.daily.set(day, dayEntry); + } + dayEntry.cacheWriteUsd += components.cacheWriteUsd; + dayEntry.cacheReadUsd += components.cacheReadUsd; + dayEntry.freshUsd += components.freshUsd; + } + + if (context.agentId !== null) { + let agent = group.agents.get(context.agentId); + if (agent === undefined) { + agent = { + totals: EMPTY_TOTALS, + costUsd: 0, + cacheWriteUsd: 0, + cacheWriteComplete: true, + }; + group.agents.set(context.agentId, agent); + } + agent.totals = addTotals(agent.totals, record.totals); + agent.costUsd += priced.costUsd; + agent.cacheWriteUsd += writeUsd; + agent.cacheWriteComplete &&= cacheWriteComplete; + } + } + + finish(): readonly SessionUsageGroup[] { + const groups = new Map(); + for (const { record, context } of this.#unkeyedRecords) { + if (this.#isInWindow(record)) this.#foldRecord(record, context, groups); + } + for (const { record, context } of this.#recordsByKey.values()) { + if (this.#isInWindow(record)) this.#foldRecord(record, context, groups); + } + return [...groups.values()].map((group) => ({ + sessionKey: group.sessionKey, + provider: group.provider, + sessionId: group.sessionId, + cwd: group.cwd, + projectId: group.projectId, + projectKey: group.projectKey, + projectAttribution: group.projectAttribution, + project: group.project, + totals: group.totals, + costUsd: group.costUsd, + cacheWriteUsd: group.cacheWriteUsd, + cacheWriteComplete: group.cacheWriteComplete, + daily: group.daily, + agents: group.agents, + })); + } +} + +/** A thread a session can attribute to, from the environment's own state. */ +export interface ThreadRef { + readonly threadId: ThreadId; + readonly title: string; +} + +export interface ThreadAttribution { + /** `provider:sessionId` of each thread's current session, from resume cursors. */ + readonly sessionToThread: ReadonlyMap; + /** + * Dedicated worktree path → thread. Only paths claimed by exactly one + * thread belong here: a shared root would stamp one thread's identity onto + * every unrelated session running there. + */ + readonly worktreeToThread: ReadonlyMap; +} + +export interface FoldThreadRowsOptions { + /** A title, `null` for outside-projects sessions, `undefined` for no filter. */ + readonly projectFilter?: string | null | undefined; + /** Return only sessions attributed to this T3 thread. */ + readonly threadFilter?: ThreadId | undefined; + /** Maximum returned rows, including grouped remainders. */ + readonly cap: number; +} + +interface MutableThreadRow { + threadId: ThreadId | null; + title: string | null; + provider: UsageProviderKind; + project: string; + projectId: ProjectId | null; + projectKey: string | null; + cwd: string; + totals: UsageTokenTotals; + costUsd: number; + sessionKeys: Set; + cacheWriteUsd: number; + cacheWriteComplete: boolean; + groupedRows: number; + daily: Map; + agents: Map; + /** Session whose transcript can supply a title when no thread claims the row. */ + titleSessionKey: string; +} + +export interface FoldedThreadRows { + readonly rows: readonly (Omit & { + readonly title: string | null; + readonly titleSessionKey: string; + })[]; + readonly truncatedRows: number; +} + +function addDailyCosts( + target: Map, + source: ReadonlyMap, +): void { + for (const [day, components] of source) { + let dayEntry = target.get(day); + if (dayEntry === undefined) { + dayEntry = { cacheWriteUsd: 0, cacheReadUsd: 0, freshUsd: 0 }; + target.set(day, dayEntry); + } + dayEntry.cacheWriteUsd += components.cacheWriteUsd; + dayEntry.cacheReadUsd += components.cacheReadUsd; + dayEntry.freshUsd += components.freshUsd; + } +} + +function worktreeThreadForCwd( + cwd: string, + worktreeToThread: ReadonlyMap, +): ThreadRef | undefined { + const normalizedCwd = normalizeUsagePath(cwd); + let deepest: { readonly pathLength: number; readonly ref: ThreadRef } | undefined; + for (const [worktree, ref] of worktreeToThread) { + const normalizedWorktree = normalizeUsagePath(worktree); + const prefix = normalizedWorktree.endsWith("/") ? normalizedWorktree : `${normalizedWorktree}/`; + if (normalizedCwd !== normalizedWorktree && !normalizedCwd.startsWith(prefix)) continue; + if (deepest === undefined || normalizedWorktree.length > deepest.pathLength) { + deepest = { pathLength: normalizedWorktree.length, ref }; + } + } + return deepest?.ref; +} + +function toAgentRow([agentId, slice]: readonly [string, MutableAgentSlice]): UsageAgentRow { + return { + agentId, + totals: slice.totals, + costUsd: slice.costUsd, + cacheWriteUsd: slice.cacheWriteComplete ? slice.cacheWriteUsd : null, + }; +} + +function boundedAgentRows( + agents: ReadonlyMap, + cap: number, +): readonly UsageAgentRow[] { + const sorted = [...agents.entries()].sort( + (a, b) => + b[1].costUsd - a[1].costUsd || + totalOf(b[1].totals) - totalOf(a[1].totals) || + a[0].localeCompare(b[0]), + ); + if (sorted.length <= cap) return sorted.map(toAgentRow); + + const kept = sorted.slice(0, Math.max(0, cap - 1)); + const omitted = sorted.slice(kept.length); + const overflow = omitted.reduce( + (combined, [, slice]) => ({ + totals: addTotals(combined.totals, slice.totals), + costUsd: combined.costUsd + slice.costUsd, + cacheWriteUsd: combined.cacheWriteUsd + slice.cacheWriteUsd, + cacheWriteComplete: combined.cacheWriteComplete && slice.cacheWriteComplete, + }), + { + totals: EMPTY_TOTALS, + costUsd: 0, + cacheWriteUsd: 0, + cacheWriteComplete: true, + }, + ); + return [...kept.map(toAgentRow), toAgentRow([`Other subagents (${omitted.length})`, overflow])]; +} + +/** + * Groups sessions into thread rows: resume-cursor matches first, then unique + * worktrees, else one row per session. Rows sort by cost. Rows beyond the cap + * fold into provider/project-specific remainders so the returned hierarchy + * still reconciles. A `null` title marks retained rows whose name must come + * from the transcript. + */ +export function foldThreadRows( + groups: readonly SessionUsageGroup[], + attribution: ThreadAttribution, + options: FoldThreadRowsOptions, +): FoldedThreadRows { + const byKey = new Map(); + + for (const group of groups) { + if ( + options.projectFilter !== undefined && + (options.projectFilter === null + ? group.projectAttribution !== "outside" + : group.projectKey !== options.projectFilter) + ) + continue; + + const ref = + attribution.sessionToThread.get(group.sessionKey) ?? + (group.cwd.length > 0 + ? worktreeThreadForCwd(group.cwd, attribution.worktreeToThread) + : undefined); + if (options.threadFilter !== undefined && ref?.threadId !== options.threadFilter) continue; + const rowKey = + ref === undefined + ? JSON.stringify(["session", group.provider, group.projectKey, group.sessionKey]) + : JSON.stringify(["thread", group.provider, group.projectKey, ref.threadId]); + + let row = byKey.get(rowKey); + if (row === undefined) { + row = { + threadId: ref?.threadId ?? null, + title: ref?.title ?? null, + provider: group.provider, + project: group.project, + projectId: group.projectId, + projectKey: group.projectKey, + cwd: group.cwd, + totals: EMPTY_TOTALS, + costUsd: 0, + sessionKeys: new Set(), + cacheWriteUsd: 0, + cacheWriteComplete: true, + groupedRows: 0, + daily: new Map(), + agents: new Map(), + titleSessionKey: group.sessionKey, + }; + byKey.set(rowKey, row); + } + + row.totals = addTotals(row.totals, group.totals); + row.costUsd += group.costUsd; + row.sessionKeys.add(group.sessionKey); + row.cacheWriteUsd += group.cacheWriteUsd; + row.cacheWriteComplete &&= group.cacheWriteComplete; + addDailyCosts(row.daily, group.daily); + for (const [agentId, slice] of group.agents) { + let agent = row.agents.get(agentId); + if (agent === undefined) { + agent = { + totals: EMPTY_TOTALS, + costUsd: 0, + cacheWriteUsd: 0, + cacheWriteComplete: true, + }; + row.agents.set(agentId, agent); + } + agent.totals = addTotals(agent.totals, slice.totals); + agent.costUsd += slice.costUsd; + agent.cacheWriteUsd += slice.cacheWriteUsd; + agent.cacheWriteComplete &&= slice.cacheWriteComplete; + } + } + + const sorted = [...byKey.entries()].sort( + (a, b) => + b[1].costUsd - a[1].costUsd || + totalOf(b[1].totals) - totalOf(a[1].totals) || + a[0].localeCompare(b[0]), + ); + let keptCount = Math.min(sorted.length, options.cap); + const projectScopeCount = (rows: typeof sorted): number => + new Set(rows.map(([, row]) => JSON.stringify([row.provider, row.projectKey]))).size; + while (keptCount > 0 && keptCount + projectScopeCount(sorted.slice(keptCount)) > options.cap) { + keptCount -= 1; + } + + let kept = sorted.slice(0, keptCount); + let omitted = sorted.slice(keptCount); + let remainderScope: "project" | "provider" = "project"; + if (projectScopeCount(omitted) > options.cap) { + // More project scopes than the response can represent. Collapse all named + // rows and preserve provider totals in provider-wide overflow rows. + kept = []; + omitted = sorted; + remainderScope = "provider"; + const providerCount = new Set(omitted.map(([, row]) => row.provider)).size; + if (providerCount > options.cap) { + throw new RangeError("Thread row cap must fit one remainder per provider"); + } + } + + const remainders = new Map(); + for (const [, omittedRow] of omitted) { + const scopeKey = JSON.stringify([ + omittedRow.provider, + remainderScope === "project" ? omittedRow.projectKey : null, + ]); + let remainder = remainders.get(scopeKey); + if (remainder === undefined) { + const key = `remainder:${scopeKey}`; + remainder = { + threadId: null, + title: null, + provider: omittedRow.provider, + project: remainderScope === "project" ? omittedRow.project : "", + projectId: remainderScope === "project" ? omittedRow.projectId : null, + projectKey: remainderScope === "project" ? omittedRow.projectKey : null, + cwd: "", + totals: EMPTY_TOTALS, + costUsd: 0, + sessionKeys: new Set(), + cacheWriteUsd: 0, + cacheWriteComplete: true, + groupedRows: 0, + daily: new Map(), + agents: new Map(), + titleSessionKey: key, + }; + remainders.set(scopeKey, remainder); + } + remainder.groupedRows += 1; + remainder.totals = addTotals(remainder.totals, omittedRow.totals); + remainder.costUsd += omittedRow.costUsd; + for (const sessionKey of omittedRow.sessionKeys) remainder.sessionKeys.add(sessionKey); + remainder.cacheWriteUsd += omittedRow.cacheWriteUsd; + remainder.cacheWriteComplete &&= omittedRow.cacheWriteComplete; + addDailyCosts(remainder.daily, omittedRow.daily); + for (const [agentId, slice] of omittedRow.agents) { + let agent = remainder.agents.get(agentId); + if (agent === undefined) { + agent = { + totals: EMPTY_TOTALS, + costUsd: 0, + cacheWriteUsd: 0, + cacheWriteComplete: true, + }; + remainder.agents.set(agentId, agent); + } + agent.totals = addTotals(agent.totals, slice.totals); + agent.costUsd += slice.costUsd; + agent.cacheWriteUsd += slice.cacheWriteUsd; + agent.cacheWriteComplete &&= slice.cacheWriteComplete; + } + } + + const displayed = [ + ...kept, + ...[...remainders.entries()].map(([scopeKey, remainder]) => { + remainder.title = `Other threads (${remainder.groupedRows})`; + return [`remainder:${scopeKey}`, remainder] as const; + }), + ].sort( + (a, b) => + b[1].costUsd - a[1].costUsd || + totalOf(b[1].totals) - totalOf(a[1].totals) || + a[0].localeCompare(b[0]), + ); + + return { + rows: displayed.map(([key, row]) => ({ + key, + threadId: row.threadId, + title: row.title, + titleSessionKey: row.titleSessionKey, + provider: row.provider, + ...(row.projectId === null ? {} : { projectId: row.projectId }), + ...(row.project === "" ? {} : { project: row.project }), + totals: row.totals, + costUsd: row.costUsd, + cacheWriteUsd: row.cacheWriteComplete ? row.cacheWriteUsd : null, + sessions: row.sessionKeys.size, + ...(row.groupedRows === 0 ? {} : { groupedRows: row.groupedRows }), + agents: boundedAgentRows(row.agents, options.cap), + daily: [...row.daily.entries()] + .map(([day, components]) => ({ + day: day as UsageDay, + cacheWriteUsd: components.cacheWriteUsd, + cacheReadUsd: components.cacheReadUsd, + freshUsd: components.freshUsd, + })) + .sort((a, b) => a.day.localeCompare(b.day)) satisfies UsageThreadDayCost[], + })), + truncatedRows: omitted.length, + }; +} + +function totalOf(totals: UsageTokenTotals): number { + return ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + totals.outputTokens + ); +} diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 5feb68b2ff58..194af67edc74 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -7,7 +7,11 @@ import * as NodePath from "node:path"; import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; -import { readTranscriptRecords } from "./usageTranscriptReader.ts"; +import { + readCodexTranscriptIdentity, + readTranscriptRecords, + readTranscriptTitle, +} from "./usageTranscriptReader.ts"; let dir: string; @@ -41,6 +45,31 @@ function codexMetaLine(): string { })}\n`; } +describe("readCodexTranscriptIdentity", () => { + it("reads session and cwd from the bounded rollout preamble", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile( + path, + `${JSON.stringify({ + type: "session_meta", + payload: { id: "codex-session-1", cwd: "/work/app/.wt/thread-1" }, + })}\n${"not valid usage json\n".repeat(1_000)}`, + ); + + assert.deepStrictEqual(await readCodexTranscriptIdentity(path), { + sessionId: "codex-session-1", + cwd: "/work/app/.wt/thread-1", + }); + }); + + it("stops after the bounded preamble when metadata is absent", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile(path, `${JSON.stringify({ type: "other" })}\n`.repeat(101)); + + assert.isNull(await readCodexTranscriptIdentity(path)); + }); +}); + function codexModelLine(model: string): string { return `${JSON.stringify({ type: "turn_context", @@ -208,3 +237,91 @@ describe("readTranscriptRecords resume", () => { assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); }); }); + +describe("readTranscriptTitle", () => { + it("keeps a real prompt that begins with an angle bracket", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + JSON.stringify({ type: "user", message: { content: "<3 ship this today" } }), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), "<3 ship this today"); + }); + + it("skips a known injected preamble and reads the next user prompt", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + [ + { + type: "user", + message: { content: "generated context" }, + }, + { type: "user", message: { content: "Fix the real bug" } }, + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), "Fix the real bug"); + }); + + it("skips a user shell command wrapper", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + [ + { + type: "user", + message: { content: "git status" }, + }, + { type: "user", message: { content: "Explain the failing check" } }, + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), "Explain the failing check"); + }); + + it("uses the child prompt instead of copied parent history for a forked Codex rollout", async () => { + const file = NodePath.join(dir, "session.jsonl"); + const message = (timestamp: string, text: string) => ({ + type: "event_msg", + timestamp, + payload: { type: "message", role: "user", content: [{ type: "input_text", text }] }, + }); + await NodeFSP.writeFile( + file, + [ + { + type: "session_meta", + timestamp: "2026-08-01T05:00:00.000Z", + payload: { type: "session_meta", id: "child", forked_from_id: "parent" }, + }, + message("2026-08-01T05:00:00.600Z", "First copied parent prompt"), + message("2026-08-01T05:00:01.100Z", "Second copied parent prompt"), + message("2026-08-01T05:00:02.500Z", "Investigate the child task"), + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + + assert.strictEqual(await readTranscriptTitle(file, "codex"), "Investigate the child task"); + }); + + it("truncates titles without splitting a Unicode code point", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + JSON.stringify({ type: "user", message: { content: `${"a".repeat(78)}🙂more` } }), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), `${"a".repeat(78)}🙂…`); + }); + + it("returns null when the title stream cannot be read", async () => { + assert.isNull(await readTranscriptTitle(NodePath.join(dir, "missing.jsonl"), "claude")); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 9e5ab6e0c9e0..454311b340ef 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -15,6 +15,7 @@ * * @module usageTranscriptReader */ +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; @@ -23,7 +24,7 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { initialCodexScanState, mightCarryUsage, - parseClaudeLine, + parseClaudeLineRecords, parseCodexLine, parseGrokLine, type CodexScanState, @@ -36,6 +37,11 @@ export interface TranscriptFile { readonly mtimeMs: number; } +export interface TranscriptFileIdentity { + readonly sessionId: string; + readonly cwd: string; +} + /** * Where a parse stopped, with enough state to continue from there. * @@ -100,7 +106,10 @@ function fnv1a(buffer: Buffer): number { export async function listTranscriptFiles( root: string, sinceMs: number, - options?: { readonly fileName?: string }, + options?: { + readonly fileName?: string; + readonly onFile?: (path: string) => void; + }, ): Promise { const found: TranscriptFile[] = []; const fileName = options?.fileName; @@ -123,6 +132,7 @@ export async function listTranscriptFiles( } else if (!entry.name.endsWith(".jsonl")) { continue; } + options?.onFile?.(child); try { const stats = await NodeFSP.stat(child); if (stats.mtimeMs >= sinceMs) { @@ -138,6 +148,59 @@ export async function listTranscriptFiles( return found; } +const IDENTITY_MAX_BYTES = 256 * 1024; +const IDENTITY_MAX_LINES = 100; + +/** Reads only the bounded Codex preamble needed to identify a rollout. */ +export async function readCodexTranscriptIdentity( + filePath: string, +): Promise { + let stream: NodeFS.ReadStream | null = null; + try { + stream = NodeFS.createReadStream(filePath, { + encoding: "utf8", + highWaterMark: 16 * 1024, + }); + let pending = ""; + let bytesRead = 0; + let linesRead = 0; + for await (const chunk of stream) { + const text = String(chunk); + bytesRead += Buffer.byteLength(text); + if (bytesRead > IDENTITY_MAX_BYTES) return null; + pending += text; + for (;;) { + const newline = pending.indexOf("\n"); + if (newline === -1) break; + const line = pending.slice(0, newline).replace(/\r$/, ""); + pending = pending.slice(newline + 1); + linesRead += 1; + if (linesRead > IDENTITY_MAX_LINES) return null; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (typeof parsed !== "object" || parsed === null) continue; + const record = parsed as Record; + if (record["type"] !== "session_meta") continue; + const payload = record["payload"]; + if (typeof payload !== "object" || payload === null) return null; + const meta = payload as Record; + const sessionId = meta["id"] ?? meta["session_id"]; + return { + sessionId: typeof sessionId === "string" ? sessionId : "", + cwd: typeof meta["cwd"] === "string" ? meta["cwd"] : "", + }; + } + } + return null; + } finally { + stream?.destroy(); + } +} + /** * Filesystem identity of a directory, as `device:inode`. * @@ -235,8 +298,7 @@ export async function readTranscriptRecords( for (const grokRecord of parseGrokLine(line)) out.push(grokRecord); return; } - const record = parseClaudeLine(line); - if (record !== null) out.push(record); + for (const record of parseClaudeLineRecords(line)) out.push(record); }; const toLineString = (lineBuffer: Buffer): string => { @@ -311,3 +373,150 @@ export async function readTranscriptRecords( await handle.close().catch(() => undefined); } } + +/** Prefixes that mark an injected preamble, not something the user typed. */ +const NOT_TITLE_PREFIXES = [ + "", + "", + "", + "", + "", + "", + "", + "# AGENTS.md instructions", + "Caveat: the messages below", +]; + +const TITLE_MAX_LENGTH = 80; +const TITLE_MAX_LINES = 400; +const TITLE_MAX_BYTES = 1024 * 1024; + +function cleanTitle(text: unknown): string | null { + if (typeof text !== "string") return null; + const collapsed = text.split(/\s+/).join(" ").trim(); + if (collapsed.length === 0) return null; + if (NOT_TITLE_PREFIXES.some((prefix) => collapsed.startsWith(prefix))) return null; + const characters = Array.from(collapsed); + return characters.length > TITLE_MAX_LENGTH + ? `${characters.slice(0, TITLE_MAX_LENGTH - 1).join("")}\u2026` + : collapsed; +} + +function claudeTitleFromLine(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + if (record["type"] !== "user") return null; + const message = record["message"]; + if (typeof message !== "object" || message === null) return null; + const content = (message as Record)["content"]; + if (typeof content === "string") return cleanTitle(content); + if (!Array.isArray(content)) return null; + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const entry = block as Record; + if (entry["type"] !== "text") continue; + const title = cleanTitle(entry["text"]); + if (title !== null) return title; + } + return null; +} + +function codexTitleFromLine( + line: string, +): { readonly title: string; readonly timestampMs: number | null } | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const payload = (parsed as Record)["payload"]; + if (typeof payload !== "object" || payload === null) return null; + const record = payload as Record; + if (record["type"] !== "message" || record["role"] !== "user") return null; + const content = record["content"]; + if (!Array.isArray(content)) return null; + const timestamp = (parsed as Record)["timestamp"]; + const parsedTimestamp = typeof timestamp === "string" ? Date.parse(timestamp) : Number.NaN; + const timestampMs = Number.isNaN(parsedTimestamp) ? null : parsedTimestamp; + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const title = cleanTitle((block as Record)["text"]); + if (title !== null) return { title, timestampMs }; + } + return null; +} + +/** + * First thing the user actually typed in a session, as a display title. + * + * Only called for the handful of unattributed rows that survived the response + * cap, so a second bounded read per row is fine. Returns null when the file + * cannot be read, holds no user text (Grok logs carry none we trust), or only + * injected preambles appear early on. + */ +export async function readTranscriptTitle( + filePath: string, + provider: UsageProviderKind, +): Promise { + if (provider === "grok") return null; + const codexState = provider === "codex" ? initialCodexScanState() : null; + let stream: NodeFS.ReadStream | null = null; + try { + stream = NodeFS.createReadStream(filePath, { encoding: "utf8" }); + let pending = ""; + let seen = 0; + let bytesRead = 0; + for await (const chunk of stream) { + const text = String(chunk); + bytesRead += Buffer.byteLength(text); + pending += text; + for (;;) { + const newline = pending.indexOf("\n"); + if (newline === -1) break; + const line = pending.slice(0, newline).replace(/\r$/, ""); + pending = pending.slice(newline + 1); + seen += 1; + if (seen > TITLE_MAX_LINES) return null; + if (provider === "claude") { + if (!line.includes('"user"')) continue; + const title = claudeTitleFromLine(line); + if (title !== null) return title; + continue; + } + const title = codexTitleFromLine(line); + parseCodexLine(line, codexState!); + if (title === null) continue; + if (!codexState!.suppressingForkCopies) return title.title; + if (title.timestampMs !== null) { + if (title.timestampMs - codexState!.forkCopyAnchorMs >= 1000) return title.title; + codexState!.forkCopyAnchorMs = title.timestampMs; + } + } + if (bytesRead >= TITLE_MAX_BYTES) return null; + } + if (pending.length > 0 && seen < TITLE_MAX_LINES) { + if (provider === "claude") return claudeTitleFromLine(pending); + const title = codexTitleFromLine(pending); + parseCodexLine(pending, codexState!); + if (title === null) return null; + if (!codexState!.suppressingForkCopies) return title.title; + if (title.timestampMs === null) return null; + if (title.timestampMs - codexState!.forkCopyAnchorMs >= 1000) return title.title; + codexState!.forkCopyAnchorMs = title.timestampMs; + return null; + } + } catch { + return null; + } finally { + stream?.destroy(); + } + return null; +} diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index b09db613ed85..428e27118b97 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -4,6 +4,7 @@ import { GROK_COST_USD_TICKS_PER_DOLLAR, initialCodexScanState, parseClaudeLine, + parseClaudeLineRecords, parseCodexLine, parseGrokLine, totalTokens, @@ -15,12 +16,14 @@ function claudeLine(overrides: { contentType: string; model?: string; outputTokens?: number; + requestId?: string; }): string { return JSON.stringify({ type: "assistant", timestamp: "2026-08-07T04:05:13.944Z", sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", cwd: "/home/theo/project", + ...(overrides.requestId === undefined ? {} : { requestId: overrides.requestId }), message: { id: overrides.messageId, role: "assistant", @@ -51,6 +54,7 @@ describe("parseClaudeLine", () => { reasoningTokens: 0, }); expect(record?.dedupeKey).toBe("msg_1:"); + expect(record?.cwd).toBe("/home/theo/project"); }); it("gives every content block of one message the same dedupe key", () => { @@ -63,6 +67,187 @@ describe("parseClaudeLine", () => { expect(text?.totals).toEqual(toolUse?.totals); }); + it("keeps a shared message id when the request id differs", () => { + const first = parseClaudeLine( + claudeLine({ messageId: "msg_shared", requestId: "req_1", contentType: "text" }), + ); + const second = parseClaudeLine( + claudeLine({ messageId: "msg_shared", requestId: "req_2", contentType: "text" }), + ); + + expect(first?.dedupeKey).toBe("msg_shared:req_1"); + expect(second?.dedupeKey).toBe("msg_shared:req_2"); + }); + + it("expands fallback iterations under their own models and TTL counters", () => { + const records = parseClaudeLineRecords( + JSON.stringify({ + type: "assistant", + timestamp: "2026-08-18T01:13:44.675Z", + requestId: "req_fallback", + sessionId: "session-fallback", + cwd: "/work/app", + costUSD: 0.123, + message: { + id: "msg_fallback", + model: "claude-opus-5", + usage: { + output_tokens: 300, + output_tokens_details: { thinking_tokens: 125 }, + iterations: [ + { + type: "message", + model: "claude-fable-5", + input_tokens: 2, + cache_read_input_tokens: 10, + cache_creation_input_tokens: 20, + cache_creation: { + ephemeral_5m_input_tokens: 20, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 100, + }, + { + type: "fallback_message", + model: "claude-opus-5", + input_tokens: 3, + cache_read_input_tokens: 11, + cache_creation_input_tokens: 40, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 40, + }, + output_tokens: 300, + }, + ], + }, + }, + }), + ); + + expect(records).toHaveLength(2); + expect(records.map((record) => record.model)).toEqual(["claude-fable-5", "claude-opus-5"]); + expect(records[0]?.totals).toMatchObject({ + cacheCreationTokens: 20, + cacheCreation5mTokens: 20, + reasoningTokens: 0, + }); + expect(records[1]?.totals).toMatchObject({ + cacheCreationTokens: 40, + cacheCreation1hTokens: 40, + reasoningTokens: 125, + }); + expect(records.map((record) => record.dedupeKey)).toEqual([ + "msg_fallback:req_fallback:0", + "msg_fallback:req_fallback", + ]); + expect(records.map((record) => record.reportedCostUsd)).toEqual([null, 0.123]); + }); + + it("preserves an aggregate cache creation count when TTL details are partial", () => { + const records = parseClaudeLineRecords( + JSON.stringify({ + type: "assistant", + timestamp: "2026-09-03T01:13:44.675Z", + requestId: "req_partial_ttl", + sessionId: "session-partial-ttl", + cwd: "/work/app", + message: { + id: "msg_partial_ttl", + model: "claude-opus-5", + usage: { + input_tokens: 2, + cache_read_input_tokens: 10, + cache_creation_input_tokens: 60, + cache_creation: { + ephemeral_5m_input_tokens: 20, + ephemeral_1h_input_tokens: 10, + }, + output_tokens: 12, + }, + }, + }), + ); + + expect(records).toHaveLength(1); + expect(records[0]?.totals).toMatchObject({ + cacheCreationTokens: 60, + cacheCreation5mTokens: 20, + cacheCreation1hTokens: 10, + }); + }); + + it("uses the serving model when an iteration omits its model", () => { + const records = parseClaudeLineRecords( + JSON.stringify({ + type: "assistant", + timestamp: "2026-09-03T01:13:44.675Z", + requestId: "req_model_omitted", + sessionId: "session-model-omitted", + cwd: "/work/app", + message: { + id: "msg_model_omitted", + model: "claude-fable-5-1", + usage: { + output_tokens: 12, + iterations: [ + { + type: "message", + input_tokens: 2, + cache_read_input_tokens: 100, + cache_creation_input_tokens: 20, + output_tokens: 12, + }, + ], + }, + }, + }), + ); + + expect(records).toHaveLength(1); + expect(records[0]?.model).toBe("claude-fable-5-1"); + expect(records[0]?.totals).toMatchObject({ + uncachedInputTokens: 2, + cachedInputTokens: 100, + cacheCreationTokens: 20, + outputTokens: 12, + }); + }); + + it("replaces a progressive Claude snapshot with its final serving iteration", () => { + const partial = parseClaudeLineRecords( + claudeLine({ + messageId: "msg_progressive", + requestId: "req_progressive", + contentType: "text", + }), + ); + const complete = parseClaudeLineRecords( + JSON.stringify({ + type: "assistant", + timestamp: "2026-08-18T01:13:44.675Z", + requestId: "req_progressive", + message: { + id: "msg_progressive", + model: "claude-opus-5", + usage: { + output_tokens: 300, + iterations: [ + { model: "claude-fable-5", output_tokens: 100 }, + { model: "claude-opus-5", output_tokens: 300 }, + ], + }, + }, + }), + ); + + expect(partial[0]?.dedupeKey).toBe("msg_progressive:req_progressive"); + expect(complete.map((record) => record.dedupeKey)).toEqual([ + "msg_progressive:req_progressive:0", + "msg_progressive:req_progressive", + ]); + }); + it("ignores records that are not assistant messages", () => { expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); expect(parseClaudeLine("not json")).toBeNull(); @@ -73,7 +258,11 @@ describe("parseCodexLine", () => { const sessionMeta = JSON.stringify({ type: "session_meta", timestamp: "2026-08-01T05:17:41.289Z", - payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, + payload: { + type: "session_meta", + id: "019fbbc1-b12c-7360-a685-28c181f0025f", + cwd: "/home/theo/project", + }, }); const turnContext = JSON.stringify({ type: "turn_context", @@ -107,12 +296,34 @@ describe("parseCodexLine", () => { expect(record?.provider).toBe("codex"); expect(record?.model).toBe("gpt-5.6-sol"); expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f"); + expect(record?.cwd).toBe("/home/theo/project"); // Codex reports input_tokens inclusive of the cached portion. expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008); expect(record?.totals.cachedInputTokens).toBe(11008); expect(record?.totals.reasoningTokens).toBe(116); }); + it("attributes resumed usage to the latest turn working directory", () => { + const state = initialCodexScanState(); + parseCodexLine(sessionMeta, state); + parseCodexLine( + JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T05:17:42.694Z", + payload: { + type: "turn_context", + model: "gpt-5.6-sol", + cwd: "/home/theo/next-project", + }, + }), + state, + ); + + const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(record?.cwd).toBe("/home/theo/next-project"); + }); + it("skips a repeated token_count so deltas are not double counted", () => { const state = initialCodexScanState(); parseCodexLine(turnContext, state); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 2aea60709666..775c70e2ec48 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -13,6 +13,11 @@ export interface UsageRecord { readonly timestampMs: number; readonly model: string; readonly sessionId: string; + /** + * Working directory the session ran in, or `""` when the transcript does not + * record one (Grok). Drives project attribution at aggregation time. + */ + readonly cwd: string; readonly totals: UsageTokenTotals; readonly reportedCostUsd: number | null; /** @@ -41,10 +46,14 @@ function parseTimestampMs(value: unknown): number | null { } export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { + const cacheCreation5mTokens = (a.cacheCreation5mTokens ?? 0) + (b.cacheCreation5mTokens ?? 0); + const cacheCreation1hTokens = (a.cacheCreation1hTokens ?? 0) + (b.cacheCreation1hTokens ?? 0); return { uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens, cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens, + ...(cacheCreation5mTokens === 0 ? {} : { cacheCreation5mTokens }), + ...(cacheCreation1hTokens === 0 ? {} : { cacheCreation1hTokens }), outputTokens: a.outputTokens + b.outputTokens, reasoningTokens: a.reasoningTokens + b.reasoningTokens, }; @@ -91,36 +100,36 @@ export function grokCostTicksToUsd(ticks: unknown): number | null { /** * Parses one line of a Claude Code transcript. * - * T3 Code writes one record per assistant *content block*, and every one of - * those records repeats the same complete `usage` object for the parent - * message. Summing them overcounts by roughly 2.4x on a real workload, so the - * caller must drop repeats by `dedupeKey` and keep the first. + * Claude Code can write several snapshots for one assistant message. The last + * snapshot is the complete one, so callers replace an earlier record carrying + * the same `dedupeKey`. `usage.iterations` is expanded into one record per + * attempted model; the top-level usage is the serving iteration and must not + * be added again. */ -export function parseClaudeLine(line: string): UsageRecord | null { +export function parseClaudeLineRecords(line: string): readonly UsageRecord[] { let parsed: unknown; try { parsed = JSON.parse(line); } catch { - return null; + return []; } - if (typeof parsed !== "object" || parsed === null) return null; + if (typeof parsed !== "object" || parsed === null) return []; const record = parsed as Record; - if (record["type"] !== "assistant") return null; + if (record["type"] !== "assistant") return []; const message = record["message"]; - if (typeof message !== "object" || message === null) return null; + if (typeof message !== "object" || message === null) return []; const messageRecord = message as Record; const usage = messageRecord["usage"]; - if (typeof usage !== "object" || usage === null) return null; + if (typeof usage !== "object" || usage === null) return []; const usageRecord = usage as Record; const timestampMs = parseTimestampMs(record["timestamp"]); - if (timestampMs === null) return null; + if (timestampMs === null) return []; const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; - if (model.length === 0) return null; const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; @@ -129,24 +138,70 @@ export function parseClaudeLine(line: string): UsageRecord | null { const dedupeKey = messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; + const iterations = Array.isArray(usageRecord["iterations"]) + ? usageRecord["iterations"].filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const attempts = iterations.length > 0 ? iterations : [usageRecord]; + const topLevelThinking = + typeof usageRecord["output_tokens_details"] === "object" && + usageRecord["output_tokens_details"] !== null + ? int((usageRecord["output_tokens_details"] as Record)["thinking_tokens"]) + : 0; const cost = record["costUSD"]; - return { - provider: "claude", - timestampMs, - model, - sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", - totals: { - uncachedInputTokens: int(usageRecord["input_tokens"]), - cachedInputTokens: int(usageRecord["cache_read_input_tokens"]), - cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]), - outputTokens: int(usageRecord["output_tokens"]), - // Anthropic folds thinking tokens into output and does not break them out. - reasoningTokens: 0, - }, - reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, - dedupeKey, - }; + return attempts.flatMap((attempt, index) => { + const attemptModel = + typeof attempt["model"] === "string" && attempt["model"].length > 0 + ? attempt["model"] + : model; + if (attemptModel.length === 0) return []; + + const cacheCreation = + typeof attempt["cache_creation"] === "object" && attempt["cache_creation"] !== null + ? (attempt["cache_creation"] as Record) + : null; + const cacheCreation5mTokens = int(cacheCreation?.["ephemeral_5m_input_tokens"]); + const cacheCreation1hTokens = int(cacheCreation?.["ephemeral_1h_input_tokens"]); + const detailedCacheCreation = cacheCreation5mTokens + cacheCreation1hTokens; + const totalCacheCreation = int(attempt["cache_creation_input_tokens"]); + const outputTokens = int(attempt["output_tokens"]); + const isServingIteration = iterations.length === 0 || index === attempts.length - 1; + + return [ + { + provider: "claude" as const, + timestampMs, + model: attemptModel, + sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", + cwd: typeof record["cwd"] === "string" ? record["cwd"] : "", + totals: { + uncachedInputTokens: int(attempt["input_tokens"]), + cachedInputTokens: int(attempt["cache_read_input_tokens"]), + // Some Claude records classify only part of the aggregate cache + // creation count by TTL. Preserve the larger aggregate so the + // unclassified remainder is still accounted for at the base rate. + cacheCreationTokens: Math.max(totalCacheCreation, detailedCacheCreation), + ...(cacheCreation5mTokens === 0 ? {} : { cacheCreation5mTokens }), + ...(cacheCreation1hTokens === 0 ? {} : { cacheCreation1hTokens }), + outputTokens, + reasoningTokens: isServingIteration ? Math.min(outputTokens, topLevelThinking) : 0, + }, + reportedCostUsd: + isServingIteration && typeof cost === "number" && Number.isFinite(cost) ? cost : null, + dedupeKey: + dedupeKey === null || iterations.length === 0 || isServingIteration + ? dedupeKey + : `${dedupeKey}:${index}`, + }, + ]; + }); +} + +/** Compatibility helper for callers that only need a non-iterated line. */ +export function parseClaudeLine(line: string): UsageRecord | null { + return parseClaudeLineRecords(line)[0] ?? null; } /* -------------------------------------------------------------------------- */ @@ -163,6 +218,7 @@ export function parseClaudeLine(line: string): UsageRecord | null { export interface CodexScanState { model: string; sessionId: string; + cwd: string; lastUsageSignature: string | null; sawSessionMeta: boolean; /** While true, leading usage events are re-stamped copies of parent history. */ @@ -174,6 +230,7 @@ export function initialCodexScanState(): CodexScanState { return { model: "", sessionId: "", + cwd: "", lastUsageSignature: null, sawSessionMeta: false, suppressingForkCopies: false, @@ -233,6 +290,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord state.sawSessionMeta = true; const id = payloadRecord["id"] ?? payloadRecord["session_id"]; if (typeof id === "string") state.sessionId = id; + if (typeof payloadRecord["cwd"] === "string") state.cwd = payloadRecord["cwd"]; const metaTimestampMs = parseTimestampMs(record["timestamp"]); if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) { state.suppressingForkCopies = true; @@ -243,6 +301,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord if (record["type"] === "turn_context") { if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; + if (typeof payloadRecord["cwd"] === "string") state.cwd = payloadRecord["cwd"]; return null; } @@ -301,6 +360,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord timestampMs, model: state.model, sessionId: state.sessionId, + cwd: state.cwd, totals, // Codex does not report cost in the rollout. reportedCostUsd: null, @@ -431,6 +491,8 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { timestampMs, model: "grok", sessionId, + // Grok session logs record no working directory. + cwd: "", totals: grokTotalsToUsage(topLevel), reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), // No prompt id means we cannot tell two same-second updates apart. @@ -477,6 +539,7 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { timestampMs, model: entry.model, sessionId, + cwd: "", totals, reportedCostUsd, dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6261f7bc5287..d3b867c14c94 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2024,6 +2024,14 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetUsageThreadBreakdown]: (input) => + observeRpcEffect( + WS_METHODS.serverGetUsageThreadBreakdown, + usage.readThreadBreakdown(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverRefreshUsageRates]: (_input) => observeRpcEffect(WS_METHODS.serverRefreshUsageRates, usage.refreshRates, { "rpc.aggregate": "server", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 10898e311cef..e0111e506177 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -87,6 +87,7 @@ import { } from "../../promptStashStore"; import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; +import { ComposerThreadCostIndicator } from "./ThreadCostIndicator"; import { useComposerMenuState } from "./useComposerMenuState"; import { useComposerFocusState } from "./useComposerFocusState"; import { @@ -1043,6 +1044,12 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; + threadCost: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly createdAt: string; + readonly refreshKey: string | null; + } | null; activeContextWindow: ContextWindowSnapshot | null; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; @@ -1081,6 +1088,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compactDisabledReason={props.compactDisabledReason} /> ) : null} + {props.threadCost ? : null} ({ + Popover: ({ children }: { children: ReactNode }) => children, + PopoverPopup: ({ children }: { children: ReactNode }) => children, + PopoverTrigger: ({ openOnHover, render }: { openOnHover: boolean; render: ReactNode }) => ( +
{render}
+ ), +})); + +const cost: ThreadCostSnapshot = { + costUsd: 4.25, + cacheWriteUsd: 1.5, + cacheReadUsd: 2, + freshUsd: 0.75, + providerReportedUsd: 0, + uncachedInputTokens: 100, + cachedInputTokens: 200, + cacheCreationTokens: 300, + outputTokens: 400, +}; + +describe("ThreadCostIndicator", () => { + it("shows the current total and opens the component breakdown on hover", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-open-on-hover="true"'); + expect(markup).toContain('data-slot="button"'); + expect(markup).toContain('aria-label="Thread API cost $4.25"'); + expect(markup).toContain("Cache writes, estimated"); + expect(markup).toContain("Cache reads"); + expect(markup).toContain("Fresh input + output"); + expect(markup).toContain("300 tokens"); + }); + + it("keeps sub-cent thread costs readable", () => { + expect(formatThreadCostUsd(0)).toBe("$0.00"); + expect(formatThreadCostUsd(0.0042)).toBe("$0.0042"); + expect(formatThreadCostUsd(1.234)).toBe("$1.23"); + }); + + it("reports unavailable cache-write pricing and provider-reported cost", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Unavailable"); + expect(markup).toContain("Provider-reported remainder"); + }); +}); diff --git a/apps/web/src/components/chat/ThreadCostIndicator.tsx b/apps/web/src/components/chat/ThreadCostIndicator.tsx new file mode 100644 index 000000000000..dce6e2d45d2f --- /dev/null +++ b/apps/web/src/components/chat/ThreadCostIndicator.tsx @@ -0,0 +1,114 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { formatTokens } from "@t3tools/shared/usageFormat"; + +import { type ThreadCostSnapshot, useThreadCost } from "../../state/threadCost"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; + +const STANDARD_USD = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const SMALL_USD = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 4, +}); + +export function formatThreadCostUsd(value: number): string { + if (!Number.isFinite(value) || value <= 0) return STANDARD_USD.format(0); + return value < 0.01 ? SMALL_USD.format(value) : STANDARD_USD.format(value); +} + +function CostRow(props: { + readonly label: string; + readonly tokens?: number | undefined; + readonly costUsd: number | null; +}) { + return ( +
+ + {props.label} + {props.tokens === undefined ? null : ( + {formatTokens(props.tokens)} tokens + )} + + + {props.costUsd === null ? "Unavailable" : formatThreadCostUsd(props.costUsd)} + +
+ ); +} + +export function ThreadCostIndicator({ cost }: { readonly cost: ThreadCostSnapshot }) { + const formattedTotal = formatThreadCostUsd(cost.costUsd); + const freshTokens = cost.uncachedInputTokens + cost.outputTokens; + return ( + + event.preventDefault()} + > + {formattedTotal} + + } + /> + +
+
+ Thread API cost + + {formattedTotal} + +
+ + + + {cost.providerReportedUsd > 0.000_001 ? ( + + ) : null} +

+ API-equivalent cost. Subscription billing may differ. +

+
+
+
+ ); +} + +export function ComposerThreadCostIndicator(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly createdAt: string; + readonly refreshKey: string | null; +}) { + const { cost } = useThreadCost(props); + return cost === null ? null : ; +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index cae3dfe62852..bb2958693c24 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -4,9 +4,14 @@ import { Input as InputPrimitive } from "@base-ui/react/input"; import type * as React from "react"; import { cn } from "~/lib/utils"; +import { + segmentedControlItemSizeClassName, + segmentedControlItemVariantClassName, +} from "~/components/ui/segmented-control-styles"; type InputProps = Omit, "size"> & { - size?: "sm" | "compact" | "default" | "lg" | number; + size?: "sm" | "compact" | "default" | "lg" | "segmented" | number; + variant?: "default" | "segmented"; unstyled?: boolean; nativeInput?: boolean; }; @@ -14,6 +19,7 @@ type InputProps = Omit {inputElement} diff --git a/apps/web/src/components/ui/segmented-control-styles.ts b/apps/web/src/components/ui/segmented-control-styles.ts new file mode 100644 index 000000000000..bde9af0fb71e --- /dev/null +++ b/apps/web/src/components/ui/segmented-control-styles.ts @@ -0,0 +1,8 @@ +/** Shared visual contract for segmented controls and segmented inputs. */ +export const segmentedControlGroupClassName = "gap-0.5 rounded-lg bg-input/40 p-0.5"; + +export const segmentedControlItemSizeClassName = + "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]"; + +export const segmentedControlItemVariantClassName = + "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10 dark:hover:bg-input/32 dark:data-pressed:bg-input/72"; diff --git a/apps/web/src/components/ui/toggle-group.tsx b/apps/web/src/components/ui/toggle-group.tsx index 23501ec7a96c..2903bb8cd4b2 100644 --- a/apps/web/src/components/ui/toggle-group.tsx +++ b/apps/web/src/components/ui/toggle-group.tsx @@ -6,6 +6,7 @@ import type { VariantProps } from "class-variance-authority"; import * as React from "react"; import { cn } from "~/lib/utils"; +import { segmentedControlGroupClassName } from "~/components/ui/segmented-control-styles"; import { Separator } from "~/components/ui/separator"; import { Toggle as ToggleComponent, type toggleVariants } from "~/components/ui/toggle"; @@ -31,7 +32,7 @@ function ToggleGroup({ ? "*:pointer-coarse:after:min-w-auto" : "*:pointer-coarse:after:min-h-auto", variant === "segmented" - ? "gap-0.5 rounded-lg bg-input/40 p-0.5" + ? segmentedControlGroupClassName : variant === "default" ? "gap-0.5" : orientation === "horizontal" diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 9f74d4546cc7..14b80a4440fa 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -4,6 +4,10 @@ import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "~/lib/utils"; +import { + segmentedControlItemSizeClassName, + segmentedControlItemVariantClassName, +} from "~/components/ui/segmented-control-styles"; const toggleVariants = cva( "[&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base text-foreground outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-pressed:bg-input/64 data-pressed:text-accent-foreground sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", @@ -18,8 +22,7 @@ const toggleVariants = cva( "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", - segmented: - "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]", + segmented: segmentedControlItemSizeClassName, sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", }, @@ -29,8 +32,7 @@ const toggleVariants = cva( "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", outline: "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", - segmented: - "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10 dark:hover:bg-input/32 dark:data-pressed:bg-input/72", + segmented: segmentedControlItemVariantClassName, }, }, }, diff --git a/apps/web/src/components/usage/UsageCacheWriteCell.tsx b/apps/web/src/components/usage/UsageCacheWriteCell.tsx new file mode 100644 index 000000000000..4aba2f81a7b8 --- /dev/null +++ b/apps/web/src/components/usage/UsageCacheWriteCell.tsx @@ -0,0 +1,19 @@ +import { formatUsd } from "@t3tools/shared/usageFormat"; + +/** Consistent cache-write treatment across project, model, and thread tables. */ +export function UsageCacheWriteCell({ + cacheWriteTokens, + cacheWriteUsd, +}: { + readonly cacheWriteTokens: number; + readonly cacheWriteUsd: number | null; +}) { + const value = + cacheWriteTokens === 0 + ? "-" + : cacheWriteUsd === null + ? "Unavailable" + : formatUsd(cacheWriteUsd); + + return {value}; +} diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 944987388b06..2276d66c9979 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -1,12 +1,18 @@ -import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { mergeUsage } from "@t3tools/shared/usageMerge"; +import type { ComponentProps, ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ useUsage: vi.fn(), - metric: "cost" as "cost" | "tokens", - breakdown: "time" as "model" | "time", + usageThreadTable: vi.fn((_props: unknown) => null), + metric: "cost" as "cost" | "tokens" | "limits", + breakdown: "time" as "model" | "project" | "thread" | "time", + projectFilter: undefined as string | null | undefined, + refresh: vi.fn(), + setWindowSelection: vi.fn(), + refreshWindow: undefined as (() => void) | undefined, })); vi.mock("react", async (importOriginal) => { @@ -30,15 +36,34 @@ vi.mock("react", async (importOriginal) => { ? testState.metric : initial === "model" ? testState.breakdown - : initial, - vi.fn(), + : initial === undefined + ? testState.projectFilter + : initial, + typeof initial === "function" ? testState.setWindowSelection : vi.fn(), ]), }; }); vi.mock("../../env", () => ({ isElectron: false })); vi.mock("../../state/usage", () => ({ useUsage: testState.useUsage })); -vi.mock("../ui/button", () => ({ Button: "button" })); +vi.mock("../ui/button", () => ({ + Button: (props: { "aria-label"?: string; children?: ReactNode; onClick?: () => void }) => { + if (props["aria-label"] === "Refresh usage") testState.refreshWindow = props.onClick; + return ; + }, +})); +vi.mock("../ui/input", () => ({ + Input: ({ + nativeInput: _nativeInput, + size, + variant, + ...props + }: Omit, "size"> & { + nativeInput?: boolean; + size?: string; + variant?: string; + }) => , +})); vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); vi.mock("../ui/select", () => ({ Select: "div", @@ -57,6 +82,7 @@ vi.mock("../WorkspaceBreadcrumb", () => ({ vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); +vi.mock("./UsageThreadTable", () => ({ UsageThreadTable: testState.usageThreadTable })); vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); vi.mock("./usageProviders", async (importOriginal) => { const actual = await importOriginal(); @@ -83,6 +109,8 @@ const modelTotals = Object.freeze([ provider: "claude" as const, costUsd: 10, totalTokens: 100, + cacheWriteTokens: 40, + cacheWriteUsd: 2.5, records: 1, costShare: 10 / 16, }, @@ -91,6 +119,8 @@ const modelTotals = Object.freeze([ provider: "codex" as const, costUsd: 5, totalTokens: 1_000, + cacheWriteTokens: 0, + cacheWriteUsd: 0, records: 1, costShare: 5 / 16, }, @@ -99,6 +129,8 @@ const modelTotals = Object.freeze([ provider: "codex" as const, costUsd: 1, totalTokens: 1_000, + cacheWriteTokens: 0, + cacheWriteUsd: 0, records: 1, costShare: 1 / 16, }, @@ -124,13 +156,44 @@ const environments = [ }, ]; +const projectTotals = Object.freeze([ + { + projectId: ProjectId.make("project-expensive"), + projectKey: "id:project-expensive", + project: "Expensive Project", + costUsd: 9, + totalTokens: 200, + cacheWriteTokens: 60, + cacheWriteUsd: 1.75, + records: 2, + costShare: 9 / 20, + }, + { + projectId: null, + projectKey: null, + project: null, + costUsd: 7, + totalTokens: 900, + cacheWriteTokens: 0, + cacheWriteUsd: 0, + records: 1, + costShare: 7 / 20, + }, +]); + beforeEach(() => { testState.metric = "cost"; testState.breakdown = "time"; + testState.projectFilter = undefined; + testState.usageThreadTable.mockClear(); + testState.refresh.mockReset(); + testState.setWindowSelection.mockReset(); + testState.refreshWindow = undefined; testState.useUsage.mockReturnValue({ merged: { ...mergeUsage([], USAGE_CONTRACT_VERSION), models: modelTotals, + projects: projectTotals, hourly: [ { day: "2026-08-10", @@ -152,11 +215,30 @@ beforeEach(() => { selectedEnvironments: environments, isPending: false, isPartial: false, - refresh: vi.fn(), + refresh: testState.refresh, }); }); describe("UsagePage hourly breakdown", () => { + it("refreshes after rebasing a rolling window", () => { + renderToStaticMarkup(); + + testState.refreshWindow?.(); + + expect(testState.setWindowSelection).toHaveBeenCalledOnce(); + expect(testState.refresh).toHaveBeenCalledOnce(); + }); + + it("keeps custom date fields available in both desktop and compact layouts", () => { + const markup = renderToStaticMarkup(); + + expect(markup.match(/aria-label="From day"/g)).toHaveLength(2); + expect(markup.match(/aria-label="To day"/g)).toHaveLength(2); + expect(testState.useUsage).toHaveBeenLastCalledWith(expect.anything(), null, undefined, false); + expect(markup.match(/data-size="segmented"/g)).toHaveLength(4); + expect(markup.match(/data-variant="segmented"/g)).toHaveLength(4); + }); + it("keeps recent activity visible first without empty hourly rows", () => { const markup = renderToStaticMarkup(); const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; @@ -177,6 +259,133 @@ describe("UsagePage hourly breakdown", () => { }); }); +describe("UsagePage project breakdown", () => { + it("offers a lone project filter when unknown attribution remains in the totals", () => { + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { + ...usage.merged, + costUsd: 10, + totalTokens: 300, + projects: [projectTotals[0]], + }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup.match(/aria-label="Project filter"/g)).toHaveLength(2); + }); + + it("hides a lone project filter when it would not narrow the totals", () => { + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { + ...usage.merged, + costUsd: 9, + totalTokens: 200, + projects: [projectTotals[0]], + }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup).not.toContain('aria-label="Project filter"'); + }); + + it("ranks projects by cost and labels unattributed work", () => { + testState.breakdown = "project"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toMatch(/Expensive Project.*Outside projects/); + expect(body).toContain("$9.00"); + expect(body).toContain("$7.00"); + expect(body).toContain("45.0%"); + expect(body).toContain("35.0%"); + }); + + it("ranks projects by tokens when the token metric is selected", () => { + testState.metric = "tokens"; + testState.breakdown = "project"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toMatch(/Outside projects.*Expensive Project/); + }); + + it("shows only the selected project in the project breakdown", () => { + testState.breakdown = "project"; + testState.projectFilter = "id:project-expensive"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toContain("Expensive Project"); + expect(body).not.toContain("Outside projects"); + expect(body).toContain("100.0%"); + }); + + it("distinguishes unattributed usage from an empty window", () => { + testState.breakdown = "project"; + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { ...usage.merged, projects: [], records: 1 }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain("No project attribution in this window."); + expect(markup).not.toContain("No activity in this window."); + }); + + it("keeps the empty-window message when there is no usage", () => { + testState.breakdown = "project"; + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { ...usage.merged, projects: [], records: 0 }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain("No activity in this window."); + expect(markup).not.toContain("No project attribution in this window."); + }); +}); + +describe("UsagePage thread breakdown", () => { + it("requests thread rows in the selected project scope", () => { + testState.breakdown = "thread"; + testState.projectFilter = "id:project-expensive"; + + renderToStaticMarkup(); + + expect(testState.usageThreadTable).toHaveBeenCalledOnce(); + expect(testState.usageThreadTable.mock.calls[0]?.[0]).toMatchObject({ + input: { + sinceDay: "2026-08-10", + untilDay: "2026-08-11", + timeZone: "UTC", + sinceTime: "2026-08-10T12:37:00.000Z", + untilTime: "2026-08-11T12:37:00.000Z", + projectKey: "id:project-expensive", + }, + providerContributions: [], + }); + expect(testState.useUsage).toHaveBeenLastCalledWith( + expect.anything(), + null, + "id:project-expensive", + true, + ); + }); +}); + describe("UsagePage model breakdown", () => { it("sorts models by cost when the cost metric is selected", () => { testState.breakdown = "model"; @@ -187,6 +396,35 @@ describe("UsagePage model breakdown", () => { expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/); }); + it("shows cache-write cost per row, with a dash for write-free providers", () => { + testState.breakdown = "model"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + // Claude row carries its cache-write dollars; codex rows never bill writes. + expect(body).toContain("$2.50"); + expect(body).toMatch(/token-heavy-model.*>- { + testState.breakdown = "model"; + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { + ...usage.merged, + models: [{ ...modelTotals[0], cacheWriteUsd: null }], + costQuality: { ...usage.merged.costQuality, cacheWriteUsd: null }, + }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup.match(/Unavailable/g)).toHaveLength(2); + expect(markup).not.toContain("NaN%"); + }); + it("sorts models by token usage when the token metric is selected", () => { testState.metric = "tokens"; testState.breakdown = "model"; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index e957002115ab..ab3ac07cd1aa 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -11,12 +11,14 @@ import { RefreshCwIcon, SlidersHorizontalIcon, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { isCompatibleUsageContractVersion, + projectFilterForEnvironment, type DailyTotals, type HourlyTotals, + type ProjectTotals, } from "@t3tools/shared/usageMerge"; import { isElectron } from "../../env"; @@ -26,6 +28,7 @@ import { serverEnvironment } from "../../state/server"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { useAtomCommand } from "../../state/use-atom-command"; import { + compareUsageDays, enumerateDays, enumerateHourStarts, formatCount, @@ -35,9 +38,12 @@ import { formatPercent, formatTokens, formatUsd, + makeCustomWindow, makeWindow, } from "@t3tools/shared/usageFormat"; +import { useCommitOnBlur } from "../../hooks/useCommitOnBlur"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { Menu, MenuCheckboxItem, @@ -47,6 +53,7 @@ import { MenuTrigger, } from "../ui/menu"; import { ScrollArea } from "../ui/scroll-area"; +import { segmentedControlGroupClassName } from "../ui/segmented-control-styles"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; import { Skeleton } from "../ui/skeleton"; @@ -61,6 +68,8 @@ import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { UsageCacheWriteCell } from "./UsageCacheWriteCell"; +import { UsageThreadTable } from "./UsageThreadTable"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; type UsageMetric = UsageChartMetric | "limits"; @@ -82,20 +91,27 @@ const WINDOW_OPTIONS = [ ] as const; export function UsagePage() { + // `days` remembers the last preset even while a custom (brushed or typed) + // range is active, so a reset lands back where the user started. const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, + custom: false, window: makeWindow(30), })); const [metric, setMetric] = useState("cost"); const showingLimits = metric === "limits"; - const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + const [breakdown, setBreakdown] = useState<"model" | "project" | "thread" | "time">("model"); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); - const { days: windowDays, window } = windowSelection; - const isPast24Hours = windowDays === 1; + // A namespaced project key, null for work outside every project, undefined for all. + const [projectFilter, setProjectFilter] = useState(undefined); + const { days: windowDays, custom: isCustomWindow, window } = windowSelection; + const isPast24Hours = !isCustomWindow && windowDays === 1; const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( window, selectedEnvironmentIds, + projectFilter, + breakdown === "thread", ); const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { @@ -128,15 +144,62 @@ export function UsagePage() { : merged.models, [breakdown, merged.models, metric], ); + const breakdownProjects = useMemo(() => { + const scoped = + projectFilter === undefined + ? merged.projects + : merged.projects.filter((project) => project.projectKey === projectFilter); + return metric === "tokens" + ? scoped.toSorted( + (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, + ) + : scoped; + }, [merged.projects, metric, projectFilter]); + const breakdownProjectCostUsd = useMemo( + () => breakdownProjects.reduce((sum, project) => sum + project.costUsd, 0), + [breakdownProjects], + ); + const projectLabelsRef = useRef(new Map()); + for (const project of merged.projects) { + if (project.projectKey !== null && project.project !== null) { + projectLabelsRef.current.set(project.projectKey, project.project); + } + } + const selectedProjectLabel = + projectFilter === undefined + ? null + : projectFilter === null + ? "Outside projects" + : (projectLabelsRef.current.get(projectFilter) ?? "Selected project"); const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; + // Session figures are per transcript directory; a project filter cannot + // split them, so they only render unfiltered. + const sessionsKnown = projectFilter === undefined; + const onlyProject = merged.projects.length === 1 ? merged.projects[0] : undefined; + // Unknown attribution remains in the overall totals but is absent from the + // project list. Keep a lone known project selectable when that distinction + // lets the user remove unknown usage from the page. + const showProjectPicker = + merged.projects.length > 1 || + projectFilter !== undefined || + (onlyProject !== undefined && + (onlyProject.totalTokens !== merged.totalTokens || onlyProject.costUsd !== merged.costUsd)); const selectWindow = (days: number) => { setWindowSelection({ days, + custom: false, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; + const selectCustomWindow = (sinceDay: string, untilDay: string) => { + setWindowSelection({ + days: windowDays, + custom: true, + window: makeCustomWindow(sinceDay, untilDay), + }); + }; const refreshWindow = () => { if (showingLimits) { for (const [environmentId, presentation] of presentations) { @@ -147,17 +210,22 @@ export function UsagePage() { } return; } + // A custom range is a fixed span of past days; rescanning is all a + // refresh can mean for it. + if (isCustomWindow) { + refresh(); + return; + } const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { - setWindowSelection({ days: windowDays, window: nextWindow }); + setWindowSelection({ days: windowDays, custom: false, window: nextWindow }); } + refresh(); }; const windowLabel = isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined @@ -189,6 +257,14 @@ export function UsagePage() { ) : null}
+ {showProjectPicker ? ( + + ) : null} ))} + {/* The period does not apply to Limits, so it stays in place but disabled; unmounting it shifted the metric toggle ~300px. */} { const value = next[0]; @@ -232,6 +314,14 @@ export function UsagePage() {
+ {showProjectPicker ? ( + + ) : null} + to + +
+ ); +} + +/** + * Select values are plain strings, so the three filter states get distinct + * encodings: sentinels for "all" and "outside", while attributed projects + * already carry a namespaced stable key from the merge layer. + */ +const ALL_PROJECTS_VALUE = "all"; +const OUTSIDE_PROJECTS_VALUE = "outside"; +const PROJECT_VALUE_PREFIX = "p:"; + +function projectFilterValue(filter: string | null | undefined): string { + if (filter === undefined) return ALL_PROJECTS_VALUE; + if (filter === null) return OUTSIDE_PROJECTS_VALUE; + return `${PROJECT_VALUE_PREFIX}${filter}`; +} + +function projectFilterFromValue(value: string): string | null | undefined { + if (value === OUTSIDE_PROJECTS_VALUE) return null; + if (value.startsWith(PROJECT_VALUE_PREFIX)) return value.slice(PROJECT_VALUE_PREFIX.length); + return undefined; +} + +/** Narrows the whole page to one project's buckets. */ +function UsageProjectSelect({ + projects, + filter, + selectedLabel, + onChange, +}: { + readonly projects: readonly ProjectTotals[]; + readonly filter: string | null | undefined; + readonly selectedLabel: string | null; + readonly onChange: (filter: string | null | undefined) => void; +}) { + const label = filter === undefined ? "All projects" : (selectedLabel ?? "Selected project"); + return ( + + ); +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, @@ -567,11 +948,22 @@ function ProviderMark({ return ; } -function Metric({ label, value }: { readonly label: string; readonly value: string }) { +function Metric({ + label, + value, + detail, +}: { + readonly label: string; + readonly value: string; + readonly detail?: string; +}) { return (
{label} {value} + {detail === undefined ? null : ( + {detail} + )}
); } @@ -809,15 +1201,20 @@ function UsageSkeleton() {

Totals

-
- {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( - (label) => ( -
- {label} - -
- ), - )} +
+ {[ + "Processed tokens", + "Cached input", + "Uncached input", + "Output", + "Cache writes, estimated", + "Cache savings", + ].map((label) => ( +
+ {label} + +
+ ))}
diff --git a/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx new file mode 100644 index 000000000000..034c8a7c7e7a --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx @@ -0,0 +1,81 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { UsageProviderChart } from "./UsageProviderChart"; + +const days = ["2026-09-01", "2026-09-02", "2026-09-03"]; +let renderer: ReactTestRenderer; +const onZoomToDays = vi.fn(); +const captures = new Set(); +const plot = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 260 }), + hasPointerCapture: (id: number) => captures.has(id), + setPointerCapture: (id: number) => captures.add(id), + releasePointerCapture: (id: number) => captures.delete(id), +}; + +function chart(windowDays: readonly string[], resolution: "day" | "hour" = "day") { + return ( + + ); +} + +function pointer(name: "onPointerDown" | "onPointerUp", clientX: number) { + renderer.root + .find((node) => node.type === "div" && node.props.onPointerDown !== undefined) + .props[name]({ button: 0, isPrimary: true, pointerId: 1, clientX, currentTarget: plot }); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onZoomToDays.mockClear(); + captures.clear(); + await act(() => { + renderer = create(chart(days), { + createNodeMock: (element) => (element.type === "div" ? plot : null), + }); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("usage chart brush ownership", () => { + it("cancels a brush if date-field blur replaces its window before pointer-up", async () => { + await act(() => pointer("onPointerDown", 0)); + expect(captures.has(1)).toBe(true); + await act(() => renderer.update(chart(["2026-08-01", "2026-08-02", "2026-08-03"]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); + + it("keeps a brush when the same days are supplied by a fresh array", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart([...days]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).toHaveBeenCalledExactlyOnceWith(days[0], days[2]); + }); + + it("cancels a brush when the view switches to hourly resolution", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart(days, "hour"))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 622d73d13844..000c20a3ebf7 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildPeriodColumns, niceScale } from "./UsageProviderChart"; +import { + brushSelection, + buildPeriodColumns, + chartLabelIndices, + niceScale, + periodIndexAt, + spanSinglePeriodPoints, +} from "./UsageProviderChart"; import { providersWithUsage } from "./usageProviders"; describe("niceScale", () => { @@ -135,3 +142,65 @@ describe("hourly chart columns", () => { ).toEqual([0, 4, 0]); }); }); + +describe("brushSelection", () => { + const days = ["2026-08-01", "2026-08-02", "2026-08-03", "2026-08-04"]; + + it("returns inclusive bounds for a forward drag", () => { + expect(brushSelection(days, 1, 3)).toEqual({ + sinceDay: "2026-08-02", + untilDay: "2026-08-04", + }); + }); + + it("normalises a backward drag", () => { + expect(brushSelection(days, 3, 1)).toEqual({ + sinceDay: "2026-08-02", + untilDay: "2026-08-04", + }); + }); + + it("treats a plain click as no selection", () => { + expect(brushSelection(days, 2, 2)).toBeNull(); + }); + + it("rejects endpoints outside the day list", () => { + expect(brushSelection(days, 0, 9)).toBeNull(); + }); +}); + +describe("periodIndexAt", () => { + it("clamps a captured pointer to either chart edge", () => { + expect(periodIndexAt(-50, 100, 400, 5)).toBe(0); + expect(periodIndexAt(750, 100, 400, 5)).toBe(4); + }); +}); + +describe("spanSinglePeriodPoints", () => { + it("repeats one point across the chart width", () => { + expect(spanSinglePeriodPoints([{ x: 0, y: 42 }])).toEqual([ + { x: 0, y: 42 }, + { x: 960, y: 42 }, + ]); + }); + + it("leaves multi-period points unchanged", () => { + const points = [ + { x: 0, y: 42 }, + { x: 960, y: 12 }, + ]; + + expect(spanSinglePeriodPoints(points)).toBe(points); + }); +}); + +describe("chartLabelIndices", () => { + it("deduplicates labels for one- and two-period windows", () => { + expect(chartLabelIndices(1)).toEqual([0]); + expect(chartLabelIndices(2)).toEqual([0, 1]); + }); + + it("keeps left, middle, and right labels for wider windows", () => { + expect(chartLabelIndices(5)).toEqual([0, 2, 4]); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 4a66349ddfa5..988757af014b 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -2,6 +2,8 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; + +import { cn } from "../../lib/utils"; import { formatDayShort, formatHourShort, @@ -19,6 +21,13 @@ const PLOT_TOP = 8; export type UsageChartMetric = "tokens" | "cost"; interface UsageProviderChartProps { + /** + * Present only when the window can zoom (daily resolution). Receives the + * inclusive day bounds of a completed drag selection. + */ + readonly onZoomToDays?: (sinceDay: string, untilDay: string) => void; + /** Restores the preset window on double-click. */ + readonly onResetZoom?: () => void; readonly providers: readonly UsageProviderKind[]; readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; @@ -44,6 +53,18 @@ interface Point { readonly y: number; } +/** Gives a one-period daily window enough horizontal span to draw a path. */ +export function spanSinglePeriodPoints(points: readonly Point[]): readonly Point[] { + const only = points.length === 1 ? points[0] : undefined; + return only === undefined ? points : [only, { ...only, x: VIEW_WIDTH }]; +} + +/** Selects distinct left, middle, and right labels for the available span. */ +export function chartLabelIndices(periodCount: number): readonly number[] { + if (periodCount <= 0) return []; + return [...new Set([0, Math.floor(periodCount / 2), periodCount - 1])]; +} + function valueFor( totals: DailyTotals | HourlyTotals | undefined, provider: UsageProviderKind, @@ -169,7 +190,40 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re return { max, ticks }; } +/** + * Inclusive day bounds of a brush selection, or null for a plain click. + * Endpoints may arrive in either drag direction. + */ +export function brushSelection( + days: readonly string[], + startIndex: number, + endIndex: number, +): { readonly sinceDay: string; readonly untilDay: string } | null { + if (startIndex === endIndex) return null; + const [first, last] = startIndex < endIndex ? [startIndex, endIndex] : [endIndex, startIndex]; + const sinceDay = days[first]; + const untilDay = days[last]; + if (sinceDay === undefined || untilDay === undefined) return null; + return { sinceDay, untilDay }; +} + +/** Period index beneath a pointer, clamped when pointer capture moves outside the plot. */ +export function periodIndexAt( + clientX: number, + plotLeft: number, + plotWidth: number, + periodCount: number, +): number | null { + if (plotWidth <= 0 || periodCount <= 0) return null; + const localX = Math.min(plotWidth, Math.max(0, clientX - plotLeft)); + const fraction = localX / plotWidth; + const index = Math.round(fraction * (periodCount - 1)); + return Math.min(periodCount - 1, Math.max(0, index)); +} + export function UsageProviderChart({ + onZoomToDays, + onResetZoom, providers, days, daily, @@ -189,10 +243,36 @@ export function UsageProviderChart({ [daily, hourly, resolution], ); const [hoverIndex, setHoverIndex] = useState(null); + // Drag-selection endpoints, as period indices. Only daily windows zoom. + const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); + const brushRef = useRef<{ + readonly pointerId: number; + readonly days: readonly string[]; + readonly start: number; + readonly end: number; + } | null>(null); + const zoomable = resolution === "day" && onZoomToDays !== undefined; const plotRef = useRef(null); const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); + useLayoutEffect(() => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + (zoomable && + activeBrush.days.length === days.length && + activeBrush.days.every((day, index) => day === days[index])) + ) + return; + brushRef.current = null; + setBrush(null); + const plot = plotRef.current; + if (plot?.hasPointerCapture(activeBrush.pointerId)) { + plot.releasePointerCapture(activeBrush.pointerId); + } + }, [days, zoomable]); + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { @@ -221,14 +301,11 @@ export function UsageProviderChart({ const built = providers.map((provider) => { const providerIndex = PROVIDER_ORDER.indexOf(provider); - const line = curvePath( - smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * step, - y: toY(column.bands[providerIndex]?.value ?? 0), - })), - ), - ); + const points = columns.map((column, periodIndex) => ({ + x: periodIndex * step, + y: toY(column.bands[providerIndex]?.value ?? 0), + })); + const line = curvePath(smoothCurve(spanSinglePeriodPoints(points))); return { provider, total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), @@ -288,23 +365,96 @@ export function UsageProviderChart({ return () => observer.disconnect(); }, [hoverIndex, positionTooltip]); + const indexAt = useCallback( + (clientX: number): number | null => { + const plot = plotRef.current; + if (plot === null || periods.length === 0) return null; + const bounds = plot.getBoundingClientRect(); + return periodIndexAt(clientX, bounds.left, bounds.width, periods.length); + }, + [periods.length], + ); + const handleMove = useCallback( (event: React.MouseEvent) => { const plot = plotRef.current; if (plot === null || periods.length === 0) return; const bounds = plot.getBoundingClientRect(); if (bounds.width === 0) return; + if (brushRef.current !== null) return; + const index = indexAt(event.clientX); + if (index === null) return; const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); - const fraction = localX / bounds.width; - const index = Math.round(fraction * (periods.length - 1)); hoverPositionRef.current = { x: localX, y: localY }; positionTooltip(); - setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); + setHoverIndex(index); }, - [periods.length, positionTooltip], + [indexAt, periods.length, positionTooltip], ); + const trackBrush = useCallback( + (event: React.PointerEvent) => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + activeBrush.pointerId !== event.pointerId || + !event.currentTarget.hasPointerCapture(event.pointerId) + ) { + return; + } + const index = indexAt(event.clientX); + if (index === null || index === activeBrush.end) return; + const nextBrush = { ...activeBrush, end: index }; + brushRef.current = nextBrush; + setBrush(nextBrush); + }, + [indexAt], + ); + + const beginBrush = useCallback( + (event: React.PointerEvent) => { + if (!zoomable || event.button !== 0 || !event.isPrimary || brushRef.current !== null) return; + const index = indexAt(event.clientX); + if (index === null) return; + event.currentTarget.setPointerCapture(event.pointerId); + hoverPositionRef.current = null; + setHoverIndex(null); + const nextBrush = { pointerId: event.pointerId, days, start: index, end: index }; + brushRef.current = nextBrush; + setBrush(nextBrush); + }, + [days, indexAt, zoomable], + ); + + const finishBrush = useCallback( + (event: React.PointerEvent) => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + activeBrush.pointerId !== event.pointerId || + onZoomToDays === undefined + ) { + return; + } + const end = indexAt(event.clientX) ?? activeBrush.end; + const selection = brushSelection(days, activeBrush.start, end); + brushRef.current = null; + setBrush(null); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (selection !== null) onZoomToDays(selection.sinceDay, selection.untilDay); + }, + [days, indexAt, onZoomToDays], + ); + + const cancelBrush = useCallback((event: React.PointerEvent) => { + if (brushRef.current?.pointerId !== event.pointerId) return; + brushRef.current = null; + setBrush(null); + }, []); + const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; const formatPeriod = (period: string) => @@ -332,8 +482,17 @@ export function UsageProviderChart({
{ hoverPositionRef.current = null; setHoverIndex(null); @@ -383,7 +542,22 @@ export function UsageProviderChart({ /> ))} - {hoverIndex === null ? null : ( + {brush === null || brush.start === brush.end ? null : ( + + )} + + {hoverIndex === null || periods.length === 1 ? null : (
- {periods[0] === undefined ? "" : formatPeriod(periods[0])} - - {periods[Math.floor(periods.length / 2)] === undefined - ? "" - : formatPeriod(periods[Math.floor(periods.length / 2)] ?? "")} - - - {periods[periods.length - 1] === undefined - ? "" - : formatPeriod(periods[periods.length - 1] ?? "")} - + {chartLabelIndices(periods.length).map((index) => ( + {formatPeriod(periods[index] ?? "")} + ))}
); diff --git a/apps/web/src/components/usage/UsageThreadTable.test.tsx b/apps/web/src/components/usage/UsageThreadTable.test.tsx new file mode 100644 index 000000000000..914b010caf89 --- /dev/null +++ b/apps/web/src/components/usage/UsageThreadTable.test.tsx @@ -0,0 +1,159 @@ +import { EnvironmentId, ThreadId, UsageDay } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ useUsageThreads: vi.fn() })); + +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); +vi.mock("../../state/usage", () => ({ useUsageThreads: testState.useUsageThreads })); +vi.mock("../ui/tooltip", async () => { + const React = await import("react"); + return { + Tooltip: "span", + TooltipPopup: "span", + TooltipTrigger: ({ + render, + children, + }: { + render: React.ReactElement; + children: React.ReactNode; + }) => React.cloneElement(render, {}, children), + }; +}); +vi.mock("./usageProviders", () => ({ + PROVIDER_PRESENTATION: { + claude: { mark: "span" }, + codex: { mark: "span" }, + grok: { mark: "span" }, + }, +})); + +import { UsageThreadDailyChart, UsageThreadTable } from "./UsageThreadTable"; + +const input = { + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-31"), + timeZone: "UTC", +}; + +beforeEach(() => { + testState.useUsageThreads.mockReset(); +}); + +describe("UsageThreadTable", () => { + it("uses the shared skeleton treatment while thread data is pending", () => { + testState.useUsageThreads.mockReturnValue({ + rows: [], + truncatedRows: 0, + isPending: true, + failedEnvironments: 0, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup.match(/motion-safe:animate-skeleton/g)).toHaveLength(4); + }); + + it("reports an unavailable breakdown when every query failed", () => { + testState.useUsageThreads.mockReturnValue({ + rows: [], + truncatedRows: 0, + isPending: false, + failedEnvironments: 0, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Thread activity could not be loaded"); + expect(markup).not.toContain("No activity in this window"); + }); + + it("uses a keyboard-accessible disclosure button without a native title", () => { + testState.useUsageThreads.mockReturnValue({ + rows: [ + { + environmentId: EnvironmentId.make("environment-one"), + key: "row-one", + threadId: ThreadId.make("thread-one"), + title: "Fix the flaky test", + provider: "claude", + totals: { + uncachedInputTokens: 1, + cachedInputTokens: 2, + cacheCreationTokens: 3, + outputTokens: 4, + reasoningTokens: 0, + }, + costUsd: 1, + cacheWriteUsd: 0.25, + sessions: 1, + agents: [ + { + agentId: "agent-one", + totals: { + uncachedInputTokens: 1, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 1, + reasoningTokens: 0, + }, + costUsd: 0.1, + }, + ], + daily: [], + }, + ], + truncatedRows: 0, + isPending: false, + failedEnvironments: 0, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('