authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-08 02:48:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 02:48:14-07:00
log85975585579188be20f46d96b4972ce46c33f4bc
tree0cbc8866c0f7716f1003d134b690d8ad0932a804
parent0ce6b9deb0c3f6b4dd9df7ed809f7acb9d237ae0
signature Signed by PGP key B5690EEEBB952194

perf(usage): the Usage page shows numbers in under a second and dims only what is still loading (#17147)

Takeover of #16376. Co-authored-by: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

22 files changed, 2131 insertions(+), 429 deletions(-)

apps/mobile/src/features/usage/UsageRouteScreen.tsx+101-53
......@@ -1,8 +1,13 @@
11import { ChatGptUsageSummary } from "./ChatGptUsageSummary";
22import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
3import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
3import { EnvironmentId, USAGE_CONTRACT_VERSION, type UsageProviderKind } from "@t3tools/contracts";
44import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
55import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage";
6import {
7 updatingProvidersLabel,
8 usageEnvironmentProgress,
9 usageProgress,
10} from "@t3tools/client-runtime/state/usage-progress";
611import {
712 isCompatibleUsageContractVersion,
813 isModelCostUnknown,
......@@ -21,9 +26,16 @@ import {
2126 formatUsd,
2227 makeWindow,
2328} from "@t3tools/shared/usageFormat";
24import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
25import { Platform, Pressable, RefreshControl, View } from "react-native";
26import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated";
29import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
30import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native";
31import Animated, {
32 FadeIn,
33 ReduceMotion,
34 useAnimatedStyle,
35 useSharedValue,
36 withDelay,
37 withTiming,
38} from "react-native-reanimated";
2739import { useSafeAreaInsets } from "react-native-safe-area-context";
2840
2941import { SegmentedControl } from "../../components/SegmentedControl";
......@@ -65,6 +77,7 @@ const METRIC_OPTIONS = [
6577] as const satisfies readonly { value: UsageChartMetric; label: string }[];
6678
6779const CHART_HEIGHT = 180;
80const providerLabel = (provider: UsageProviderKind) => PROVIDER_LABEL[provider];
6881const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain.";
6982
7083/**
......@@ -156,6 +169,10 @@ export function UsageRouteScreen() {
156169 const [refreshingUsage, setRefreshingUsage] = useState(false);
157170 const refreshingRef = useRef(false);
158171 const showingLimits = tab === "limits";
172 const progress = usageProgress(selectedEnvironments, {
173 refreshing: refreshingUsage,
174 providerLabel,
175 });
159176 const selectWindow = (days: number) => {
160177 setWindowSelection({
161178 days,
......@@ -182,10 +199,6 @@ export function UsageRouteScreen() {
182199 };
183200
184201 const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null;
185 const hasLoadingEnvironments = selectedEnvironments.some(isUsageLoading);
186 const filterAccessibilityLabel = hasLoadingEnvironments
187 ? "Filter usage environments, some environments are loading"
188 : "Filter usage environments";
189202 const filterIcon =
190203 selectedEnvironmentIds === null
191204 ? "line.3.horizontal.decrease"
......@@ -201,14 +214,14 @@ export function UsageRouteScreen() {
201214 ...environments.map((environment) => ({
202215 id: environment.environmentId,
203216 title: environment.label,
204 subtitle: usageEnvironmentStatus(environment),
217 subtitle: usageEnvironmentStatus(environment, refreshingUsage),
205218 state:
206219 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId)
207220 ? ("on" as const)
208221 : ("off" as const),
209222 })),
210223 ],
211 [environments, selectedEnvironmentIds],
224 [environments, refreshingUsage, selectedEnvironmentIds],
212225 );
213226 const selectEnvironment = useCallback(
214227 (value: string) => {
......@@ -227,37 +240,24 @@ export function UsageRouteScreen() {
227240 <ControlPillMenu
228241 accessible
229242 accessibilityRole="button"
230 accessibilityLabel={filterAccessibilityLabel}
243 accessibilityLabel="Filter usage environments"
231244 title="Environments"
232245 actions={environmentActions}
233246 onPressAction={({ nativeEvent }) => selectEnvironment(nativeEvent.event)}
234247 >
235248 <Pressable
236249 accessibilityRole="button"
237 accessibilityLabel={filterAccessibilityLabel}
250 accessibilityLabel="Filter usage environments"
238251 className={cn(
239252 "items-center justify-center rounded-full",
240253 Platform.OS === "ios" ? "size-[28px]" : "size-[44px]",
241254 )}
242255 >
243256 <SymbolView name={filterIcon} size={22} tintColorClassName="accent-icon" />
244 {hasLoadingEnvironments ? (
245 <View
246 pointerEvents="none"
247 className="absolute -right-[2px] -top-[2px] size-[9px] rounded-full bg-amber-500"
248 />
249 ) : null}
250257 </Pressable>
251258 </ControlPillMenu>
252259 ) : null,
253 [
254 showEnvironmentFilter,
255 environmentActions,
256 selectEnvironment,
257 filterAccessibilityLabel,
258 filterIcon,
259 hasLoadingEnvironments,
260 ],
260 [showEnvironmentFilter, environmentActions, selectEnvironment, filterIcon],
261261 );
262262
263263 useLayoutEffect(() => {
......@@ -361,26 +361,28 @@ export function UsageRouteScreen() {
361361 {message}
362362 </Text>
363363 ))}
364 <ChartCard
365 merged={merged}
366 days={chartDays}
367 daily={chartTotals}
368 metric={metric}
369 sinceDay={window.sinceDay}
370 untilDay={window.untilDay}
371 isPast24Hours={isPast24Hours}
372 timeZone={window.timeZone}
373 />
374 <ProviderSection
375 merged={merged}
376 metric={metric}
377 cursorAccessEnvironments={cursorAccessEnvironments}
378 showCursorEnvironment={selectedEnvironments.length > 1}
379 onCursorEnabled={refreshAfterCursorEnable}
380 />
381 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
382 <CostSection merged={merged} />
383 <ModelsSection merged={merged} metric={metric} />
364 <UsageUpdating dimmed={progress.dimmed} label={progress.label}>
365 <ChartCard
366 merged={merged}
367 days={chartDays}
368 daily={chartTotals}
369 metric={metric}
370 sinceDay={window.sinceDay}
371 untilDay={window.untilDay}
372 isPast24Hours={isPast24Hours}
373 timeZone={window.timeZone}
374 />
375 <ProviderSection
376 merged={merged}
377 metric={metric}
378 cursorAccessEnvironments={cursorAccessEnvironments}
379 showCursorEnvironment={selectedEnvironments.length > 1}
380 onCursorEnabled={refreshAfterCursorEnable}
381 />
382 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
383 <CostSection merged={merged} />
384 <ModelsSection merged={merged} metric={metric} />
385 </UsageUpdating>
384386 </>
385387 )}
386388 </>
......@@ -391,6 +393,53 @@ export function UsageRouteScreen() {
391393 );
392394}
393395
396/**
397 * Dims totals that are about to change and says what is still updating. The
398 * status overlays the first child's top-right corner, the chart card's label
399 * row, so appearing never moves anything.
400 */
401function UsageUpdating({
402 dimmed,
403 label,
404 children,
405}: {
406 readonly dimmed: boolean;
407 readonly label: string | null;
408 readonly children: ReactNode;
409}) {
410 const opacity = useSharedValue(1);
411 useLayoutEffect(() => {
412 // The delay keeps a quick cached answer from flashing the dim.
413 opacity.set(
414 dimmed
415 ? withDelay(150, withTiming(0.5, { duration: 150, reduceMotion: ReduceMotion.System }))
416 : withTiming(1, { duration: 150, reduceMotion: ReduceMotion.System }),
417 );
418 }, [opacity, dimmed]);
419 const dimStyle = useAnimatedStyle(() => ({ opacity: opacity.get() }));
420
421 return (
422 <View>
423 <Animated.View style={dimStyle} className="gap-6" accessibilityState={{ busy: dimmed }}>
424 {children}
425 </Animated.View>
426 {label !== null ? (
427 <Animated.View
428 entering={FadeIn.delay(150).duration(150).reduceMotion(ReduceMotion.System)}
429 accessibilityLiveRegion="polite"
430 pointerEvents="none"
431 className="absolute right-4 top-4 h-5 max-w-[55%] flex-row items-center gap-1.5"
432 >
433 <ActivityIndicator size="small" colorClassName="accent-adaptive-sky-600-400" />
434 <Text className="shrink text-sm text-adaptive-sky-600-400" numberOfLines={1}>
435 {label}
436 </Text>
437 </Animated.View>
438 ) : null}
439 </View>
440 );
441}
442
394443function CursorEnableAction({
395444 environmentId,
396445 label,
......@@ -860,11 +909,7 @@ function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: U
860909 * one that failed, or one whose transcripts another environment already
861910 * reported.
862911 */
863function isUsageLoading(environment: EnvironmentUsageStatus) {
864 return environment.isPending || (environment.summary === null && environment.error === null);
865}
866
867function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
912function usageEnvironmentStatus(environment: EnvironmentUsageStatus, refreshing: boolean): string {
868913 if (
869914 environment.summary &&
870915 !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION)
......@@ -881,7 +926,10 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
881926 return environment.summary ? `${environment.error} Showing saved totals.` : environment.error;
882927 if (!environment.isConnected)
883928 return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";
884 if (isUsageLoading(environment))
885 return environment.summary ? "Updating usage…" : "Loading usage…";
929 const progress = usageEnvironmentProgress(environment, refreshing);
930 if (progress.phase === "loading") return "Loading usage…";
931 if (progress.phase === "stale") return "Updating usage…";
932 if (progress.phase === "partway")
933 return updatingProvidersLabel(progress.providers, providerLabel);
886934 return "Usage up to date";
887935}
apps/server/src/server.ts+5-1
......@@ -153,6 +153,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien
153153import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
154154import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";
155155import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
156import * as CursorUsageReader from "./usage/cursorUsageReader.ts";
156157import * as UsageService from "./usage/UsageService.ts";
157158import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts";
158159import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
......@@ -225,7 +226,10 @@ const layerBackground = BackgroundPolicy.layer.pipe(
225226 Layer.provideMerge(layerServerSettings),
226227);
227228
228const layerUsage = UsageService.layer.pipe(Layer.provide(layerServerSettings));
229const layerUsage = UsageService.layer.pipe(
230 Layer.provide(layerServerSettings),
231 Layer.provide(CursorUsageReader.layer),
232);
229233
230234const layerResourceDiagnostics = Layer.mergeAll(
231235 HostResources.layer,
apps/server/src/usage/UsageService.test.ts+276-2
......@@ -15,6 +15,7 @@ import {
1515 ProviderDriverKind,
1616 ProviderInstanceId,
1717 UsageDay,
18 type UsageSource,
1819 type UsageSummaryInput,
1920} from "@t3tools/contracts";
2021import * as Duration from "effect/Duration";
......@@ -31,6 +32,8 @@ import { HttpClient, HttpClientResponse } from "effect/http";
3132
3233import * as ServerConfig from "../config.ts";
3334import * as ServerSettings from "../serverSettings.ts";
35import * as CursorUsageReader from "./cursorUsageReader.ts";
36import type { UsageRecord } from "./usageTranscripts.ts";
3437import * as UsageService from "./UsageService.ts";
3538
3639const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
......@@ -90,6 +93,7 @@ const layerService = (input: {
9093}) =>
9194 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(
9295 Layer.provideMerge(NodeServices.layer),
96 Layer.provideMerge(CursorUsageReader.layer),
9397 Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")),
9498 Layer.provideMerge(ServerSettings.layerTest(input.settings)),
9599 Layer.provideMerge(
......@@ -157,6 +161,71 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
157161 return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0);
158162}
159163
164/** Inside `WINDOW`: the time Cursor tests run at. */
165const CURSOR_NOW = Date.parse("2026-08-02T12:00:00Z");
166const HOUR_MS = 60 * 60 * 1000;
167/** The service's cache retention, which the Cursor account cache always covers. */
168const CURSOR_RETENTION_MS = 92 * 24 * HOUR_MS;
169
170/**
171 * Stands in for Cursor's dashboard API. Each read returns the account's events
172 * in its range, keyed like the real reader: occurrences count within one read.
173 * While `gate` is set, reads wait for it.
174 */
175function makeFakeCursor() {
176 const state = {
177 accountKey: "account-a",
178 events: [] as { readonly timestampMs: number; readonly outputTokens: number }[],
179 error: null as string | null,
180 gate: undefined as Deferred.Deferred<void> | undefined,
181 calls: [] as { readonly sinceMs: number; readonly untilMs: number }[],
182 };
183 const read = (_credential: unknown, sinceMs: number, untilMs: number) =>
184 Effect.gen(function* () {
185 state.calls.push({ sinceMs, untilMs });
186 if (state.gate !== undefined) yield* Deferred.await(state.gate);
187 const { accountKey, error } = state;
188 if (error !== null) return { accountKey, records: [], missing: false, error };
189 const occurrences = new Map<string, number>();
190 const records = state.events
191 .filter(({ timestampMs }) => timestampMs >= sinceMs && timestampMs <= untilMs)
192 .map(({ timestampMs, outputTokens }): UsageRecord => {
193 const key = `${timestampMs}:${outputTokens}`;
194 const occurrence = occurrences.get(key) ?? 0;
195 occurrences.set(key, occurrence + 1);
196 return {
197 provider: "cursor",
198 timestampMs,
199 model: "claude-fable-5",
200 sessionId: "conversation-1",
201 totals: {
202 uncachedInputTokens: 0,
203 cachedInputTokens: 0,
204 cacheCreationTokens: 0,
205 outputTokens,
206 reasoningTokens: 0,
207 },
208 reportedCostUsd: null,
209 speed: "standard",
210 dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,
211 };
212 });
213 return { accountKey, records, missing: false, error: null };
214 });
215 return { state, read };
216}
217
218const writeCursorLogin = (home: string) =>
219 Effect.promise(async () => {
220 const authPath = NodePath.join(home, "config", "cursor", "auth.json");
221 await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true });
222 await NodeFSP.writeFile(authPath, "{}");
223 });
224
225function cursorSource(summary: { readonly sources: readonly UsageSource[] }) {
226 return summary.sources.find((source) => source.fingerprint.provider === "cursor");
227}
228
160229describe("UsageService", () => {
161230 it.live.each([
162231 { explicitDefault: true, label: "explicit" },
......@@ -196,6 +265,9 @@ describe("UsageService", () => {
196265 const service = yield* UsageService.make;
197266 return yield* service.readSummary(WINDOW);
198267 }).pipe(
268 // Scoped inside the state directory, so pending cache writes land
269 // before it is removed.
270 Effect.scoped,
199271 Effect.provide(
200272 layerService({
201273 prefix: "usage-managed-accounts",
......@@ -282,12 +354,201 @@ describe("UsageService", () => {
282354 layerService({ prefix: "usage-service-cursor-invalid-login", home, settings }),
283355 ),
284356 );
285 const summary = yield* service.readSummary(WINDOW);
357 const summary = yield* service.readSummary({ ...WINDOW, awaitRefresh: true });
286358 const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor");
287359 assert.strictEqual(cursor?.message, "Cursor credentials could not be read.");
288360 }).pipe(Effect.scoped),
289361 );
290362
363 it.live("answers Cursor from its cache while one shared refresh runs", () =>
364 Effect.gen(function* () {
365 const { settings, home } = yield* setup;
366 yield* writeCursorLogin(home);
367 yield* TestClock.setTime(CURSOR_NOW);
368 const cursor = makeFakeCursor();
369 cursor.state.events = [{ timestampMs: CURSOR_NOW - 2 * HOUR_MS, outputTokens: 5 }];
370 const gate = yield* Deferred.make<void>();
371 cursor.state.gate = gate;
372 yield* Effect.gen(function* () {
373 const service = yield* UsageService.make.pipe(
374 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }),
375 );
376 // Cold: nothing cached yet, so Cursor answers empty while it refreshes.
377 const cold = yield* service.readSummary(WINDOW);
378 assert.strictEqual(cursorSource(cold)?.refreshing, true);
379 assert.strictEqual(cursorSource(cold)?.status, "ok");
380 assert.strictEqual(totalOutputTokens(cold), 0);
381 // A second reader joins the refresh in flight.
382 const joined = yield* service.readSummary(WINDOW);
383 assert.strictEqual(cursorSource(joined)?.refreshing, true);
384
385 const waited = yield* service
386 .readSummary({ ...WINDOW, awaitRefresh: true })
387 .pipe(Effect.forkChild);
388 yield* Deferred.succeed(gate, undefined);
389 const refreshed = yield* Fiber.join(waited);
390 assert.isFalse(refreshed.sources.some((source) => source.refreshing));
391 assert.strictEqual(cursorSource(refreshed)?.status, "ok");
392 assert.strictEqual(totalOutputTokens(refreshed), 5);
393 assert.strictEqual(cursor.state.calls.length, 1);
394
395 // Inside the TTL a plain read answers from the cache without refreshing.
396 yield* TestClock.adjust(Duration.seconds(30));
397 const cached = yield* service.readSummary(WINDOW);
398 assert.isUndefined(cursorSource(cached)?.refreshing);
399 assert.strictEqual(totalOutputTokens(cached), 5);
400 assert.strictEqual(cursor.state.calls.length, 1);
401
402 // Past it, a plain read answers from the stale cache and refreshes.
403 yield* TestClock.adjust(Duration.seconds(31));
404 cursor.state.events.push({ timestampMs: CURSOR_NOW, outputTokens: 7 });
405 const stale = yield* service.readSummary(WINDOW);
406 assert.strictEqual(cursorSource(stale)?.refreshing, true);
407 assert.strictEqual(totalOutputTokens(stale), 5);
408 const fresh = yield* service.readSummary({ ...WINDOW, awaitRefresh: true });
409 assert.isUndefined(cursorSource(fresh)?.refreshing);
410 assert.strictEqual(totalOutputTokens(fresh), 12);
411 assert.strictEqual(cursor.state.calls.length, 2);
412 }).pipe(Effect.provide(layerService({ prefix: "usage-service-cursor-swr", home, settings })));
413 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
414 );
415
416 it.live("caches Cursor's whole retention and then refetches only the newest edge", () =>
417 Effect.gen(function* () {
418 const { settings, home } = yield* setup;
419 yield* writeCursorLogin(home);
420 yield* TestClock.setTime(CURSOR_NOW);
421 const cursor = makeFakeCursor();
422 // Two identical billed rows inside the refetched hour must both survive it, once each.
423 cursor.state.events = [
424 { timestampMs: Date.parse("2026-07-10T10:00:00Z"), outputTokens: 17 },
425 { timestampMs: CURSOR_NOW - 24 * HOUR_MS, outputTokens: 5 },
426 { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 },
427 { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 },
428 ];
429 yield* Effect.gen(function* () {
430 const service = yield* UsageService.make.pipe(
431 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }),
432 );
433 const read = (input: UsageSummaryInput) =>
434 service.readSummary({ ...input, awaitRefresh: true });
435 assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 19);
436 assert.deepStrictEqual(cursor.state.calls, [
437 { sinceMs: CURSOR_NOW - CURSOR_RETENTION_MS, untilMs: CURSOR_NOW },
438 ]);
439
440 // A wider window is answered from the same cache.
441 const wide = { ...WINDOW, sinceDay: UsageDay.make("2026-07-01") };
442 assert.strictEqual(totalOutputTokens(yield* read(wide)), 36);
443 assert.strictEqual(cursor.state.calls.length, 1);
444
445 // An event finalized late, inside the overlap, and a new one.
446 cursor.state.events.push(
447 { timestampMs: CURSOR_NOW - HOUR_MS / 6, outputTokens: 11 },
448 { timestampMs: CURSOR_NOW + HOUR_MS, outputTokens: 13 },
449 );
450 yield* TestClock.adjust(Duration.hours(2));
451 assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 43);
452 assert.deepStrictEqual(cursor.state.calls[1], {
453 sinceMs: CURSOR_NOW - HOUR_MS,
454 untilMs: CURSOR_NOW + 2 * HOUR_MS,
455 });
456
457 // Another login replaces the cached account's history instead of adding to it.
458 cursor.state.accountKey = "account-b";
459 yield* TestClock.adjust(Duration.minutes(2));
460 assert.strictEqual(totalOutputTokens(yield* read(wide)), 60);
461 assert.strictEqual(cursor.state.calls.length, 4);
462 assert.strictEqual(cursorSource(yield* read(wide))?.fingerprint.volumeId, "account-b");
463 }).pipe(
464 Effect.provide(
465 layerService({ prefix: "usage-service-cursor-incremental", home, settings }),
466 ),
467 );
468 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
469 );
470
471 it.live("reports a failed Cursor refresh from its cache without refreshing", () =>
472 Effect.gen(function* () {
473 const { settings, home } = yield* setup;
474 yield* writeCursorLogin(home);
475 yield* TestClock.setTime(CURSOR_NOW);
476 const cursor = makeFakeCursor();
477 cursor.state.events = [{ timestampMs: CURSOR_NOW - HOUR_MS * 3, outputTokens: 5 }];
478 yield* Effect.gen(function* () {
479 const service = yield* UsageService.make.pipe(
480 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }),
481 );
482 yield* service.readSummary({ ...WINDOW, awaitRefresh: true });
483
484 yield* TestClock.adjust(Duration.minutes(2));
485 cursor.state.error = "Sign in to Cursor again to read account usage.";
486 const failed = yield* service.readSummary({ ...WINDOW, awaitRefresh: true });
487 assert.strictEqual(cursorSource(failed)?.status, "partial");
488 assert.strictEqual(cursorSource(failed)?.message, cursor.state.error);
489 assert.isUndefined(cursorSource(failed)?.refreshing);
490 assert.strictEqual(totalOutputTokens(failed), 5);
491
492 // The failure stands for the TTL; a plain read does not retry it.
493 const again = yield* service.readSummary(WINDOW);
494 assert.strictEqual(cursorSource(again)?.status, "partial");
495 assert.isUndefined(cursorSource(again)?.refreshing);
496 assert.strictEqual(cursor.state.calls.length, 2);
497
498 // A failing read of another login drops the previous account's history.
499 yield* TestClock.adjust(Duration.minutes(2));
500 cursor.state.accountKey = "account-b";
501 const switched = yield* service.readSummary({ ...WINDOW, awaitRefresh: true });
502 assert.strictEqual(cursorSource(switched)?.status, "missing");
503 assert.strictEqual(cursorSource(switched)?.message, cursor.state.error);
504 assert.strictEqual(totalOutputTokens(switched), 0);
505 }).pipe(
506 Effect.provide(layerService({ prefix: "usage-service-cursor-failure", home, settings })),
507 );
508 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
509 );
510
511 it.live("restores the Cursor account cache after a restart", () =>
512 Effect.gen(function* () {
513 const { settings, home } = yield* setup;
514 yield* writeCursorLogin(home);
515 yield* TestClock.setTime(CURSOR_NOW);
516 const before = makeFakeCursor();
517 before.state.events = [
518 { timestampMs: CURSOR_NOW - 30 * HOUR_MS, outputTokens: 5 },
519 { timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 },
520 ];
521 yield* Effect.gen(function* () {
522 const first = yield* UsageService.make.pipe(
523 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: before.read }),
524 );
525 const original = yield* first.readSummary({ ...WINDOW, awaitRefresh: true });
526 yield* first.awaitPersisted;
527
528 const after = makeFakeCursor();
529 after.state.events = before.state.events;
530 const restarted = yield* UsageService.make.pipe(
531 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: after.read }),
532 );
533 const restored = yield* restarted.readSummary(WINDOW);
534 assert.isUndefined(cursorSource(restored)?.refreshing);
535 assert.deepStrictEqual(restored.buckets, original.buckets);
536 assert.deepStrictEqual(cursorSource(restored), cursorSource(original));
537 assert.strictEqual(after.state.calls.length, 0);
538
539 // Once stale, the restored cache refreshes only its newest edge.
540 yield* TestClock.adjust(Duration.minutes(2));
541 const refreshed = yield* restarted.readSummary({ ...WINDOW, awaitRefresh: true });
542 assert.deepStrictEqual(refreshed.buckets, original.buckets);
543 assert.deepStrictEqual(after.state.calls, [
544 { sinceMs: CURSOR_NOW - HOUR_MS, untilMs: CURSOR_NOW + 2 * 60 * 1000 },
545 ]);
546 }).pipe(
547 Effect.provide(layerService({ prefix: "usage-service-cursor-restart", home, settings })),
548 );
549 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
550 );
551
291552 it.live("does not read the macOS Cursor Keychain before account usage is enabled", () =>
292553 Effect.gen(function* () {
293554 const { settings, home } = yield* setup;
......@@ -642,6 +903,7 @@ describe("UsageService", () => {
642903 environmentProjects,
643904 );
644905 }).pipe(
906 Effect.scoped,
645907 Effect.provide(
646908 layerService({
647909 prefix: "usage-service-home-refresh-test",
......@@ -747,6 +1009,7 @@ describe("UsageService", () => {
7471009 const restored = yield* service.readSummary(WINDOW);
7481010 assert.deepStrictEqual(restored.buckets, original.buckets);
7491011 }).pipe(
1012 Effect.scoped,
7501013 Effect.provide(
7511014 layerService({ prefix: "usage-service-price-overrides-test", home, settings }),
7521015 ),
......@@ -801,9 +1064,11 @@ describe("UsageService", () => {
8011064 appended.buckets.reduce((sum, bucket) => sum + bucket.totals.uncachedInputTokens, 0),
8021065 20,
8031066 );
1067 yield* service.awaitPersisted;
8041068 const restarted = yield* UsageService.make;
8051069 const restored = yield* restarted.readSummary(WINDOW);
8061070 assert.deepStrictEqual(restored.buckets, appended.buckets);
1071 yield* restarted.awaitPersisted;
8071072 yield* Effect.promise(() => NodeFSP.rm(transcript));
8081073 const afterCleanup = yield* UsageService.make;
8091074 assert.deepStrictEqual(
......@@ -811,6 +1076,7 @@ describe("UsageService", () => {
8111076 appended.buckets,
8121077 );
8131078 }).pipe(
1079 Effect.scoped,
8141080 Effect.provide(
8151081 layerService({
8161082 prefix: "usage-service-large-record-test",
......@@ -865,7 +1131,9 @@ describe("UsageService", () => {
8651131 const { stateDir } = yield* ServerConfig.ServerConfig;
8661132 const cachePath = NodePath.join(stateDir, "usage-scan-cache-v5.json");
8671133 const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json");
868 yield* (yield* UsageService.make).readSummary(WINDOW);
1134 const first = yield* UsageService.make;
1135 yield* first.readSummary(WINDOW);
1136 yield* first.awaitPersisted;
8691137
8701138 // Rewrite the cache as a v4 server left it: every Codex record at
8711139 // speed 0 (standard), and no tier in the reducer state.
......@@ -937,6 +1205,7 @@ describe("UsageService", () => {
9371205 assert.deepStrictEqual(deleted.buckets, first.buckets);
9381206 assert.deepStrictEqual(deleted.sources, first.sources);
9391207
1208 yield* service.awaitPersisted;
9401209 const restarted = yield* UsageService.make;
9411210 const restored = yield* restarted.readSummary(WINDOW);
9421211 assert.deepStrictEqual(restored.buckets, first.buckets);
......@@ -947,6 +1216,7 @@ describe("UsageService", () => {
9471216 const moved = yield* restarted.readSummary(WINDOW);
9481217 assert.deepStrictEqual(moved.buckets, first.buckets);
9491218 assert.strictEqual(moved.sources[0]?.distinctSessions, 1);
1219 yield* restarted.awaitPersisted;
9501220
9511221 const replacementProjects = NodePath.join(home, "replacement-projects");
9521222 yield* Effect.promise(() => NodeFSP.mkdir(replacementProjects));
......@@ -994,6 +1264,9 @@ describe("UsageService", () => {
9941264 assert.deepStrictEqual(outsideWindow.buckets, []);
9951265 assert.strictEqual(outsideWindow.sources[0]?.distinctSessions, 0);
9961266 }).pipe(
1267 // Scoped inside the state directory, so pending cache writes land
1268 // before it is removed.
1269 Effect.scoped,
9971270 Effect.provide(
9981271 layerService({
9991272 prefix: "usage-service-cleanup-test",
......@@ -1039,6 +1312,7 @@ describe("UsageService", () => {
10391312 const saved = yield* service.readSummary(WINDOW);
10401313 assert.deepStrictEqual(saved.buckets, live.buckets);
10411314 assert.deepStrictEqual(saved.sources, live.sources);
1315 yield* service.awaitPersisted;
10421316 const restored = yield* (yield* UsageService.make).readSummary(WINDOW);
10431317 assert.deepStrictEqual(restored.buckets, live.buckets);
10441318 }).pipe(
apps/server/src/usage/UsageService.ts+467-212
......@@ -12,6 +12,10 @@
1212 * remain visible. Antigravity databases are memoised in memory while the
1313 * database and its WAL keep the same `(size, mtime, ctime)`.
1414 *
15 * Cursor's account API is slow, so its source answers from a cache and marks
16 * itself `refreshing` while a background refresh runs; `awaitRefresh` waits
17 * for that refresh instead. See `cursorAccountCache`.
18 *
1519 * @module UsageService
1620 */
1721import * as NodeOS from "node:os";
......@@ -38,6 +42,8 @@ import * as Crypto from "effect/Crypto";
3842import * as DateTime from "effect/DateTime";
3943import * as Deferred from "effect/Deferred";
4044import * as Effect from "effect/Effect";
45import * as Exit from "effect/Exit";
46import * as Fiber from "effect/Fiber";
4147import * as FileSystem from "effect/FileSystem";
4248import * as Layer from "effect/Layer";
4349import * as Option from "effect/Option";
......@@ -55,7 +61,18 @@ import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAu
5561import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";
5662import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
5763import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";
58import { readCursorAccountUsage } from "./cursorUsageReader.ts";
64import {
65 CURSOR_ACCOUNT_CACHE_FILE_NAME,
66 CURSOR_ACCOUNT_TTL_MS,
67 cursorFetchRange,
68 decodeCursorAccountCaches,
69 encodeCursorAccountCaches,
70 isCursorCacheFresh,
71 mergeCursorFetch,
72 type CursorAccountCache,
73 type CursorCredentialSource,
74} from "./cursorAccountCache.ts";
75import * as CursorUsageReader from "./cursorUsageReader.ts";
5976import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
6077import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
6178import {
......@@ -91,8 +108,13 @@ const RATES_REFRESH_FLOOR_MS = 60 * 1000;
91108const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;
92109const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;
93110
94/** Longest window the UI offers, plus slack. Older entries are pruned. */
95const CACHE_RETENTION_DAYS = 90;
111/**
112 * The longest window the UI offers, 90 days, plus its `MTIME_SLACK_MS`, rounded
113 * up. Older entries are pruned.
114 */
115const CACHE_RETENTION_DAYS = 92;
116
117const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read.";
96118
97119/** Transcripts parsed at once. More gains little once the disk stays busy. */
98120const TRANSCRIPT_READ_CONCURRENCY = 4;
......@@ -195,11 +217,26 @@ export const make = Effect.gen(function* () {
195217 const httpClient = yield* HttpClient.HttpClient;
196218 const hostEnvironment = yield* HostProcessEnvironment;
197219 const platform = yield* HostProcessPlatform;
220 const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader;
198221
199222 const fileCache: ScanCache = new Map();
200223 const antigravityCache = makeAntigravityUsageCache();
201224 const sourceCache = new Map<string, typeof CachedSource.Type>();
202225 let cacheDirty = false;
226 /** Cursor account caches by credential source. */
227 const cursorCaches = new Map<string, CursorAccountCache>();
228 let cursorCacheDirty = false;
229 /**
230 * The last failed refresh per credential source, standing for a TTL so a
231 * client refetching a broken login does not refetch Cursor each time. A
232 * `null` error means there is no login: no source to report.
233 */
234 const cursorFailures = new Map<
235 string,
236 { readonly atMs: number; readonly error: string | null }
237 >();
238 /** The refresh in flight per credential source, which every read joins. */
239 const cursorRefreshes = new Map<string, Deferred.Deferred<void>>();
203240 const isWithinDirectory = (filePath: string, dir: string) => {
204241 const relative = path.relative(dir, filePath);
205242 return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
......@@ -208,6 +245,7 @@ export const make = Effect.gen(function* () {
208245 const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");
209246 const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME);
210247 const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME);
248 const cursorCachePath = path.join(config.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME);
211249 const writeCacheFile = (filePath: string, contents: string) =>
212250 writeFileStringAtomically({ filePath, contents }).pipe(
213251 Effect.provideService(FileSystem.FileSystem, fileSystem),
......@@ -398,7 +436,7 @@ export const make = Effect.gen(function* () {
398436 });
399437
400438 /**
401 * Loads the persisted scan cache exactly once per process.
439 * Loads the persisted scan and Cursor account caches exactly once per process.
402440 *
403441 * `Effect.cached` makes concurrent first readers await the same load rather
404442 * than each seeing a "loaded" flag set before the read finished and cold
......@@ -411,6 +449,9 @@ export const make = Effect.gen(function* () {
411449 Effect.flatMap((raw) => decodeScanCacheFile(raw)),
412450 Effect.catchCause(() => Effect.succeed(null)),
413451 );
452 for (const [key, cache] of decodeCursorAccountCaches(yield* readDocument(cursorCachePath))) {
453 cursorCaches.set(key, cache);
454 }
414455 let document = yield* readDocument(scanCachePath);
415456 if (document === null) {
416457 document = yield* readDocument(legacyScanCachePath);
......@@ -432,25 +473,52 @@ export const make = Effect.gen(function* () {
432473 // keeps an older snapshot from landing after a newer one.
433474 const persistLock = yield* Semaphore.make(1);
434475
435 const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () {
436 if (!cacheDirty) return;
437 // Cleared before encoding, so a scan that changes the cache while this
438 // write is in flight marks it dirty again. A failed write restores the
439 // flag, so the next scan retries instead of leaving disk stale.
440 cacheDirty = false;
441 yield* Effect.sync(() =>
442 writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }),
443 ).pipe(
444 Effect.flatMap((contents) => writeCacheFile(scanCachePath, contents)),
445 // A cache we cannot write is a slower next start, not a failed read.
446 Effect.catchCause(() =>
447 Effect.sync(() => {
448 cacheDirty = true;
449 }),
450 ),
451 persistLock.withPermit,
452 );
453 });
476 // Each dirty flag is cleared before encoding, so a change while the write is
477 // in flight marks it dirty again. A failed write restores the flag, so the
478 // next persist retries instead of leaving disk stale. A cache we cannot
479 // write is a slower next start, not a failed read.
480 const persistCaches = Effect.gen(function* () {
481 if (cacheDirty) {
482 cacheDirty = false;
483 yield* Effect.sync(() =>
484 writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }),
485 ).pipe(
486 Effect.flatMap((contents) => writeCacheFile(scanCachePath, contents)),
487 Effect.catchCause(() =>
488 Effect.sync(() => {
489 cacheDirty = true;
490 }),
491 ),
492 );
493 }
494 if (cursorCacheDirty) {
495 cursorCacheDirty = false;
496 yield* Effect.sync(() => encodeCursorAccountCaches(cursorCaches)).pipe(
497 Effect.flatMap((contents) => writeCacheFile(cursorCachePath, contents)),
498 Effect.catchCause(() =>
499 Effect.sync(() => {
500 cursorCacheDirty = true;
501 }),
502 ),
503 );
504 }
505 }).pipe(persistLock.withPermit, Effect.withSpan("UsageService.persistCaches"));
506
507 const pendingPersists = new Set<Fiber.Fiber<void>>();
508 /** Writes dirty caches in the background, after the summary that dirtied them answers. */
509 const schedulePersist = Effect.forkDetach(persistCaches).pipe(
510 Effect.map((fiber) => {
511 pendingPersists.add(fiber);
512 fiber.addObserver(() => pendingPersists.delete(fiber));
513 }),
514 );
515 /** Waits for every write scheduled so far, as a restart would need. */
516 const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe(
517 Effect.asVoid,
518 );
519 // A write still running when the service shuts down finishes first, so the
520 // next start does not lose the last scan.
521 yield* Effect.addFinalizer(() => awaitPersisted);
454522
455523 /**
456524 * Parses one transcript, reusing the cached result when it is unchanged.
......@@ -541,58 +609,223 @@ export const make = Effect.gen(function* () {
541609 readonly files:
542610 | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]
543611 | null;
612 /** Answered from a cache while a refresh runs. */
613 readonly refreshing?: true;
544614 }
545615
616 const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* (
617 source: {
618 readonly provider: UsageProviderKind;
619 readonly dir: string;
620 readonly volumeId: string;
621 readonly fileName?: string;
622 },
623 windowStartMs: number,
624 ) {
625 const { provider, dir, volumeId, fileName } = source;
626 const exists = yield* fileSystem
627 .exists(dir)
628 .pipe(Effect.catchCause(() => Effect.succeed(false)));
629 if (!exists) return { provider, dir, volumeId, files: null } satisfies ScannedDir;
630 const files = yield* Effect.promise(() =>
631 listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }),
632 );
633 // A cold parse waits on disk reads, so a few files in flight read
634 // close to twice as fast. Results keep walk order.
635 const read = yield* Effect.forEach(
636 files,
637 (file) =>
638 readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe(
639 Effect.map((result) => ({ path: file.path, ...result })),
640 ),
641 { concurrency: TRANSCRIPT_READ_CONCURRENCY },
642 );
643 const parsedFiles = read.map(({ path, records, update }) => {
644 if (update === undefined) return { path, records };
645 // A scan of another window may have cached its own read of this file
646 // meanwhile. Then keep whichever read saw the later file, so a slower
647 // scan never replaces newer usage with older.
648 const current = fileCache.get(path);
649 if (
650 current === update.replaces ||
651 current === undefined ||
652 !isLaterRead(current, update.entry)
653 ) {
654 fileCache.set(path, update.entry);
655 cacheDirty = true;
656 }
657 return { path, records };
658 });
659 return { provider, dir, volumeId, files: parsedFiles } satisfies ScannedDir;
660 });
661
662 /** Fetches what one account cache is missing, then persists it if anything changed. */
663 const refreshCursorAccount = Effect.fn("UsageService.refreshCursorAccount")(function* (
664 credential: CursorCredentialSource,
665 credentialKey: string,
666 retentionStartMs: number,
667 ) {
668 const nowMs = yield* Clock.currentTimeMillis;
669 const fetchMissing = (cache: CursorAccountCache | undefined) => {
670 const range = cursorFetchRange(cache, retentionStartMs, nowMs);
671 return cursorAccountReader
672 .read(credential, range.sinceMs, range.untilMs)
673 .pipe(Effect.map((result) => ({ range, result })));
674 };
675 let base = cursorCaches.get(credentialKey);
676 let fetched = yield* fetchMissing(base);
677 // Another login's history replaces the cached account's, even if reading it fails.
678 if (
679 base !== undefined &&
680 fetched.result.accountKey !== null &&
681 fetched.result.accountKey !== base.accountKey
682 ) {
683 cursorCaches.delete(credentialKey);
684 cursorCacheDirty = true;
685 base = undefined;
686 fetched = yield* fetchMissing(base);
687 }
688 const { range, result } = fetched;
689 if (result.missing || result.error !== null || result.accountKey === null) {
690 cursorFailures.set(credentialKey, {
691 atMs: nowMs,
692 error: result.missing || result.error !== null ? result.error : CURSOR_ACCOUNT_READ_ERROR,
693 });
694 yield* schedulePersist;
695 return;
696 }
697 const merged = mergeCursorFetch(
698 base,
699 result.accountKey,
700 range,
701 result.records,
702 nowMs,
703 retentionStartMs,
704 );
705 cursorCaches.set(credentialKey, merged.cache);
706 cursorFailures.delete(credentialKey);
707 // An unchanged edge is not worth rewriting the file for: after a restart
708 // the cache just refetches a slightly wider edge.
709 if (merged.changed) {
710 cursorCacheDirty = true;
711 yield* schedulePersist;
712 }
713 });
714
715 /** Joins the refresh in flight, or starts one. */
716 const startCursorRefresh = (
717 credential: CursorCredentialSource,
718 credentialKey: string,
719 retentionStartMs: number,
720 ) =>
721 // Enrollment and fork are atomic, so an interrupted caller cannot leave a
722 // registered refresh that nothing will finish.
723 Effect.uninterruptible(
724 Effect.gen(function* () {
725 const current = cursorRefreshes.get(credentialKey);
726 if (current !== undefined) return current;
727 const done = Deferred.makeUnsafe<void>();
728 cursorRefreshes.set(credentialKey, done);
729 // Detached: a departing client must not cancel a fetch later reads reuse.
730 yield* refreshCursorAccount(credential, credentialKey, retentionStartMs).pipe(
731 Effect.catchCause(() =>
732 Clock.currentTimeMillis.pipe(
733 Effect.map((atMs) =>
734 cursorFailures.set(credentialKey, { atMs, error: CURSOR_ACCOUNT_READ_ERROR }),
735 ),
736 ),
737 ),
738 Effect.ensuring(
739 Effect.suspend(() => {
740 cursorRefreshes.delete(credentialKey);
741 return Deferred.succeed(done, undefined);
742 }),
743 ),
744 Effect.forkDetach,
745 );
746 return done;
747 }),
748 );
749
750 /**
751 * The Cursor account source. A cache inside its TTL, or a refresh that failed
752 * inside it, answers directly. Otherwise a refresh starts: `awaitRefresh`
753 * waits for it, and anything else answers from the cache marked `refreshing`.
754 */
755 const cursorAccountSource = Effect.fn("UsageService.cursorAccountSource")(function* (
756 credential: CursorCredentialSource,
757 authPath: string,
758 windowStartMs: number,
759 retentionStartMs: number,
760 awaitRefresh: boolean,
761 ) {
762 // No saved login means there is no account source to report, not a setup error.
763 if (
764 typeof credential === "string" &&
765 !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true)))
766 ) {
767 return null;
768 }
769 const credentialKey = typeof credential === "string" ? credential : "keychain";
770 const nowMs = yield* Clock.currentTimeMillis;
771 const recentFailure = cursorFailures.get(credentialKey);
772 let refreshing = false;
773 if (
774 (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) &&
775 !isCursorCacheFresh(cursorCaches.get(credentialKey), nowMs)
776 ) {
777 const refresh = yield* startCursorRefresh(credential, credentialKey, retentionStartMs);
778 if (awaitRefresh) yield* Deferred.await(refresh);
779 else refreshing = true;
780 }
781
782 const cache = cursorCaches.get(credentialKey);
783 const failure = refreshing ? undefined : cursorFailures.get(credentialKey);
784 const failureMessage = failure === undefined ? undefined : failure.error;
785 if (failureMessage === null) return null;
786 if (cache === undefined) {
787 return {
788 provider: "cursor",
789 dir: authPath,
790 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(authPath)),
791 // Never combine a local fallback with another server's account-wide history.
792 ...(refreshing
793 ? { files: [], refreshing: true }
794 : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }),
795 } satisfies ScannedDir;
796 }
797 // The same account includes CLI and desktop history from every machine.
798 // A stable remote fingerprint prevents connected environments counting it twice.
799 const source = `cursor-account:${cache.accountKey}`;
800 return {
801 provider: "cursor",
802 dir: source,
803 hostId: "cursor.com",
804 volumeId: cache.accountKey,
805 files: [
806 {
807 path: source,
808 records: cache.records.filter((record) => record.timestampMs >= windowStartMs),
809 },
810 ],
811 ...(failureMessage === undefined
812 ? { status: "ok" }
813 : { status: "partial", message: failureMessage }),
814 ...(refreshing ? { refreshing: true } : {}),
815 } satisfies ScannedDir;
816 });
817
546818 const collectDirs = Effect.fn("UsageService.collectDirs")(function* (
547819 windowStartMs: number,
548820 settings: ServerSettingsValue,
549821 retentionCutoffMs: number,
822 awaitRefresh: boolean,
550823 ) {
551824 // The home resolvers ask for `Path` themselves; satisfy them from the
552825 // instance we already hold so the scan stays context-free.
553826 const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe(
554827 Effect.provideService(Path.Path, path),
555828 );
556 const scanned: ScannedDir[] = [];
557 for (const { provider, dir, volumeId, fileName } of dirs) {
558 const exists = yield* fileSystem
559 .exists(dir)
560 .pipe(Effect.catchCause(() => Effect.succeed(false)));
561 if (!exists) {
562 scanned.push({ provider, dir, volumeId, files: null });
563 continue;
564 }
565 const files = yield* Effect.promise(() =>
566 listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }),
567 );
568 // A cold parse waits on disk reads, so a few files in flight read
569 // close to twice as fast. Results keep walk order.
570 const read = yield* Effect.forEach(
571 files,
572 (file) =>
573 readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe(
574 Effect.map((result) => ({ path: file.path, ...result })),
575 ),
576 { concurrency: TRANSCRIPT_READ_CONCURRENCY },
577 );
578 const parsedFiles = read.map(({ path, records, update }) => {
579 if (update === undefined) return { path, records };
580 // A scan of another window may have cached its own read of this file
581 // meanwhile. Then keep whichever read saw the later file, so a slower
582 // scan never replaces newer usage with older.
583 const current = fileCache.get(path);
584 if (
585 current === update.replaces ||
586 current === undefined ||
587 !isLaterRead(current, update.entry)
588 ) {
589 fileCache.set(path, update.entry);
590 cacheDirty = true;
591 }
592 return { path, records };
593 });
594 scanned.push({ provider, dir, volumeId, files: parsedFiles });
595 }
596829
597830 const home = NodeOS.homedir();
598831 const envRoots = Effect.fnUntraced(function* (key: string, defaults: readonly string[]) {
......@@ -610,156 +843,168 @@ export const make = Effect.gen(function* () {
610843 return [...canonical];
611844 });
612845 const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim();
613 for (const dir of yield* envRoots("OPENCODE_DATA_DIR", [
614 path.join(
615 dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"),
616 "opencode",
617 ),
618 ])) {
619 const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs));
620 scanned.push({
621 provider: "opencode",
622 dir,
623 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)),
624 files: result.missing && !result.error ? null : result.files,
625 status: result.error ? "partial" : "ok",
626 ...(result.error ? { message: "Some OpenCode history could not be read." } : {}),
627 });
628 }
629 const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [
630 ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) =>
631 path.join(home, ".gemini", name),
632 ),
633 path.join(home, ".config", "antigravity"),
634 ]);
635 for (const [instanceId, instance] of Object.entries(settings.providerInstances)) {
636 if (instance.driver === "antigravity") {
637 const directories = yield* resolveAntigravityInstanceDirectories(
638 config.stateDir,
639 ProviderInstanceId.make(instanceId),
640 ).pipe(
641 Effect.provideService(Crypto.Crypto, crypto),
642 Effect.provideService(Path.Path, path),
643 Effect.mapError(
644 (cause) =>
645 new UsageReadError({
646 reason: "scanFailed",
647 detail: "Antigravity profile directory could not be resolved.",
648 cause,
649 }),
650 ),
651 );
652 antigravityRoots.push(path.join(directories.profile, "antigravity-acp"));
653 }
654 }
655 const antigravityDirs = new Set<string>();
656 for (const root of antigravityRoots) {
657 const resolvedRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root));
658 const nested = path.join(resolvedRoot, "conversations");
659 const dir = (yield* fileSystem
660 .exists(nested)
661 .pipe(Effect.catchCause(() => Effect.succeed(false))))
662 ? nested
663 : resolvedRoot;
664 antigravityDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir)));
665 }
666 const antigravity = yield* Effect.promise(() =>
667 readAntigravityUsage([...antigravityDirs], windowStartMs, antigravityCache),
668 );
669 for (const dir of antigravityDirs) {
670 const exists = yield* fileSystem
671 .exists(dir)
672 .pipe(Effect.catchCause(() => Effect.succeed(false)));
673 const failed = antigravity.errors.some(
674 (error) => error === dir || error.startsWith(`${dir}${path.sep}`),
846
847 const openCode = Effect.gen(function* () {
848 const roots = yield* envRoots("OPENCODE_DATA_DIR", [
849 path.join(
850 dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"),
851 "opencode",
852 ),
853 ]);
854 return yield* Effect.forEach(
855 roots,
856 (dir) =>
857 Effect.gen(function* () {
858 const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs));
859 return {
860 provider: "opencode",
861 dir,
862 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)),
863 files: result.missing && !result.error ? null : result.files,
864 status: result.error ? "partial" : "ok",
865 ...(result.error ? { message: "Some OpenCode history could not be read." } : {}),
866 } satisfies ScannedDir;
867 }),
868 { concurrency: "unbounded" },
675869 );
676 scanned.push({
677 provider: "antigravity",
678 dir,
679 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)),
680 files: !exists && !failed ? null : antigravity.files.filter((file) => file.root === dir),
681 status: failed ? "partial" : "ok",
682 ...(failed ? { message: "Some Antigravity history could not be read." } : {}),
683 });
684 }
685 const cursorUserHome =
686 (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home;
687 const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim();
688 const cursorHome =
689 platform === "darwin"
690 ? path.join(cursorUserHome, "Library", "Application Support")
691 : platform === "win32"
692 ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming")
693 : configHome && path.isAbsolute(configHome)
694 ? configHome
695 : path.join(cursorUserHome, ".config");
696 const cursorAuthPath =
697 platform === "darwin"
698 ? path.join(cursorUserHome, ".cursor", "auth.json")
699 : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json");
700 const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"];
701 const loginUnavailable =
702 Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) ||
703 Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) ||
704 credentialStore === "memory";
705 if (
706 platform === "darwin" &&
707 credentialStore !== "file" &&
708 !loginUnavailable &&
709 !settings.cursorKeychainUsageEnabled
710 ) {
711 scanned.push({
712 provider: "cursor",
713 dir: cursorAuthPath,
714 volumeId: "",
715 files: null,
716 message: "Cursor account usage is off on this environment.",
717 action: "enableCursorKeychain",
718 });
719 return scanned;
720 }
721 const cursorUntilMs = yield* Clock.currentTimeMillis;
722 const account = loginUnavailable
723 ? {
724 accountKey: null,
725 records: [],
726 missing: true,
727 error: "Cursor account history needs a Cursor CLI login on this server.",
870 });
871
872 const antigravity = Effect.gen(function* () {
873 const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [
874 ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) =>
875 path.join(home, ".gemini", name),
876 ),
877 path.join(home, ".config", "antigravity"),
878 ]);
879 for (const [instanceId, instance] of Object.entries(settings.providerInstances)) {
880 if (instance.driver === "antigravity") {
881 const directories = yield* resolveAntigravityInstanceDirectories(
882 config.stateDir,
883 ProviderInstanceId.make(instanceId),
884 ).pipe(
885 Effect.provideService(Crypto.Crypto, crypto),
886 Effect.provideService(Path.Path, path),
887 Effect.mapError(
888 (cause) =>
889 new UsageReadError({
890 reason: "scanFailed",
891 detail: "Antigravity profile directory could not be resolved.",
892 cause,
893 }),
894 ),
895 );
896 antigravityRoots.push(path.join(directories.profile, "antigravity-acp"));
728897 }
729 : yield* Effect.promise(() =>
730 readCursorAccountUsage(
731 platform === "darwin" && credentialStore !== "file"
732 ? { kind: "keychain" }
733 : cursorAuthPath,
734 windowStartMs,
735 cursorUntilMs,
736 ),
898 }
899 const antigravityDirs = new Set<string>();
900 for (const root of antigravityRoots) {
901 const resolvedRoot = yield* fileSystem
902 .realPath(root)
903 .pipe(Effect.orElseSucceed(() => root));
904 const nested = path.join(resolvedRoot, "conversations");
905 const dir = (yield* fileSystem
906 .exists(nested)
907 .pipe(Effect.catchCause(() => Effect.succeed(false))))
908 ? nested
909 : resolvedRoot;
910 antigravityDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir)));
911 }
912 const result = yield* Effect.promise(() =>
913 readAntigravityUsage([...antigravityDirs], windowStartMs, antigravityCache),
914 );
915 const scanned: ScannedDir[] = [];
916 for (const dir of antigravityDirs) {
917 const exists = yield* fileSystem
918 .exists(dir)
919 .pipe(Effect.catchCause(() => Effect.succeed(false)));
920 const failed = result.errors.some(
921 (error) => error === dir || error.startsWith(`${dir}${path.sep}`),
737922 );
738 // No saved login means there is no account source to report, not a setup error.
739 if (account.missing && account.error === null) return scanned;
740 if (account.accountKey !== null && account.error === null && !account.missing) {
741 // The same account includes CLI and desktop history from every machine.
742 // A stable remote fingerprint prevents connected environments counting it twice.
743 const source = `cursor-account:${account.accountKey}`;
744 scanned.push({
745 provider: "cursor",
746 dir: source,
747 hostId: "cursor.com",
748 volumeId: account.accountKey,
749 files: [{ path: source, records: account.records }],
750 status: "ok",
751 });
923 scanned.push({
924 provider: "antigravity",
925 dir,
926 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)),
927 files: !exists && !failed ? null : result.files.filter((file) => file.root === dir),
928 status: failed ? "partial" : "ok",
929 ...(failed ? { message: "Some Antigravity history could not be read." } : {}),
930 });
931 }
752932 return scanned;
753 }
754 scanned.push({
755 provider: "cursor",
756 dir: cursorAuthPath,
757 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)),
758 // Never combine a local fallback with another server's account-wide history.
759 files: null,
760 message:
761 account.error ?? "Cursor account history needs a Cursor CLI login saved on this server.",
762933 });
934
935 const cursor = Effect.gen(function* () {
936 const cursorUserHome =
937 (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home;
938 const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim();
939 const cursorHome =
940 platform === "darwin"
941 ? path.join(cursorUserHome, "Library", "Application Support")
942 : platform === "win32"
943 ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming")
944 : configHome && path.isAbsolute(configHome)
945 ? configHome
946 : path.join(cursorUserHome, ".config");
947 const cursorAuthPath =
948 platform === "darwin"
949 ? path.join(cursorUserHome, ".cursor", "auth.json")
950 : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json");
951 const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"];
952 const loginUnavailable =
953 Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) ||
954 Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) ||
955 credentialStore === "memory";
956 const useKeychain = platform === "darwin" && credentialStore !== "file";
957 if (useKeychain && !loginUnavailable && !settings.cursorKeychainUsageEnabled) {
958 return [
959 {
960 provider: "cursor",
961 dir: cursorAuthPath,
962 volumeId: "",
963 files: null,
964 message: "Cursor account usage is off on this environment.",
965 action: "enableCursorKeychain",
966 } satisfies ScannedDir,
967 ];
968 }
969 if (loginUnavailable) {
970 return [
971 {
972 provider: "cursor",
973 dir: cursorAuthPath,
974 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)),
975 files: null,
976 message: "Cursor account history needs a Cursor CLI login on this server.",
977 } satisfies ScannedDir,
978 ];
979 }
980 const source = yield* cursorAccountSource(
981 useKeychain ? { kind: "keychain" } : cursorAuthPath,
982 cursorAuthPath,
983 windowStartMs,
984 retentionCutoffMs,
985 awaitRefresh,
986 );
987 return source === null ? [] : [source];
988 });
989
990 // Independent sources scan together. Transcript directories go one at a
991 // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result
992 // keeps this order, since aggregation keeps the first copy of a duplicate.
993 const [transcripts, openCodeDirs, antigravityDirs, cursorDirs] = yield* Effect.all(
994 [
995 Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)),
996 openCode,
997 antigravity,
998 cursor,
999 ],
1000 { concurrency: "unbounded" },
1001 );
1002 const scanned: readonly ScannedDir[] = [
1003 ...transcripts,
1004 ...openCodeDirs,
1005 ...antigravityDirs,
1006 ...cursorDirs,
1007 ];
7631008 return scanned;
7641009 });
7651010
......@@ -818,7 +1063,10 @@ export const make = Effect.gen(function* () {
8181063 // loads while transcripts stream instead of gating them: a cold rates
8191064 // fetch on a slow network no longer delays the scan by its own timeout.
8201065 const [, scannedDirs] = yield* Effect.all(
821 [ensureRates(false), collectDirs(windowStartMs, settings, retentionCutoffMs)],
1066 [
1067 ensureRates(false),
1068 collectDirs(windowStartMs, settings, retentionCutoffMs, input.awaitRefresh === true),
1069 ],
8221070 { concurrency: 2 },
8231071 );
8241072
......@@ -859,7 +1107,7 @@ export const make = Effect.gen(function* () {
8591107
8601108 for (const [
8611109 index,
862 { provider, dir, volumeId, files, status, message, action, hostId: sourceHostId },
1110 { provider, dir, volumeId, files, status, message, action, refreshing, hostId: sourceHostId },
8631111 ] of scannedDirs.entries()) {
8641112 let scannedFiles = 0;
8651113 let skippedFiles = 0;
......@@ -910,12 +1158,12 @@ export const make = Effect.gen(function* () {
9101158 message:
9111159 message ?? (files === null ? "No transcript directory on this environment." : null),
9121160 ...(action ? { action } : {}),
1161 ...(refreshing ? { refreshing } : {}),
9131162 });
9141163 }
9151164
9161165 const pruned = pruneScanCache(fileCache, retentionCutoffMs);
9171166 if (pruned > 0) cacheDirty = true;
918 yield* persistScanCache();
9191167
9201168 const aggregated = aggregator.finish();
9211169 const readAt = yield* DateTime.now;
......@@ -952,6 +1200,8 @@ export const make = Effect.gen(function* () {
9521200 settings.usagePriceOverrides,
9531201 settings.usageModelAliases,
9541202 settings.cursorKeychainUsageEnabled,
1203 // A waiting read must never share a scan that answers with `refreshing`.
1204 input.awaitRefresh === true,
9551205 ]);
9561206
9571207 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
......@@ -968,9 +1218,12 @@ export const make = Effect.gen(function* () {
9681218 inflightScans.set(key, created);
9691219 // Detached so one departing client cannot tear the scan out from under
9701220 // the fibers awaiting it; a finished scan warms the cache either way.
1221 // The cache write is registered before the waiters resume, so they
1222 // can await it, but its fiber starts after they have the summary.
9711223 yield* scanSummary(input, settings).pipe(
9721224 Effect.onExit((exit) =>
9731225 Effect.sync(() => inflightScans.delete(key)).pipe(
1226 Effect.andThen(Exit.isSuccess(exit) ? schedulePersist : Effect.void),
9741227 Effect.andThen(Deferred.done(created, exit)),
9751228 ),
9761229 ),
......@@ -984,7 +1237,9 @@ export const make = Effect.gen(function* () {
9841237 return yield* Deferred.await(deferred);
9851238 });
9861239
987 return { readSummary, refreshRates } as const;
1240 // `awaitPersisted` is outside the service interface: tests use it to restart
1241 // against what a previous instance wrote.
1242 return { readSummary, refreshRates, awaitPersisted } as const;
9881243});
9891244
9901245export const layer = Layer.effect(UsageService, make);
apps/server/src/usage/cursorAccountCache.test.ts created+80
......@@ -0,0 +1,80 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts";
4import type { UsageRecord } from "./usageTranscripts.ts";
5
6const DAY_MS = 24 * 60 * 60 * 1000;
7
8const record = (timestampMs: number): UsageRecord => ({
9 provider: "cursor",
10 timestampMs,
11 model: "claude-fable-5",
12 sessionId: "conversation-1",
13 totals: {
14 uncachedInputTokens: 0,
15 cachedInputTokens: 0,
16 cacheCreationTokens: 0,
17 outputTokens: 1,
18 reasoningTokens: 0,
19 },
20 reportedCostUsd: null,
21 speed: "standard",
22 dedupeKey: `cursor-account:a:${timestampMs}:0`,
23});
24
25describe("cursorAccountCache", () => {
26 it("refetches everything once the cache no longer reaches the retention start", () => {
27 const cache = {
28 accountKey: "a",
29 sinceMs: 0,
30 untilMs: 10 * DAY_MS,
31 fetchedAtMs: 10 * DAY_MS,
32 records: [record(5 * DAY_MS)],
33 };
34 const nowMs = 100 * DAY_MS;
35 const range = cursorFetchRange(cache, 10 * DAY_MS, nowMs);
36 assert.deepStrictEqual(range, { sinceMs: 10 * DAY_MS, untilMs: nowMs });
37
38 const { cache: merged } = mergeCursorFetch(
39 cache,
40 "a",
41 range,
42 [record(50 * DAY_MS)],
43 nowMs,
44 10 * DAY_MS,
45 );
46 assert.deepStrictEqual(
47 merged.records.map((entry) => entry.timestampMs),
48 [50 * DAY_MS],
49 );
50 // Then only the newest edge.
51 assert.deepStrictEqual(cursorFetchRange(merged, 11 * DAY_MS, nowMs + DAY_MS), {
52 sinceMs: nowMs - 60 * 60 * 1000,
53 untilMs: nowMs + DAY_MS,
54 });
55 });
56
57 it("reports an edge that only confirms the cache as unchanged", () => {
58 const cache = {
59 accountKey: "a",
60 sinceMs: 0,
61 untilMs: 10 * DAY_MS,
62 fetchedAtMs: 10 * DAY_MS,
63 records: [record(DAY_MS), record(10 * DAY_MS)],
64 };
65 const range = cursorFetchRange(cache, 0, 11 * DAY_MS);
66 assert.isFalse(
67 mergeCursorFetch(cache, "a", range, [record(10 * DAY_MS)], 11 * DAY_MS, 0).changed,
68 );
69 assert.isTrue(
70 mergeCursorFetch(
71 cache,
72 "a",
73 range,
74 [record(10 * DAY_MS), record(10.5 * DAY_MS)],
75 11 * DAY_MS,
76 0,
77 ).changed,
78 );
79 });
80});
apps/server/src/usage/cursorAccountCache.ts created+271
......@@ -0,0 +1,271 @@
1/**
2 * Cursor account usage, cached per credential source.
3 *
4 * Cursor's dashboard API takes about 20 seconds for a 30-day window, so fetched
5 * records are kept for the whole retention period, whatever window was asked
6 * for. After one full fetch, a refresh fetches only the newest edge.
7 *
8 * A record's dedupe key carries its timestamp and an occurrence index counted
9 * within one fetch. A refetched range therefore replaces every cached record in
10 * that range rather than adding to it, so each timestamp's records always come
11 * from a single fetch and their occurrence indexes cannot collide.
12 *
13 * @module cursorAccountCache
14 */
15import { cursorRateModel } from "./cursorUsageReader.ts";
16import type { UsageRecord } from "./usageTranscripts.ts";
17
18export type CursorCredentialSource = string | { readonly kind: "keychain" };
19
20/** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */
21export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000;
22
23/** Dashboard events can be finalized after they were first read. */
24const REFETCH_OVERLAP_MS = 60 * 60 * 1000;
25
26export interface CursorAccountCache {
27 readonly accountKey: string;
28 /** Inclusive range of event timestamps the records are complete for. */
29 readonly sinceMs: number;
30 readonly untilMs: number;
31 /** When the newest edge was fetched. */
32 readonly fetchedAtMs: number;
33 readonly records: readonly UsageRecord[];
34}
35
36interface CursorFetchRange {
37 readonly sinceMs: number;
38 readonly untilMs: number;
39}
40
41/** Whether the cache answers without a refresh. */
42export function isCursorCacheFresh(
43 cache: CursorAccountCache | undefined,
44 nowMs: number,
45): cache is CursorAccountCache {
46 return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS;
47}
48
49/**
50 * The range to fetch so the cache covers `[retentionStartMs, nowMs]`: the
51 * newest edge, or everything when the cache does not reach back that far.
52 */
53export function cursorFetchRange(
54 cache: CursorAccountCache | undefined,
55 retentionStartMs: number,
56 nowMs: number,
57): CursorFetchRange {
58 return cache === undefined ||
59 cache.sinceMs > retentionStartMs ||
60 cache.untilMs - REFETCH_OVERLAP_MS < retentionStartMs
61 ? { sinceMs: retentionStartMs, untilMs: nowMs }
62 : { sinceMs: cache.untilMs - REFETCH_OVERLAP_MS, untilMs: nowMs };
63}
64
65/**
66 * Applies a fetched range to the cache, replacing the cached records it covers
67 * and dropping those before the retention start. Pass `undefined` for a cache
68 * that belongs to another account. `changed` is false when the fetch only
69 * confirmed the cached records, so there is nothing new to persist.
70 */
71export function mergeCursorFetch(
72 cache: CursorAccountCache | undefined,
73 accountKey: string,
74 range: CursorFetchRange,
75 fetched: readonly UsageRecord[],
76 nowMs: number,
77 retentionStartMs: number,
78) {
79 const inRange = (record: UsageRecord) =>
80 record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs;
81 const previous = cache?.records ?? [];
82 const added = fetched.filter(inRange);
83 const replacedKeys = new Set(previous.filter(inRange).map((record) => record.dedupeKey));
84 const changed =
85 cache === undefined ||
86 replacedKeys.size !== added.length ||
87 added.some((record) => !replacedKeys.has(record.dedupeKey));
88 return {
89 changed,
90 cache: {
91 accountKey,
92 sinceMs: retentionStartMs,
93 untilMs: range.untilMs,
94 fetchedAtMs: nowMs,
95 records: [
96 ...previous.filter((record) => !inRange(record) && record.timestampMs >= retentionStartMs),
97 ...added,
98 ],
99 } satisfies CursorAccountCache,
100 };
101}
102
103/**
104 * Each version writes its own file, as the scan cache does, so servers of
105 * different versions sharing a state directory keep their own caches.
106 */
107export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json";
108const CURSOR_ACCOUNT_CACHE_VERSION = 1;
109
110/** Positional rows with interned strings, as in the scan cache. */
111type SerializedRecord = readonly [
112 timestampMs: number,
113 modelIndex: number,
114 sessionIndex: number,
115 uncachedInputTokens: number,
116 cachedInputTokens: number,
117 cacheCreationTokens: number,
118 outputTokens: number,
119 reportedCostUsd: number | null,
120 dedupeKey: string | null,
121];
122
123interface SerializedAccount {
124 readonly accountKey: string;
125 readonly sinceMs: number;
126 readonly untilMs: number;
127 readonly fetchedAtMs: number;
128 readonly records: readonly SerializedRecord[];
129}
130
131/** Serialises the caches by credential source. */
132export function encodeCursorAccountCaches(caches: ReadonlyMap<string, CursorAccountCache>): string {
133 const strings: string[] = [];
134 const indexes = new Map<string, number>();
135 const intern = (value: string) => {
136 let index = indexes.get(value);
137 if (index === undefined) {
138 index = strings.length;
139 strings.push(value);
140 indexes.set(value, index);
141 }
142 return index;
143 };
144 const accounts: Record<string, SerializedAccount> = {};
145 for (const [credentialKey, cache] of caches) {
146 accounts[credentialKey] = {
147 accountKey: cache.accountKey,
148 sinceMs: cache.sinceMs,
149 untilMs: cache.untilMs,
150 fetchedAtMs: cache.fetchedAtMs,
151 records: cache.records.map((record) => [
152 record.timestampMs,
153 intern(record.model),
154 intern(record.sessionId),
155 record.totals.uncachedInputTokens,
156 record.totals.cachedInputTokens,
157 record.totals.cacheCreationTokens,
158 record.totals.outputTokens,
159 record.reportedCostUsd,
160 record.dedupeKey,
161 ]),
162 };
163 }
164 return JSON.stringify({ version: CURSOR_ACCOUNT_CACHE_VERSION, strings, accounts });
165}
166
167const isFiniteNumber = (value: unknown): value is number =>
168 typeof value === "number" && Number.isFinite(value);
169
170/**
171 * Rebuilds the caches from a parsed document. A malformed account is dropped
172 * whole, costing one refetch; keeping its survivors would leave a gap the
173 * covered range claims is complete.
174 */
175export function decodeCursorAccountCaches(document: unknown): Map<string, CursorAccountCache> {
176 const caches = new Map<string, CursorAccountCache>();
177 if (typeof document !== "object" || document === null) return caches;
178 const root = document as {
179 readonly version?: unknown;
180 readonly strings?: unknown;
181 readonly accounts?: unknown;
182 };
183 const strings = root.strings;
184 if (
185 root.version !== CURSOR_ACCOUNT_CACHE_VERSION ||
186 !Array.isArray(strings) ||
187 !strings.every((value): value is string => typeof value === "string") ||
188 typeof root.accounts !== "object" ||
189 root.accounts === null
190 ) {
191 return caches;
192 }
193 const stringAt = (index: unknown): string | undefined =>
194 typeof index === "number" ? strings[index] : undefined;
195
196 const decodeRecord = (row: unknown): UsageRecord | null => {
197 if (!Array.isArray(row)) return null;
198 const [
199 timestampMs,
200 modelIndex,
201 sessionIndex,
202 uncached,
203 cached,
204 creation,
205 output,
206 cost,
207 key,
208 ]: readonly unknown[] = row;
209 const model = stringAt(modelIndex);
210 const sessionId = stringAt(sessionIndex);
211 if (
212 !isFiniteNumber(timestampMs) ||
213 model === undefined ||
214 sessionId === undefined ||
215 !isFiniteNumber(uncached) ||
216 !isFiniteNumber(cached) ||
217 !isFiniteNumber(creation) ||
218 !isFiniteNumber(output) ||
219 (cost !== null && !isFiniteNumber(cost)) ||
220 (key !== null && typeof key !== "string")
221 ) {
222 return null;
223 }
224 return {
225 provider: "cursor",
226 timestampMs,
227 model,
228 rateModel: cursorRateModel(model),
229 sessionId,
230 totals: {
231 uncachedInputTokens: uncached,
232 cachedInputTokens: cached,
233 cacheCreationTokens: creation,
234 outputTokens: output,
235 reasoningTokens: 0,
236 },
237 reportedCostUsd: cost,
238 speed: "standard",
239 dedupeKey: key,
240 };
241 };
242
243 for (const [credentialKey, raw] of Object.entries(root.accounts)) {
244 if (typeof raw !== "object" || raw === null) continue;
245 const account = raw as Partial<Record<keyof SerializedAccount, unknown>>;
246 if (
247 typeof account.accountKey !== "string" ||
248 !isFiniteNumber(account.sinceMs) ||
249 !isFiniteNumber(account.untilMs) ||
250 !isFiniteNumber(account.fetchedAtMs) ||
251 !Array.isArray(account.records)
252 ) {
253 continue;
254 }
255 const records: UsageRecord[] = [];
256 for (const row of account.records) {
257 const record = decodeRecord(row);
258 if (record === null) break;
259 records.push(record);
260 }
261 if (records.length !== account.records.length) continue;
262 caches.set(credentialKey, {
263 accountKey: account.accountKey,
264 sinceMs: account.sinceMs,
265 untilMs: account.untilMs,
266 fetchedAtMs: account.fetchedAtMs,
267 records,
268 });
269 }
270 return caches;
271}
apps/server/src/usage/cursorUsageReader.ts+25
......@@ -4,6 +4,10 @@ import * as NodeFSP from "node:fs/promises";
44import * as NodeCrypto from "node:crypto";
55import * as NodeTimersPromises from "node:timers/promises";
66
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10
711import type { UsageRecord } from "./usageTranscripts.ts";
812import {
913 CursorKeychainTimeoutError,
......@@ -289,3 +293,24 @@ export async function readCursorAccountUsage(
289293 cancel.abort();
290294 }
291295}
296
297/** Reads one range of Cursor account usage. A service so tests can stand in for Cursor's API. */
298export class CursorAccountReader extends Context.Service<
299 CursorAccountReader,
300 {
301 readonly read: (
302 credentialSource: string | { readonly kind: "keychain" },
303 sinceMs: number,
304 untilMs: number,
305 ) => Effect.Effect<CursorAccountUsageReadResult>;
306 }
307>()("t3/usage/cursorUsageReader/CursorAccountReader") {}
308
309/** Reads Cursor's dashboard API with the saved CLI or Keychain login. */
310export const layer = Layer.succeed(
311 CursorAccountReader,
312 CursorAccountReader.of({
313 read: (credentialSource, sinceMs, untilMs) =>
314 Effect.promise(() => readCursorAccountUsage(credentialSource, sinceMs, untilMs)),
315 }),
316);
apps/web/src/components/usage/UsagePage.refresh.test.tsx+1
......@@ -50,6 +50,7 @@ vi.mock("../../state/usage", () => ({
5050 },
5151 ],
5252 isPending: false,
53 shown: null,
5354 isPartial: false,
5455 refresh: async () => undefined,
5556 }),
apps/web/src/components/usage/UsagePage.test.tsx+1
......@@ -75,6 +75,7 @@ beforeEach(() => {
7575 environments,
7676 selectedEnvironments: environments,
7777 isPending: false,
78 shown: null,
7879 isPartial: false,
7980 refresh: vi.fn(),
8081 });
apps/web/src/components/usage/UsagePage.tsx+201-76
......@@ -8,18 +8,17 @@ import {
88 type EnvironmentId,
99 type UsageProviderKind,
1010} from "@t3tools/contracts";
11import {
12 CircleAlertIcon,
13 ChevronDownIcon,
14 CircleDashedIcon,
15 InfoIcon,
16 SlidersHorizontalIcon,
17} from "lucide-react";
11import { CircleAlertIcon, ChevronDownIcon, InfoIcon, SlidersHorizontalIcon } from "lucide-react";
1812import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
1913import {
2014 cursorKeychainAccessEnvironments,
2115 refreshUsageLimits,
2216} from "@t3tools/client-runtime/state/usage";
17import {
18 updatingProvidersLabel,
19 usageEnvironmentProgress,
20 usageLoadingState,
21} from "@t3tools/client-runtime/state/usage-progress";
2322
2423import {
2524 isCompatibleUsageContractVersion,
......@@ -109,6 +108,8 @@ function isUsageWindowDays(value: number): value is UsagePagePreferences["window
109108 return WINDOW_OPTIONS.some((option) => option.days === value);
110109}
111110
111const providerLabel = (provider: UsageProviderKind) => PROVIDER_PRESENTATION[provider].label;
112
112113export function UsagePage() {
113114 const [preferences, setPreferences] = useState(readUsagePagePreferences);
114115 useEscapeToGoBack();
......@@ -145,11 +146,36 @@ export function UsagePage() {
145146 );
146147 const { days: windowDays, window } = windowSelection;
147148 const isPast24Hours = windowDays === 1;
148 const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage(
149 window,
150 selectedEnvironmentIds,
151 hiddenProviders,
152 );
149 const {
150 merged: answeredUsage,
151 environments,
152 selectedEnvironments,
153 shown,
154 isPartial,
155 refresh,
156 } = useUsage(window, selectedEnvironmentIds, hiddenProviders);
157 // Until a new window's first answer, the previous one stays on screen, muted.
158 const merged = shown?.merged ?? answeredUsage;
159 const shownWindow = shown?.window ?? window;
160 const shownHourly = shownWindow.resolution === "hour";
161 const refreshingUsage = isRefreshing && !showingLimits;
162 // Usage kept from another window is all old, so every figure stays muted.
163 const showingKept = shown !== null && shown.window !== window;
164 const loading = useMemo(() => {
165 if (showingKept) {
166 return { partial: true, everyProvider: true, providers: new Set<UsageProviderKind>() };
167 }
168 const state = usageLoadingState(selectedEnvironments, refreshingUsage);
169 // A hidden provider still refreshing must not add its row or mute the totals.
170 const providers = new Set([...state.providers].filter((p) => !hiddenProviders.has(p)));
171 return {
172 partial: state.everyProvider || providers.size > 0,
173 everyProvider: state.everyProvider,
174 providers,
175 };
176 }, [showingKept, refreshingUsage, selectedEnvironments, hiddenProviders]);
177 const isProviderLoading = (provider: UsageProviderKind) =>
178 loading.everyProvider || loading.providers.has(provider);
153179 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
154180 const cursorAccessEnvironments = hiddenProviders.has("cursor")
155181 ? []
......@@ -180,21 +206,21 @@ export function UsagePage() {
180206 );
181207
182208 const days = useMemo(
183 () => enumerateDays(window.sinceDay, window.untilDay),
184 [window.sinceDay, window.untilDay],
209 () => enumerateDays(shownWindow.sinceDay, shownWindow.untilDay),
210 [shownWindow.sinceDay, shownWindow.untilDay],
185211 );
186212 const hours = useMemo(
187213 () =>
188 window.sinceTime === undefined || window.untilTime === undefined
214 shownWindow.sinceTime === undefined || shownWindow.untilTime === undefined
189215 ? []
190 : enumerateHourStarts(window.sinceTime, window.untilTime),
191 [window.sinceTime, window.untilTime],
216 : enumerateHourStarts(shownWindow.sinceTime, shownWindow.untilTime),
217 [shownWindow.sinceTime, shownWindow.untilTime],
192218 );
193219 // Newest first: the window can run 90 periods, so the interesting end
194220 // belongs at the top of the table.
195221 const breakdownPeriods = useMemo<readonly (DailyTotals | HourlyTotals)[]>(
196 () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(),
197 [isPast24Hours, merged.daily, merged.hourly],
222 () => (shownHourly ? merged.hourly : merged.daily).toReversed(),
223 [shownHourly, merged.daily, merged.hourly],
198224 );
199225 const breakdownModels = useMemo(
200226 () =>
......@@ -203,7 +229,24 @@ export function UsagePage() {
203229 : merged.models,
204230 [breakdown, merged.models, metric],
205231 );
206 const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);
232 const providersWithData = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);
233 // A provider still refreshing keeps its row and line before its usage lands.
234 const activeProviders = useMemo(
235 () =>
236 PROVIDER_ORDER.filter(
237 (provider) => providersWithData.includes(provider) || loading.providers.has(provider),
238 ),
239 [loading.providers, providersWithData],
240 );
241 const chartLoadingProviders = useMemo(
242 () =>
243 new Set(
244 activeProviders.filter(
245 (provider) => loading.everyProvider || loading.providers.has(provider),
246 ),
247 ),
248 [activeProviders, loading.everyProvider, loading.providers],
249 );
207250 const selectedModel =
208251 selectedModelKey === null
209252 ? undefined
......@@ -332,10 +375,11 @@ export function UsagePage() {
332375 if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits();
333376 }, [showingLimits, connectedLimitsEnvironments]);
334377
378 // Names the period on screen, which is the previous one until the new one answers.
335379 const windowLabel =
336 isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
337 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
338 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
380 shownHourly && shownWindow.sinceTime !== undefined && shownWindow.untilTime !== undefined
381 ? `${formatDateTimeShort(shownWindow.sinceTime, shownWindow.timeZone)} to ${formatDateTimeShort(shownWindow.untilTime, shownWindow.timeZone)}`
382 : `${formatDayShort(shownWindow.sinceDay)} to ${formatDayShort(shownWindow.untilDay)}`;
339383 const topbarContent = (
340384 <div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 xl:flex">
341385 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
......@@ -350,6 +394,7 @@ export function UsagePage() {
350394 selectedEnvironmentIds={selectedEnvironmentIds}
351395 onSelectionChange={setSelectedEnvironmentIds}
352396 showUsageStatus={!showingLimits}
397 refreshing={refreshingUsage}
353398 isPartial={isPartial}
354399 duplicateSources={merged.duplicateSources}
355400 contractMismatches={merged.contractMismatches}
......@@ -510,7 +555,7 @@ export function UsagePage() {
510555 ) : null
511556 }
512557 />
513 ) : isPending ? (
558 ) : shown === null ? (
514559 <UsageSkeleton />
515560 ) : !canReadDiagnostics ? (
516561 <div className="space-y-2 py-12 text-center text-sm text-muted-foreground">
......@@ -522,7 +567,7 @@ export function UsagePage() {
522567 ))}
523568 </div>
524569 ) : (
525 <>
570 <div aria-busy={loading.partial} className="flex flex-col gap-6">
526571 {sourceMessages.map((message) => (
527572 <p key={message} className="mb-4 text-sm text-muted-foreground">
528573 {message}
......@@ -531,13 +576,20 @@ export function UsagePage() {
531576 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
532577 <div className="flex min-w-0 flex-col gap-5">
533578 <div className="flex flex-col gap-1">
534 <span className="text-4xl font-semibold text-foreground tabular-nums">
579 <span
580 className={cn(
581 "text-4xl font-semibold text-foreground tabular-nums",
582 figureClass(loading.partial),
583 )}
584 >
535585 {metric === "cost"
536586 ? formatUsd(merged.costUsd)
537587 : formatTokens(merged.totalTokens)}
538588 </span>
539589 <span className="text-xs text-muted-foreground">
540 {formatCount(merged.sessions)} sessions
590 <span className={figureClass(loading.partial)}>
591 {formatCount(merged.sessions)} sessions
592 </span>
541593 {metric === "cost" && (
542594 <>
543595 {" · API estimate"}
......@@ -600,6 +652,8 @@ export function UsagePage() {
600652 const sessionLabel = `${formatCount(providerSessions)} ${
601653 providerSessions === 1 ? "session" : "sessions"
602654 }`;
655 const providerLoading = isProviderLoading(provider);
656 const awaitingData = providerLoading && !providersWithData.includes(provider);
603657 return (
604658 <div key={provider} className="flex flex-col gap-1">
605659 <div className="flex items-baseline justify-between gap-4">
......@@ -616,18 +670,38 @@ export function UsagePage() {
616670 <span className="truncate">
617671 {PROVIDER_PRESENTATION[provider].label}
618672 </span>
619 <span className="shrink-0 whitespace-nowrap text-2xs text-muted-foreground tabular-nums">
673 <span
674 className={cn(
675 "shrink-0 whitespace-nowrap text-2xs text-muted-foreground tabular-nums",
676 figureClass(providerLoading),
677 awaitingData && "invisible",
678 )}
679 >
620680 {sessionLabel}
621681 </span>
622682 </span>
623683 </span>
624 <span className="shrink-0 text-sm font-medium text-foreground tabular-nums">
625 {metric === "cost"
626 ? formatUsd(totals?.costUsd ?? 0)
627 : formatTokens(totals?.totalTokens ?? 0)}
684 <span
685 className={cn(
686 "shrink-0 text-sm font-medium text-foreground tabular-nums",
687 figureClass(providerLoading),
688 )}
689 >
690 {awaitingData
691 ? "—"
692 : metric === "cost"
693 ? formatUsd(totals?.costUsd ?? 0)
694 : formatTokens(totals?.totalTokens ?? 0)}
628695 </span>
629696 </div>
630 <span className="text-xs text-muted-foreground">
697 {/* Kept while awaiting data so the row does not grow when it lands. */}
698 <span
699 className={cn(
700 "text-xs text-muted-foreground",
701 figureClass(providerLoading),
702 awaitingData && "invisible",
703 )}
704 >
631705 {metric === "cost"
632706 ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens`
633707 : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
......@@ -639,19 +713,20 @@ export function UsagePage() {
639713
640714 <div className="flex min-w-0 flex-col gap-3">
641715 <h2 className="text-sm font-medium text-foreground">
642 {isPast24Hours ? "Hourly" : "Daily"}{" "}
716 {shownHourly ? "Hourly" : "Daily"}{" "}
643717 {metric === "tokens" ? "processed tokens" : "cost"}
644718 </h2>
645719 <UsageProviderChart
646720 providers={activeProviders}
721 loadingProviders={chartLoadingProviders}
647722 days={days}
648723 daily={merged.daily}
649724 hours={hours}
650725 hourly={merged.hourly}
651726 metric={metric}
652 referenceTime={window.untilTime}
653 resolution={isPast24Hours ? "hour" : "day"}
654 timeZone={window.timeZone}
727 referenceTime={shownWindow.untilTime}
728 resolution={shownHourly ? "hour" : "day"}
729 timeZone={shownWindow.timeZone}
655730 />
656731 </div>
657732 </section>
......@@ -659,14 +734,28 @@ export function UsagePage() {
659734 <section className="flex flex-col gap-2">
660735 <h2 className="text-sm font-medium text-foreground">Totals</h2>
661736 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
662 <Metric label="Processed tokens" value={formatTokens(merged.totalTokens)} />
663 <Metric label="Cached input" value={formatTokens(merged.cachedInputTokens)} />
664737 <Metric
738 loading={loading.partial}
739 label="Processed tokens"
740 value={formatTokens(merged.totalTokens)}
741 />
742 <Metric
743 loading={loading.partial}
744 label="Cached input"
745 value={formatTokens(merged.cachedInputTokens)}
746 />
747 <Metric
748 loading={loading.partial}
665749 label="Uncached input"
666750 value={formatTokens(merged.uncachedInputTokens)}
667751 />
668 <Metric label="Output" value={formatTokens(merged.outputTokens)} />
669752 <Metric
753 loading={loading.partial}
754 label="Output"
755 value={formatTokens(merged.outputTokens)}
756 />
757 <Metric
758 loading={loading.partial}
670759 label="Cache savings"
671760 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
672761 />
......@@ -674,7 +763,12 @@ export function UsagePage() {
674763 </section>
675764
676765 {merged.totalTokens > 0 ? (
677 <section className="grid gap-x-12 gap-y-8 lg:grid-cols-2">
766 <section
767 className={cn(
768 "grid gap-x-12 gap-y-8 lg:grid-cols-2",
769 figureClass(loading.partial),
770 )}
771 >
678772 {metric === "tokens" ? (
679773 <UsageShareBar
680774 label="Tokens by type"
......@@ -716,7 +810,7 @@ export function UsagePage() {
716810 {(
717811 [
718812 { value: "model", label: "Model" },
719 { value: "time", label: isPast24Hours ? "Hour" : "Day" },
813 { value: "time", label: shownHourly ? "Hour" : "Day" },
720814 ] as const
721815 ).map((option) => (
722816 <Toggle key={option.value} value={option.value}>
......@@ -752,6 +846,7 @@ export function UsagePage() {
752846 model,
753847 metric === "tokens" ? "tokens" : "cost",
754848 );
849 const rowFigures = figureClass(isProviderLoading(model.provider));
755850 return (
756851 <tr
757852 key={key}
......@@ -769,7 +864,10 @@ export function UsagePage() {
769864 <ProviderMark provider={model.provider} className="size-3.5" />
770865 {model.model}
771866 </button>
772 <div aria-hidden className="mt-1.5 h-0.5 max-w-48">
867 <div
868 aria-hidden
869 className={cn("mt-1.5 h-0.5 max-w-48", rowFigures)}
870 >
773871 <div
774872 className="h-full rounded-full"
775873 style={{
......@@ -784,17 +882,19 @@ export function UsagePage() {
784882 />
785883 </div>
786884 </td>
787 <td className="py-2.5 pl-6 text-foreground">
885 <td className={cn("py-2.5 pl-6 text-foreground", rowFigures)}>
788886 {isModelCostUnknown(model) ? (
789887 <span className="text-muted-foreground">Unpriced</span>
790888 ) : (
791889 formatUsd(model.costUsd)
792890 )}
793891 </td>
794 <td className="hidden py-2.5 pl-6 sm:table-cell">
892 <td className={cn("hidden py-2.5 pl-6 sm:table-cell", rowFigures)}>
795893 {share === null ? "" : formatPercent(share)}
796894 </td>
797 <td className="py-2.5 pl-6">{formatTokens(model.totalTokens)}</td>
895 <td className={cn("py-2.5 pl-6", rowFigures)}>
896 {formatTokens(model.totalTokens)}
897 </td>
798898 </tr>
799899 );
800900 })
......@@ -813,7 +913,7 @@ export function UsagePage() {
813913 </colgroup>
814914 <thead>
815915 <tr className="border-b border-border text-left text-xs text-muted-foreground">
816 <th className="py-2 font-normal">{isPast24Hours ? "Hour" : "Day"}</th>
916 <th className="py-2 font-normal">{shownHourly ? "Hour" : "Day"}</th>
817917 {activeProviders.map((provider) => (
818918 <th key={provider} className="py-2 text-right font-normal">
819919 {PROVIDER_PRESENTATION[provider].label}
......@@ -841,21 +941,34 @@ export function UsagePage() {
841941 >
842942 <td className="py-2 text-foreground">
843943 {"hourStart" in period
844 ? formatHourShort(period.hourStart, window.timeZone)
944 ? formatHourShort(period.hourStart, shownWindow.timeZone)
845945 : formatDayShort(period.day)}
846946 </td>
847947 {activeProviders.map((provider) => (
848948 <td
849949 key={provider}
850 className="py-2 text-right text-muted-foreground tabular-nums"
950 className={cn(
951 "py-2 text-right text-muted-foreground tabular-nums",
952 figureClass(isProviderLoading(provider)),
953 )}
851954 >
852955 {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)}
853956 </td>
854957 ))}
855 <td className="py-2 text-right text-foreground tabular-nums">
958 <td
959 className={cn(
960 "py-2 text-right text-foreground tabular-nums",
961 figureClass(loading.partial),
962 )}
963 >
856964 {formatUsd(period.costUsd)}
857965 </td>
858 <td className="py-2 text-right text-muted-foreground tabular-nums">
966 <td
967 className={cn(
968 "py-2 text-right text-muted-foreground tabular-nums",
969 figureClass(loading.partial),
970 )}
971 >
859972 {formatTokens(period.totalTokens)}
860973 </td>
861974 </tr>
......@@ -865,7 +978,7 @@ export function UsagePage() {
865978 </table>
866979 )}
867980 </section>
868 </>
981 </div>
869982 )}
870983 </WorkspacePageContainer>
871984 </ScrollArea>
......@@ -878,9 +991,9 @@ export function UsagePage() {
878991 chartWindow={{
879992 days,
880993 hours,
881 resolution: isPast24Hours ? "hour" : "day",
882 timeZone: window.timeZone,
883 referenceTime: window.untilTime,
994 resolution: shownHourly ? "hour" : "day",
995 timeZone: shownWindow.timeZone,
996 referenceTime: shownWindow.untilTime,
884997 }}
885998 onSetPrice={() => {
886999 setSelectedModelKey(null);
......@@ -1052,11 +1165,28 @@ function ProviderMark({
10521165 );
10531166}
10541167
1055function Metric({ label, value }: { readonly label: string; readonly value: string }) {
1168/** Mutes a figure that is still coming in. The delay keeps a quick answer from flashing. */
1169function figureClass(loading: boolean) {
1170 return cn("transition-opacity", loading && "opacity-40 delay-150");
1171}
1172
1173function Metric({
1174 label,
1175 value,
1176 loading,
1177}: {
1178 readonly label: string;
1179 readonly value: string;
1180 readonly loading: boolean;
1181}) {
10561182 return (
10571183 <div className="flex min-w-0 flex-col gap-0.5">
10581184 <span className="text-xs text-muted-foreground">{label}</span>
1059 <span className="text-base font-medium text-foreground tabular-nums">{value}</span>
1185 <span
1186 className={cn("text-base font-medium text-foreground tabular-nums", figureClass(loading))}
1187 >
1188 {value}
1189 </span>
10601190 </div>
10611191 );
10621192}
......@@ -1108,13 +1238,14 @@ function UsageCoverageNotice({
11081238 );
11091239}
11101240
1111/** Environment selection and scan progress share a permanent header control. */
1241/** Environment selection, with each environment's scan status in the menu. */
11121242function UsageEnvironmentFilter({
11131243 environments,
11141244 selectedEnvironments,
11151245 selectedEnvironmentIds,
11161246 onSelectionChange,
11171247 showUsageStatus,
1248 refreshing,
11181249 isPartial,
11191250 duplicateSources,
11201251 contractMismatches,
......@@ -1125,6 +1256,7 @@ function UsageEnvironmentFilter({
11251256 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
11261257 readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId> | null) => void;
11271258 readonly showUsageStatus: boolean;
1259 readonly refreshing: boolean;
11281260 readonly isPartial: boolean;
11291261 readonly duplicateSources: readonly string[];
11301262 readonly contractMismatches: MergedUsage["contractMismatches"];
......@@ -1136,10 +1268,6 @@ function UsageEnvironmentFilter({
11361268 : selectedEnvironments.length === 1
11371269 ? selectedEnvironments[0]!.label
11381270 : `${selectedEnvironments.length} environments`;
1139 const pendingCount = selectedEnvironments.filter(
1140 (environment) =>
1141 environment.error === null && (environment.isPending || environment.summary === null),
1142 ).length;
11431271 const hasIssue =
11441272 selectedEnvironments.some((environment) => environment.error !== null) ||
11451273 contractMismatches.length > 0;
......@@ -1149,15 +1277,7 @@ function UsageEnvironmentFilter({
11491277 <MenuTrigger render={<InlineButton />} className="group/usage-environment min-w-0 max-w-full">
11501278 <span className="min-w-0 truncate">{label}</span>
11511279 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
1152 {showUsageStatus && pendingCount > 0 ? (
1153 <>
1154 <CircleDashedIcon className="size-3.5" aria-hidden />
1155 <span className="sr-only">
1156 {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still scanning
1157 {isPartial ? "; totals are partial" : ""}
1158 </span>
1159 </>
1160 ) : showUsageStatus && hasIssue ? (
1280 {showUsageStatus && hasIssue ? (
11611281 <CircleAlertIcon
11621282 className="size-3.5 text-warning-foreground"
11631283 aria-label="Some environments could not report usage"
......@@ -1183,6 +1303,7 @@ function UsageEnvironmentFilter({
11831303 const checked =
11841304 selectedEnvironmentIds === null ||
11851305 selectedEnvironmentIds.has(environment.environmentId);
1306 const progress = usageEnvironmentProgress(environment, refreshing);
11861307 const status =
11871308 environment.error !== null
11881309 ? "Unavailable"
......@@ -1192,11 +1313,15 @@ function UsageEnvironmentFilter({
11921313 USAGE_CONTRACT_VERSION,
11931314 )
11941315 ? "Update required"
1195 : environment.summary === null
1196 ? "Scanning…"
1197 : environment.isPending
1198 ? "Refreshing…"
1199 : "Ready";
1316 : !environment.isConnected
1317 ? "Connecting…"
1318 : progress.phase === "loading"
1319 ? "Scanning…"
1320 : progress.phase === "stale"
1321 ? "Refreshing…"
1322 : progress.phase === "partway"
1323 ? updatingProvidersLabel(progress.providers, providerLabel)
1324 : "Ready";
12001325 return (
12011326 <MenuCheckboxItem
12021327 key={environment.environmentId}
apps/web/src/components/usage/UsageProviderChart.test.ts+22-1
......@@ -1,8 +1,29 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { buildPeriodColumns, niceScale } from "./UsageProviderChart";
3import { buildPeriodColumns, chartScale, niceScale } from "./UsageProviderChart";
44import { providersWithUsage } from "./usageProviders";
55
6describe("chartScale", () => {
7 const column = (codex: number) => ({
8 total: codex,
9 bands: [{ provider: "codex" as const, value: codex }],
10 });
11
12 it("holds unlabeled placeholder gridlines while loading providers have nothing to show", () => {
13 const scale = chartScale([column(0)], new Set(["codex" as const]));
14
15 expect(scale.labeled).toBe(false);
16 expect(scale.ticks.length).toBeGreaterThan(1);
17 });
18
19 it("scales to what is on screen, loading or not", () => {
20 expect(chartScale([column(40)], new Set(["codex" as const]))).toMatchObject({
21 max: 40,
22 labeled: true,
23 });
24 });
25});
26
627describe("niceScale", () => {
728 it("never puts the peak above the top of the scale", () => {
829 // Regression: an earlier version stopped at the last step below the peak,
apps/web/src/components/usage/UsageProviderChart.tsx+111-68
......@@ -10,17 +10,21 @@ import {
1010 formatTokens,
1111 formatUsd,
1212} from "@t3tools/shared/usageFormat";
13import { cn } from "~/lib/utils";
1314import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";
1415
1516const VIEW_WIDTH = 960;
1617const VIEW_HEIGHT = 260;
1718const TICK_COUNT = 4;
1819const PLOT_TOP = 8;
20const NONE_LOADING: ReadonlySet<UsageProviderKind> = new Set();
1921
2022export type UsageChartMetric = "tokens" | "cost";
2123
2224interface UsageProviderChartProps {
2325 readonly providers: readonly UsageProviderKind[];
26 /** Providers whose figures are still coming in: their lines are muted. */
27 readonly loadingProviders?: ReadonlySet<UsageProviderKind>;
2428 readonly days: readonly string[];
2529 readonly daily: readonly DailyTotals[];
2630 readonly hours: readonly string[];
......@@ -148,6 +152,10 @@ function curvePath(segments: readonly CurveSegment[]): string {
148152 return path;
149153}
150154
155function areaPath(line: string) {
156 return line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`;
157}
158
151159/**
152160 * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the
153161 * peak.
......@@ -170,8 +178,71 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
170178 return { max, ticks };
171179}
172180
181const PLACEHOLDER_TICKS = Array.from({ length: TICK_COUNT + 1 }, (_, index) => index);
182
183/**
184 * Scales to the largest single provider-period. With nothing to show yet while
185 * providers load, unlabeled placeholder gridlines hold their usual spacing so
186 * nothing shifts on arrival.
187 */
188export function chartScale(
189 columns: readonly DayColumn[],
190 loadingProviders: ReadonlySet<UsageProviderKind>,
191) {
192 // Not the sum: layered series each measure from zero, so a combined peak
193 // would leave the plot permanently half empty.
194 const peak = columns.reduce(
195 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
196 0,
197 );
198 return peak === 0 && loadingProviders.size > 0
199 ? { max: TICK_COUNT, ticks: PLACEHOLDER_TICKS, labeled: false }
200 : { ...niceScale(peak, TICK_COUNT), labeled: true };
201}
202
203// Leave room above the top gridline so the constant-width stroke is not
204// clipped when a series reaches the peak.
205function valueToY(value: number, max: number) {
206 return max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
207}
208
209/** Per-provider paths in paint order, heaviest first. */
210function buildChart(
211 periods: readonly string[],
212 byPeriod: ReadonlyMap<string, DailyTotals | HourlyTotals>,
213 metric: UsageChartMetric,
214 providers: readonly UsageProviderKind[],
215 loadingProviders: ReadonlySet<UsageProviderKind>,
216) {
217 const columns = buildPeriodColumns(periods, byPeriod, metric);
218 const scale = chartScale(columns, loadingProviders);
219 const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1);
220 const paths = providers.map((provider) => {
221 const slot = PROVIDER_ORDER.indexOf(provider);
222 const line = curvePath(
223 smoothCurve(
224 columns.map((column, periodIndex) => ({
225 x: periodIndex * stepX,
226 y: valueToY(column.bands[slot]?.value ?? 0, scale.max),
227 })),
228 ),
229 );
230 return {
231 provider,
232 loading: loadingProviders.has(provider),
233 total: columns.reduce((sum, column) => sum + (column.bands[slot]?.value ?? 0), 0),
234 line,
235 area: areaPath(line),
236 };
237 });
238
239 // Paint the heavier series first so the lighter one is not buried.
240 return { columns, scale, stepX, paths: paths.toSorted((a, b) => b.total - a.total) };
241}
242
173243export function UsageProviderChart({
174244 providers,
245 loadingProviders = NONE_LOADING,
175246 days,
176247 daily,
177248 hours,
......@@ -194,59 +265,14 @@ export function UsageProviderChart({
194265 const tooltipRef = useRef<HTMLDivElement | null>(null);
195266 const hoverPositionRef = useRef<{ x: number; y: number } | null>(null);
196267
197 const { paths, ticks, stepX, toY, series } = useMemo(() => {
198 if (periods.length === 0) {
199 return {
200 paths: [],
201 series: [] as readonly DayColumn[],
202 stepX: 0,
203 ticks: [0] as readonly number[],
204 toY: () => VIEW_HEIGHT,
205 };
206 }
207
208 const columns = buildPeriodColumns(periods, byPeriod, metric);
209 // The scale tops out at the largest single provider-period, not the sum:
210 // layered series each measure from zero, so a combined peak would leave
211 // the plot permanently half empty.
212 const peak = columns.reduce(
213 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
214 0,
215 );
216 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
217 const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1);
218 // Leave room above the top gridline so the constant-width stroke is not
219 // clipped when a series reaches the peak.
220 const toY = (value: number) =>
221 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
222
223 const built = providers.map((provider) => {
224 const providerIndex = PROVIDER_ORDER.indexOf(provider);
225 const line = curvePath(
226 smoothCurve(
227 columns.map((column, periodIndex) => ({
228 x: periodIndex * step,
229 y: toY(column.bands[providerIndex]?.value ?? 0),
230 })),
231 ),
232 );
233 return {
234 provider,
235 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
236 area: line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`,
237 line,
238 };
239 });
240
241 // Paint the heavier series first so the lighter one is not buried.
242 return {
243 paths: built.toSorted((a, b) => b.total - a.total),
244 series: columns,
245 stepX: step,
246 ticks: tickValues,
247 toY,
248 };
249 }, [byPeriod, metric, periods, providers]);
268 const { columns, scale, paths, stepX } = useMemo(
269 () => buildChart(periods, byPeriod, metric, providers, loadingProviders),
270 [byPeriod, loadingProviders, metric, periods, providers],
271 );
272 const toY = (value: number) => valueToY(value, scale.max);
273 // The delay keeps a quick answer from flashing, as with the page's figures.
274 const seriesClassName = (loading: boolean) =>
275 cn("transition-opacity", loading && "opacity-40 delay-150");
250276
251277 const format = metric === "tokens" ? formatTokens : formatUsd;
252278
......@@ -307,7 +333,8 @@ export function UsageProviderChart({
307333 );
308334
309335 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
310 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
336 const hoveredColumn = hoverIndex === null ? undefined : columns[hoverIndex];
337 const partial = providers.some((provider) => loadingProviders.has(provider));
311338 const formatPeriod = (period: string) =>
312339 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
313340 const formatTooltipPeriod = (period: string) =>
......@@ -320,15 +347,17 @@ export function UsageProviderChart({
320347 <div className="flex gap-2">
321348 {/* Axis labels sit outside the plot so they stay aligned to gridlines. */}
322349 <div className="relative h-56 w-14 shrink-0">
323 {ticks.map((tick) => (
324 <span
325 key={tick}
326 className="absolute right-0 -translate-y-1/2 text-3xs text-muted-foreground tabular-nums"
327 style={{ top: `${(toY(tick) / VIEW_HEIGHT) * 100}%` }}
328 >
329 {tick === 0 ? "0" : format(tick)}
330 </span>
331 ))}
350 {scale.labeled
351 ? scale.ticks.map((tick) => (
352 <span
353 key={tick}
354 className="absolute right-0 -translate-y-1/2 text-3xs text-muted-foreground tabular-nums"
355 style={{ top: `${(toY(tick) / VIEW_HEIGHT) * 100}%` }}
356 >
357 {tick === 0 ? "0" : format(tick)}
358 </span>
359 ))
360 : null}
332361 </div>
333362
334363 <div
......@@ -347,7 +376,7 @@ export function UsageProviderChart({
347376 role="img"
348377 aria-label={`${resolution === "hour" ? "Hourly" : "Daily"} ${metric === "tokens" ? "processed tokens" : "cost"} by provider`}
349378 >
350 {ticks.map((tick) => {
379 {scale.ticks.map((tick) => {
351380 const y = toY(tick);
352381 return (
353382 <line
......@@ -365,18 +394,20 @@ export function UsageProviderChart({
365394 })}
366395
367396 {/* Fills first, then every stroke, so no series covers another's line. */}
368 {paths.map(({ provider, area }) => (
397 {paths.map(({ provider, loading, area }) => (
369398 <path
370399 key={provider}
371400 d={area}
401 className={seriesClassName(loading)}
372402 fill={PROVIDER_PRESENTATION[provider].color}
373403 fillOpacity={0.12}
374404 />
375405 ))}
376 {paths.map(({ provider, line }) => (
406 {paths.map(({ provider, loading, line }) => (
377407 <path
378408 key={provider}
379409 d={line}
410 className={seriesClassName(loading)}
380411 fill="none"
381412 stroke={PROVIDER_PRESENTATION[provider].color}
382413 strokeWidth={2}
......@@ -420,7 +451,14 @@ export function UsageProviderChart({
420451 />
421452 {label}
422453 </span>
423 <span className="text-foreground tabular-nums">
454 <span
455 className={cn(
456 "tabular-nums",
457 loadingProviders.has(provider)
458 ? "text-muted-foreground"
459 : "text-foreground",
460 )}
461 >
424462 {format(
425463 hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0,
426464 )}
......@@ -430,7 +468,12 @@ export function UsageProviderChart({
430468 })}
431469 <div className="mt-1 flex items-center justify-between gap-3 border-t border-border pt-1">
432470 <span className="text-muted-foreground">Total</span>
433 <span className="text-foreground tabular-nums">
471 <span
472 className={cn(
473 "tabular-nums",
474 partial ? "text-muted-foreground" : "text-foreground",
475 )}
476 >
434477 {format(hoveredColumn?.total ?? 0)}
435478 </span>
436479 </div>
apps/web/src/state/usage.test.tsx+43-1
......@@ -33,6 +33,7 @@ function environment(
3333 label: id,
3434 isPending: cost === null,
3535 canReadDiagnostics: true,
36 isConnected: true,
3637 error: null,
3738 needsCursorKeychainAccess: false,
3839 summary:
......@@ -90,11 +91,13 @@ let latest: UsageView;
9091function Probe({
9192 selected,
9293 hidden,
94 window = input,
9395}: {
9496 selected: ReadonlySet<EnvironmentId> | null;
9597 hidden?: ReadonlySet<UsageProviderKind>;
98 window?: typeof input;
9699}) {
97 const usage = useUsage(input, selected, hidden);
100 const usage = useUsage(window, selected, hidden);
98101 useLayoutEffect(() => {
99102 latest = usage;
100103 }, [usage]);
......@@ -181,6 +184,45 @@ describe("usage environment selection", () => {
181184 expect(latest.isPending).toBe(false);
182185 expect(latest.isPartial).toBe(false);
183186 });
187
188 it("shows the last answered usage until the next window answers, for the same selection", async () => {
189 const selected = new Set([EnvironmentId.make("a")]);
190 await act(() => renderer?.update(<Probe selected={selected} />));
191 expect(latest.shown?.merged.costUsd).toBe(10);
192
193 // A new window that nothing has answered yet.
194 const nextWindow = { ...input, sinceDay: UsageDay.make("2026-08-28") };
195 testState.environments = [environment("a", null)];
196 await act(() => renderer?.update(<Probe selected={selected} window={nextWindow} />));
197 expect(latest.isPending).toBe(true);
198 expect(latest.shown?.window).toBe(input);
199 expect(latest.shown?.merged.costUsd).toBe(10);
200
201 // A window that fails everywhere keeps it too.
202 testState.environments = [{ ...environment("a", null), isPending: false, error: "Offline" }];
203 await act(() => renderer?.update(<Probe selected={selected} window={nextWindow} />));
204 expect(latest.isPending).toBe(false);
205 expect(latest.shown?.window).toBe(input);
206 expect(latest.shown?.merged.costUsd).toBe(10);
207
208 // Once the new window answers, it replaces the kept one.
209 testState.environments = [environment("a", 30)];
210 await act(() => renderer?.update(<Probe selected={selected} window={nextWindow} />));
211 expect(latest.shown?.window).toBe(nextWindow);
212 expect(latest.shown?.merged.costUsd).toBe(30);
213
214 // A different provider filter does not reuse usage merged with the old one.
215 testState.environments = [environment("a", null)];
216 await act(() =>
217 renderer?.update(<Probe selected={selected} hidden={new Set(["claude"])} window={input} />),
218 );
219 expect(latest.shown).toBeNull();
220
221 // Another selection has nothing of its own to show.
222 testState.environments = [environment("a", null)];
223 await select("a");
224 expect(latest.shown).toBeNull();
225 });
184226});
185227
186228describe("usage provider filter", () => {
apps/web/src/state/usage.ts+48-2
......@@ -20,7 +20,7 @@ import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime
2020import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
2121import * as Option from "effect/Option";
2222import { AsyncResult, Atom } from "effect/reactivity";
23import { useCallback, useMemo } from "react";
23import { useCallback, useMemo, useState } from "react";
2424
2525import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
2626import { appAtomRegistry } from "../rpc/atomRegistry";
......@@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus {
3333 readonly label: string;
3434 readonly isPending: boolean;
3535 readonly canReadDiagnostics: boolean;
36 readonly isConnected: boolean;
3637 readonly error: string | null;
3738 readonly summary: UsageSummary | null;
3839 readonly needsCursorKeychainAccess: boolean;
......@@ -52,6 +53,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
5253
5354 const statuses: EnvironmentUsageStatus[] = [];
5455 for (const [environmentId, presentation] of presentations) {
56 const isConnected = presentation.connection.phase === "connected";
5557 const sessionResult = get(environmentSession.sessionStateAtom(environmentId));
5658 const access = resolveUsageAccess({
5759 connectionPhase: presentation.connection.phase,
......@@ -62,6 +64,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
6264 statuses.push({
6365 environmentId,
6466 label: presentation.entry.target.label,
67 isConnected,
6568 ...access,
6669 summary: null,
6770 needsCursorKeychainAccess: false,
......@@ -75,6 +78,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
7578 label: presentation.entry.target.label,
7679 isPending: result.waiting,
7780 canReadDiagnostics: true,
81 isConnected,
7882 error: result._tag === "Failure" ? "This environment could not report usage." : null,
7983 summary,
8084 needsCursorKeychainAccess: needsCursorKeychainAccess(
......@@ -93,6 +97,12 @@ export interface UsageView {
9397 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
9498 /** True until at least one selected environment has answered. */
9599 readonly isPending: boolean;
100 /**
101 * The usage to draw: this window's once a selected environment answers,
102 * until then the last window answered for the same selection. Null while
103 * nothing has answered and something still could.
104 */
105 readonly shown: { readonly window: UsageSummaryInput; readonly merged: MergedUsage } | null;
96106 /**
97107 * True while environments that have not failed are still answering. Failed
98108 * environments are reported through their own error rows: totals will not
......@@ -225,12 +235,48 @@ export function useUsage(
225235 const stillReporting = selectedEnvironments.filter(
226236 (environment) => environment.summary === null && environment.error === null,
227237 ).length;
238 const isPending = answeredCount === 0 && stillReporting > 0;
239
240 // Stored during render, as React recommends for state that follows props, so
241 // the kept usage is on screen in the same frame the new window starts pending.
242 const [lastAnswered, setLastAnswered] = useState<
243 | (NonNullable<UsageView["shown"]> & {
244 readonly selection: typeof selectedEnvironmentIds;
245 readonly hidden: typeof hiddenProviders;
246 })
247 | null
248 >(null);
249 if (
250 answeredCount > 0 &&
251 (lastAnswered?.merged !== merged ||
252 lastAnswered.window !== input ||
253 lastAnswered.selection !== selectedEnvironmentIds ||
254 lastAnswered.hidden !== hiddenProviders)
255 ) {
256 setLastAnswered({
257 window: input,
258 merged,
259 selection: selectedEnvironmentIds,
260 hidden: hiddenProviders,
261 });
262 }
263 // Kept usage only stands in for the same environments and provider filter.
264 const kept =
265 lastAnswered?.selection === selectedEnvironmentIds && lastAnswered.hidden === hiddenProviders
266 ? lastAnswered
267 : null;
268 // With no answers, even failed ones keep the last answered usage on screen.
269 const shown =
270 answeredCount > 0
271 ? { window: input, merged }
272 : (kept ?? (isPending ? null : { window: input, merged }));
228273
229274 return {
230275 merged,
231276 environments,
232277 selectedEnvironments,
233 isPending: answeredCount === 0 && stillReporting > 0,
278 isPending,
279 shown,
234280 isPartial: answeredCount > 0 && stillReporting > 0,
235281 refresh,
236282 };
docs/user/usage.md+2-1
......@@ -39,7 +39,8 @@ your app understands. Update the app to include newly supported providers.
3939
4040On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All
4141environments are selected by default. The dropdown shows which environments are still scanning;
42results appear as each one responds.
42results appear as each one responds, and figures still updating are dimmed. Cursor shows its last
43saved totals first, then updates them when Cursor's API responds.
4344
4445If recent work is missing or a new model shows no cost, refresh to rescan session history and
4546update model pricing.
packages/client-runtime/package.json+4
......@@ -231,6 +231,10 @@
231231 "types": "./src/state/usage.ts",
232232 "default": "./src/state/usage.ts"
233233 },
234 "./state/usage-progress": {
235 "types": "./src/state/usageProgress.ts",
236 "default": "./src/state/usageProgress.ts"
237 },
234238 "./state/server": {
235239 "types": "./src/state/server.ts",
236240 "default": "./src/state/server.ts"
packages/client-runtime/src/state/runtime.ts+25-8
......@@ -53,12 +53,18 @@ interface EnvironmentCommandAtomOptions<Input, A, E, R> extends Omit<
5353 ) => Effect.Effect<A, E, R>;
5454}
5555
56interface EnvironmentQueryAtomOptions<Input, A, E, R> extends EnvironmentAtomOptions<
57 Input,
58 A,
59 E,
60 R
56interface EnvironmentQueryAtomOptions<Input, A, E, R> extends Omit<
57 EnvironmentAtomOptions<Input, A, E, R>,
58 "execute"
6159> {
60 /**
61 * `emit` shows a provisional value while the query keeps waiting. The settled
62 * result replaces it, so catch failures that should keep it on screen.
63 */
64 readonly execute: (
65 input: Input,
66 emit: (value: A) => Effect.Effect<void>,
67 ) => Effect.Effect<A, E, R>;
6268 readonly staleTimeMs?: number;
6369 readonly idleTtlMs?: number;
6470 readonly refreshIntervalMs?: number;
......@@ -547,10 +553,18 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>(
547553 return Effect.never;
548554 }
549555 const [connectionState, session] = connection;
556 const emit = (value: A) =>
557 Effect.sync(() => get.setSelf(AsyncResult.success(value, { waiting: true }))).pipe(
558 // A value set while the atom builds wins over the build's own
559 // result, so a query that then settled synchronously would stay
560 // waiting forever. Suspend on a promise (`yieldNow` is drained
561 // inside the build) so the settled result arrives asynchronously.
562 Effect.andThen(Effect.promise(() => Promise.resolve())),
563 );
550564 switch (connectionState.phase) {
551565 case "connected":
552566 return Option.isSome(session)
553 ? runInEnvironment(target.environmentId, options.execute(target.input))
567 ? runInEnvironment(target.environmentId, options.execute(target.input, emit))
554568 : Effect.never;
555569 case "connecting":
556570 case "backoff":
......@@ -650,6 +664,7 @@ export function createEnvironmentRpcQueryAtomFamily<
650664 readonly tag: TTag;
651665 readonly execute?: (
652666 input: EnvironmentRpcInput<TTag>,
667 emit: (value: EnvironmentRpcSuccess<TTag>) => Effect.Effect<void>,
653668 ) => Effect.Effect<
654669 EnvironmentRpcSuccess<TTag>,
655670 EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError,
......@@ -672,8 +687,10 @@ export function createEnvironmentRpcQueryAtomFamily<
672687 ? {}
673688 : { refreshIntervalMs: options.refreshIntervalMs }),
674689 ...(options.refreshTrigger === undefined ? {} : { refreshTrigger: options.refreshTrigger }),
675 execute: (input: EnvironmentRpcInput<TTag>) =>
676 options.execute?.(input) ?? request(options.tag, input),
690 execute: (
691 input: EnvironmentRpcInput<TTag>,
692 emit: (value: EnvironmentRpcSuccess<TTag>) => Effect.Effect<void>,
693 ) => options.execute?.(input, emit) ?? request(options.tag, input),
677694 });
678695}
679696
packages/client-runtime/src/state/server.ts+22
......@@ -1091,11 +1091,33 @@ export function createServerEnvironmentAtoms<R, E>(
10911091 }),
10921092 // A cold transcript scan is measured in seconds, so keep the result around
10931093 // long enough that switching windows or re-rendering does not rescan.
1094 // Slow sources answer from cache first: that summary stays on screen, with
1095 // the query waiting, until one `awaitRefresh` request replaces it.
10941096 usageSummary: createEnvironmentRpcQueryAtomFamily(runtime, {
10951097 label: "environment-data:server:usage-summary",
10961098 tag: WS_METHODS.serverGetUsageSummary,
10971099 staleTimeMs: 60_000,
10981100 refreshTrigger: ({ environmentId }) => usageScanSettingsAtom(environmentId),
1101 execute: (input, emit) =>
1102 request(WS_METHODS.serverGetUsageSummary, input).pipe(
1103 Effect.flatMap((summary) =>
1104 summary.sources.some((source) => source.refreshing === true)
1105 ? emit(summary).pipe(
1106 Effect.andThen(
1107 request(WS_METHODS.serverGetUsageSummary, { ...input, awaitRefresh: true }),
1108 ),
1109 // The cached summary is still the best data, and a failure
1110 // would read as the environment not reporting usage at all.
1111 Effect.catch((error) =>
1112 Effect.logWarning("Could not refresh slow usage sources.").pipe(
1113 Effect.annotateLogs({ ...safeErrorLogAttributes(error) }),
1114 Effect.as(summary),
1115 ),
1116 ),
1117 )
1118 : Effect.succeed(summary),
1119 ),
1120 ),
10991121 }),
11001122 resourceTelemetry: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
11011123 label: "environment-data:server:resource-telemetry",
packages/client-runtime/src/state/serverUsage.test.ts+150-4
......@@ -2,11 +2,13 @@ import {
22 DEFAULT_SERVER_SETTINGS,
33 EnvironmentId,
44 UsageDay,
5 UsageReadError,
56 USAGE_CONTRACT_VERSION,
67 WS_METHODS,
78 type ServerConfig,
89 type ServerConfigStreamEvent,
910 type ServerSettings,
11 type UsageSource,
1012 type UsageSummary,
1113 type UsageSummaryInput,
1214} from "@t3tools/contracts";
......@@ -16,6 +18,7 @@ import * as Effect from "effect/Effect";
1618import * as Layer from "effect/Layer";
1719import * as Option from "effect/Option";
1820import * as Queue from "effect/Queue";
21import * as Result from "effect/Result";
1922import * as Stream from "effect/Stream";
2023import * as SubscriptionRef from "effect/SubscriptionRef";
2124import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
......@@ -26,12 +29,14 @@ import {
2629 type PreparedConnection,
2730 type SupervisorConnectionState,
2831} from "../connection/model.ts";
32import type { EnvironmentPresentation } from "../connection/presentation.ts";
2933import * as EnvironmentRegistry from "../connection/registry.ts";
3034import * as EnvironmentSupervisor from "../connection/supervisor.ts";
3135import * as Persistence from "../platform/persistence.ts";
3236import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
3337import type { RpcSession } from "../rpc/session.ts";
3438import { createServerEnvironmentAtoms } from "./server.ts";
39import { refreshUsage } from "./usage.ts";
3540
3641const TARGET = new PrimaryConnectionTarget({
3742 environmentId: EnvironmentId.make("usage-environment"),
......@@ -51,13 +56,37 @@ const CONFIG = {
5156 environment: { serverVersion: "0.0.1", capabilities: {} },
5257} as ServerConfig;
5358
59const PRICING = { status: "unavailable" as const, source: "test", fetchedAt: null, knownModels: 0 };
60
61const cursorSource = (refreshing: boolean): UsageSource => ({
62 fingerprint: {
63 hostId: "cursor.com",
64 provider: "cursor",
65 resolvedHomePath: "cursor-account:abc",
66 volumeId: "abc",
67 },
68 status: "ok",
69 scannedFiles: 1,
70 skippedFiles: 0,
71 malformedRecords: 0,
72 distinctSessions: 1,
73 message: null,
74 ...(refreshing ? { refreshing: true as const } : {}),
75});
76
5477const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (
55 beforeRead: (request: number) => Effect.Effect<void> = () => Effect.void,
78 beforeRead: (
79 request: number,
80 input: UsageSummaryInput,
81 ) => Effect.Effect<void, UsageReadError> = () => Effect.void,
82 sources: (input: UsageSummaryInput) => readonly UsageSource[] = () => [],
5683) {
5784 const events = yield* Queue.unbounded<ServerConfigStreamEvent>();
5885 let settings = DEFAULT_SERVER_SETTINGS;
5986 let requests = 0;
87 const inputs: UsageSummaryInput[] = [];
6088 const client = {
89 [WS_METHODS.serverRefreshUsageRates]: () => Effect.succeed(PRICING),
6190 [WS_METHODS.subscribeServerConfig]: () =>
6291 Stream.concat(
6392 Stream.make({ version: 1 as const, type: "snapshot" as const, config: CONFIG }),
......@@ -66,8 +95,9 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (
6695 [WS_METHODS.serverGetUsageSummary]: (input: UsageSummaryInput) =>
6796 Effect.gen(function* () {
6897 requests += 1;
98 inputs.push(input);
6999 const price = settings.usagePriceOverrides["custom-model"]?.inputCostPerMillionTokens ?? 0;
70 yield* beforeRead(requests);
100 yield* beforeRead(requests, input);
71101 return {
72102 contractVersion: USAGE_CONTRACT_VERSION,
73103 readAt: "2026-09-04T12:00:00Z",
......@@ -92,8 +122,8 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (
92122 sessions: 1,
93123 },
94124 ],
95 sources: [],
96 pricing: { status: "unavailable", source: "test", fetchedAt: null, knownModels: 0 },
125 sources: sources(input),
126 pricing: PRICING,
97127 scanDurationMs: 0,
98128 } satisfies UsageSummary;
99129 }),
......@@ -167,12 +197,44 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (
167197 });
168198 return {
169199 registry,
200 atoms,
170201 requests: () => requests,
202 inputs: () => inputs,
171203 updateSettings,
172204 summary: (input = INPUT) => atoms.usageSummary({ environmentId: TARGET.environmentId, input }),
173205 };
174206});
175207
208function settledSummary<E>(
209 registry: AtomRegistry.AtomRegistry,
210 atom: Atom.Atom<AsyncResult.AsyncResult<UsageSummary, E>>,
211) {
212 return AtomRegistry.toStream(registry, atom).pipe(
213 Stream.filterMap((result) =>
214 AsyncResult.isSuccess(result) && !result.waiting
215 ? Result.succeed(result.value)
216 : Result.failVoid,
217 ),
218 Stream.runHead,
219 Effect.map(Option.getOrThrow),
220 );
221}
222
223/** Answers with a refreshing Cursor source; the `awaitRefresh` request runs `followUp` first. */
224const makeRefreshingHarness = Effect.fn("ServerUsageTest.makeRefreshingHarness")(function* (
225 followUp: Effect.Effect<void, UsageReadError>,
226) {
227 const followUpStarted = yield* Deferred.make<void>();
228 const harness = yield* makeHarness(
229 (_request, input) =>
230 input.awaitRefresh === true
231 ? Deferred.succeed(followUpStarted, undefined).pipe(Effect.andThen(followUp))
232 : Effect.void,
233 (input) => [cursorSource(input.awaitRefresh !== true)],
234 );
235 return { ...harness, followUpStarted };
236});
237
176238function waitForCost<E>(
177239 registry: AtomRegistry.AtomRegistry,
178240 atom: Atom.Atom<AsyncResult.AsyncResult<UsageSummary, E>>,
......@@ -261,3 +323,87 @@ it.effect("restarts a pending usage read after a price change", () =>
261323 }),
262324 ),
263325);
326
327it.effect("settles after one usage read when no source is refreshing", () =>
328 Effect.scoped(
329 Effect.gen(function* () {
330 const harness = yield* makeHarness();
331 const summary = harness.summary();
332 const unmount = harness.registry.mount(summary);
333 yield* settledSummary(harness.registry, summary);
334 expect(harness.inputs()).toEqual([INPUT]);
335 unmount();
336 }),
337 ),
338);
339
340it.effect("shows the cached usage summary until one awaited refresh replaces it", () =>
341 Effect.scoped(
342 Effect.gen(function* () {
343 const refreshed = yield* Deferred.make<void>();
344 const harness = yield* makeRefreshingHarness(Deferred.await(refreshed));
345 const summary = harness.summary();
346 const unmount = harness.registry.mount(summary);
347 yield* Deferred.await(harness.followUpStarted);
348
349 const provisional = harness.registry.get(summary);
350 expect(AsyncResult.isSuccess(provisional) && provisional.waiting).toBe(true);
351 expect(Option.getOrNull(AsyncResult.value(provisional))?.sources).toEqual([
352 cursorSource(true),
353 ]);
354 expect(harness.inputs()).toEqual([INPUT, { ...INPUT, awaitRefresh: true }]);
355
356 yield* Deferred.succeed(refreshed, undefined);
357 expect((yield* settledSummary(harness.registry, summary)).sources).toEqual([
358 cursorSource(false),
359 ]);
360 expect(harness.requests()).toBe(2);
361 unmount();
362 }),
363 ),
364);
365
366it.effect("keeps the cached usage summary when the awaited refresh fails", () =>
367 Effect.scoped(
368 Effect.gen(function* () {
369 const harness = yield* makeRefreshingHarness(
370 Effect.fail(new UsageReadError({ reason: "scanFailed", detail: "Cursor is down" })),
371 );
372 const summary = harness.summary();
373 const unmount = harness.registry.mount(summary);
374 expect((yield* settledSummary(harness.registry, summary)).sources).toEqual([
375 cursorSource(true),
376 ]);
377 expect(AsyncResult.isSuccess(harness.registry.get(summary))).toBe(true);
378 expect(harness.requests()).toBe(2);
379 unmount();
380 }),
381 ),
382);
383
384it.effect("finishes a manual usage refresh only after the awaited refresh", () =>
385 Effect.scoped(
386 Effect.gen(function* () {
387 const refreshed = yield* Deferred.make<void>();
388 const harness = yield* makeRefreshingHarness(Deferred.await(refreshed));
389 const presentation = Atom.make({
390 connection: { phase: "connected" },
391 } as EnvironmentPresentation | null);
392 let finished = false;
393 const refreshing = refreshUsage({
394 registry: harness.registry,
395 server: harness.atoms,
396 presentations: { presentationAtom: () => presentation },
397 environmentIds: [TARGET.environmentId],
398 input: INPUT,
399 }).then(() => {
400 finished = true;
401 });
402 yield* Deferred.await(harness.followUpStarted);
403 expect(finished).toBe(false);
404 yield* Deferred.succeed(refreshed, undefined);
405 yield* Effect.promise(() => refreshing);
406 expect(harness.requests()).toBe(2);
407 }),
408 ),
409);
packages/client-runtime/src/state/usageProgress.test.ts created+150
......@@ -0,0 +1,150 @@
1import {
2 USAGE_CONTRACT_VERSION,
3 UsageDay,
4 type UsageProviderKind,
5 type UsageSummary,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "vite-plus/test";
8
9import { usageEnvironmentProgress, usageLoadingState, usageProgress } from "./usageProgress.ts";
10
11function summary(refreshing: readonly UsageProviderKind[] = []): UsageSummary {
12 return {
13 contractVersion: USAGE_CONTRACT_VERSION,
14 readAt: "2026-09-05T12:00:00Z",
15 timeZone: "UTC",
16 sinceDay: UsageDay.make("2026-09-05"),
17 untilDay: UsageDay.make("2026-09-05"),
18 buckets: [],
19 sources: (["codex", ...refreshing] as const).map((provider) => ({
20 fingerprint: { hostId: "host", provider, resolvedHomePath: "/home", volumeId: "1:1" },
21 status: "ok" as const,
22 scannedFiles: 1,
23 skippedFiles: 0,
24 malformedRecords: 0,
25 distinctSessions: 1,
26 message: null,
27 ...(provider === "codex" ? {} : { refreshing: true as const }),
28 })),
29 pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 },
30 scanDurationMs: 1,
31 };
32}
33
34function environment(
35 label: string,
36 overrides: Partial<{
37 isConnected: boolean;
38 isPending: boolean;
39 error: string | null;
40 summary: UsageSummary | null;
41 }> = {},
42) {
43 return {
44 label,
45 isConnected: true,
46 isPending: false,
47 error: null as string | null,
48 summary: summary(),
49 ...overrides,
50 };
51}
52
53const options = { providerLabel: (provider: UsageProviderKind) => `${provider} label` };
54
55describe("usageProgress", () => {
56 it("dims a lone environment while it refetches over its old totals", () => {
57 expect(usageProgress([environment("a", { isPending: true })], options)).toEqual({
58 dimmed: true,
59 label: "Updating…",
60 });
61 expect(usageProgress([environment("a")], options)).toEqual({ dimmed: false, label: null });
62 });
63
64 it("undims once one device answers and names the slow one", () => {
65 const waiting = [environment("a", { isPending: true }), environment("b", { isPending: true })];
66 expect(usageProgress(waiting, options)).toEqual({
67 dimmed: true,
68 label: "Updating 2 environments…",
69 });
70 expect(
71 usageProgress([environment("a"), ...waiting.slice(1), environment("c")], options),
72 ).toEqual({ dimmed: false, label: "Updating b…" });
73 expect(
74 usageProgress(
75 [environment("a"), environment("b", { summary: null, isPending: true })],
76 options,
77 ),
78 ).toEqual({ dimmed: false, label: "Updating b…" });
79 });
80
81 it("names a slow source without dimming once its environment answers from cache", () => {
82 const partway = environment("a", { isPending: true, summary: summary(["cursor"]) });
83 expect(usageEnvironmentProgress(partway)).toEqual({ phase: "partway", providers: ["cursor"] });
84 expect(usageProgress([partway, environment("b")], options)).toEqual({
85 dimmed: false,
86 label: "Updating cursor label…",
87 });
88 expect(
89 usageProgress(
90 [partway, environment("b", { isPending: true, summary: summary(["cursor", "grok"]) })],
91 options,
92 ),
93 ).toEqual({ dimmed: false, label: "Updating 2 providers…" });
94 // Without a follow-up in flight, a leftover refreshing source is not waited on.
95 expect(usageProgress([{ ...partway, isPending: false }], options)).toEqual({
96 dimmed: false,
97 label: null,
98 });
99 });
100
101 it("ignores reconnecting and failed environments", () => {
102 const ignored = [
103 environment("reconnecting", { isPending: true, isConnected: false }),
104 environment("failed", { error: "Offline", summary: null }),
105 ];
106 expect(usageProgress([environment("a"), ...ignored], options)).toEqual({
107 dimmed: false,
108 label: null,
109 });
110 expect(
111 usageProgress(
112 [environment("a"), ...ignored, environment("slow", { summary: null, isPending: true })],
113 options,
114 ),
115 ).toEqual({ dimmed: false, label: "Updating slow…" });
116 expect(usageEnvironmentProgress(ignored[0]!)).toEqual({ phase: "inactive" });
117 });
118
119 it("dims every answered environment while a manual refresh runs", () => {
120 // Pricing refreshes first, so neither query is pending yet.
121 expect(
122 usageProgress([environment("a"), environment("b")], { ...options, refreshing: true }),
123 ).toEqual({ dimmed: true, label: "Updating 2 environments…" });
124 });
125});
126
127describe("usageLoadingState", () => {
128 it("marks only refreshing providers while the rest have answered", () => {
129 const state = usageLoadingState([
130 environment("a", { isPending: true, summary: summary(["cursor"]) }),
131 environment("b"),
132 ]);
133 expect(state).toEqual({ partial: true, everyProvider: false, providers: new Set(["cursor"]) });
134 });
135
136 it("marks every provider while an environment has not answered", () => {
137 const state = usageLoadingState([environment("a"), environment("b", { summary: null })]);
138 expect(state.everyProvider).toBe(true);
139 expect(state.partial).toBe(true);
140 });
141
142 it("ignores environments that cannot answer and settles once all have", () => {
143 const state = usageLoadingState([
144 environment("a"),
145 environment("b", { summary: null, isConnected: false }),
146 environment("c", { summary: null, error: "Offline" }),
147 ]);
148 expect(state).toEqual({ partial: false, everyProvider: false, providers: new Set() });
149 });
150});
packages/client-runtime/src/state/usageProgress.ts created+114
......@@ -0,0 +1,114 @@
1import type { UsageProviderKind, UsageSummary } from "@t3tools/contracts";
2
3interface UsageProgressEnvironment {
4 readonly label: string;
5 readonly isConnected: boolean;
6 readonly isPending: boolean;
7 readonly error: string | null;
8 readonly summary: UsageSummary | null;
9}
10
11/**
12 * Where one environment's answer stands for the usage on screen.
13 *
14 * - `inactive`: disconnected or failed, so waiting on it changes nothing.
15 * - `loading`: no summary yet.
16 * - `stale`: its summary on screen predates the current request.
17 * - `partway`: answered this round while slow sources finish refreshing.
18 * - `ready`: answered.
19 *
20 * While a manual `refreshing` runs, every summary counts as stale.
21 */
22export function usageEnvironmentProgress(
23 environment: UsageProgressEnvironment,
24 refreshing = false,
25) {
26 const { summary } = environment;
27 if (!environment.isConnected || environment.error !== null) return { phase: "inactive" } as const;
28 if (summary === null) return { phase: "loading" } as const;
29 if (refreshing) return { phase: "stale" } as const;
30 // A refreshing source without a pending follow-up has nothing left to wait on.
31 if (!environment.isPending) return { phase: "ready" } as const;
32 const providers = [
33 ...new Set(
34 summary.sources.flatMap((source) => (source.refreshing ? [source.fingerprint.provider] : [])),
35 ),
36 ];
37 return providers.length > 0
38 ? ({ phase: "partway", providers } as const)
39 : ({ phase: "stale" } as const);
40}
41
42export function updatingProvidersLabel(
43 providers: readonly UsageProviderKind[],
44 providerLabel: (provider: UsageProviderKind) => string,
45) {
46 return providers.length === 1
47 ? `Updating ${providerLabel(providers[0]!)}…`
48 : `Updating ${providers.length} providers…`;
49}
50
51/**
52 * Loading state for the selected environments' merged usage.
53 *
54 * The page dims only while old numbers are all that is on screen. Once any
55 * environment answers, the label carries what is still coming, so one slow
56 * device never holds the page dimmed.
57 */
58export function usageProgress(
59 environments: readonly UsageProgressEnvironment[],
60 {
61 refreshing = false,
62 providerLabel,
63 }: {
64 readonly refreshing?: boolean;
65 readonly providerLabel: (provider: UsageProviderKind) => string;
66 },
67) {
68 const progress = environments.map((environment) => ({
69 label: environment.label,
70 ...usageEnvironmentProgress(environment, refreshing),
71 }));
72 const waiting = progress.filter(({ phase }) => phase === "loading" || phase === "stale");
73 const providers = [
74 ...new Set(progress.flatMap((entry) => (entry.phase === "partway" ? entry.providers : []))),
75 ];
76 const answered = progress.some(({ phase }) => phase === "partway" || phase === "ready");
77 return {
78 dimmed: !answered && progress.some(({ phase }) => phase === "stale"),
79 label:
80 waiting.length === 0
81 ? providers.length === 0
82 ? null
83 : updatingProvidersLabel(providers, providerLabel)
84 : environments.length === 1
85 ? "Updating…"
86 : waiting.length === 1
87 ? `Updating ${waiting[0]!.label}…`
88 : `Updating ${waiting.length} environments…`,
89 };
90}
91
92/**
93 * Which figures in the merged usage on screen are still coming in, so clients
94 * can mute just those.
95 *
96 * - `partial`: totals will change because some environment is still answering.
97 * - `providers`: providers whose own figures will change. An environment that
98 * has not answered yet, or whose whole summary is stale, could change any
99 * provider, so it sets `everyProvider`.
100 */
101export function usageLoadingState(
102 environments: readonly UsageProgressEnvironment[],
103 refreshing = false,
104) {
105 const providers = new Set<UsageProviderKind>();
106 let everyProvider = false;
107 for (const environment of environments) {
108 const progress = usageEnvironmentProgress(environment, refreshing);
109 if (progress.phase === "loading" || progress.phase === "stale") everyProvider = true;
110 if (progress.phase === "partway")
111 for (const provider of progress.providers) providers.add(provider);
112 }
113 return { partial: everyProvider || providers.size > 0, everyProvider, providers };
114}
packages/contracts/src/usage.ts+12
......@@ -183,6 +183,12 @@ export const UsageSource = Schema.Struct({
183183 message: Schema.NullOr(TrimmedNonEmptyString),
184184 /** An action the client can offer to make this source available. */
185185 action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")),
186 /**
187 * Present when this source answered from its cache while a slow refresh (an
188 * account API, for example) runs. Repeat the request with `awaitRefresh` to
189 * get the refreshed source.
190 */
191 refreshing: Schema.optionalKey(Schema.Literal(true)),
186192});
187193export type UsageSource = typeof UsageSource.Type;
188194
......@@ -217,6 +223,12 @@ export const UsageSummaryInput = Schema.Struct({
217223 sinceTime: Schema.optional(TrimmedNonEmptyString),
218224 /** Exclusive UTC instant for an hourly rolling window. */
219225 untilTime: Schema.optional(TrimmedNonEmptyString),
226 /**
227 * Wait for slow sources to finish refreshing instead of answering from their
228 * cache. Clients send it as the follow-up to a summary with a `refreshing`
229 * source. Older servers ignore it and always wait.
230 */
231 awaitRefresh: Schema.optional(Schema.Boolean),
220232});
221233export type UsageSummaryInput = typeof UsageSummaryInput.Type;
222234