| author | |
| committer | |
| log | 85975585579188be20f46d96b4972ce46c33f4bc |
| tree | 0cbc8866c0f7716f1003d134b690d8ad0932a804 |
| parent | 0ce6b9deb0c3f6b4dd9df7ed809f7acb9d237ae0 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | 1 | import { ChatGptUsageSummary } from "./ChatGptUsageSummary"; |
| 2 | 2 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 3 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; | |
| 3 | import { EnvironmentId, USAGE_CONTRACT_VERSION, type UsageProviderKind } from "@t3tools/contracts"; | |
| 4 | 4 | import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; |
| 5 | 5 | import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; |
| 6 | import { | |
| 7 | updatingProvidersLabel, | |
| 8 | usageEnvironmentProgress, | |
| 9 | usageProgress, | |
| 10 | } from "@t3tools/client-runtime/state/usage-progress"; | |
| 6 | 11 | import { |
| 7 | 12 | isCompatibleUsageContractVersion, |
| 8 | 13 | isModelCostUnknown, |
| ... | ... | @@ -21,9 +26,16 @@ import { |
| 21 | 26 | formatUsd, |
| 22 | 27 | makeWindow, |
| 23 | 28 | } from "@t3tools/shared/usageFormat"; |
| 24 | import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 25 | import { Platform, Pressable, RefreshControl, View } from "react-native"; | |
| 26 | import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; | |
| 29 | import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 30 | import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native"; | |
| 31 | import Animated, { | |
| 32 | FadeIn, | |
| 33 | ReduceMotion, | |
| 34 | useAnimatedStyle, | |
| 35 | useSharedValue, | |
| 36 | withDelay, | |
| 37 | withTiming, | |
| 38 | } from "react-native-reanimated"; | |
| 27 | 39 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 28 | 40 | |
| 29 | 41 | import { SegmentedControl } from "../../components/SegmentedControl"; |
| ... | ... | @@ -65,6 +77,7 @@ const METRIC_OPTIONS = [ |
| 65 | 77 | ] as const satisfies readonly { value: UsageChartMetric; label: string }[]; |
| 66 | 78 | |
| 67 | 79 | const CHART_HEIGHT = 180; |
| 80 | const providerLabel = (provider: UsageProviderKind) => PROVIDER_LABEL[provider]; | |
| 68 | 81 | const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; |
| 69 | 82 | |
| 70 | 83 | /** |
| ... | ... | @@ -156,6 +169,10 @@ export function UsageRouteScreen() { |
| 156 | 169 | const [refreshingUsage, setRefreshingUsage] = useState(false); |
| 157 | 170 | const refreshingRef = useRef(false); |
| 158 | 171 | const showingLimits = tab === "limits"; |
| 172 | const progress = usageProgress(selectedEnvironments, { | |
| 173 | refreshing: refreshingUsage, | |
| 174 | providerLabel, | |
| 175 | }); | |
| 159 | 176 | const selectWindow = (days: number) => { |
| 160 | 177 | setWindowSelection({ |
| 161 | 178 | days, |
| ... | ... | @@ -182,10 +199,6 @@ export function UsageRouteScreen() { |
| 182 | 199 | }; |
| 183 | 200 | |
| 184 | 201 | 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"; | |
| 189 | 202 | const filterIcon = |
| 190 | 203 | selectedEnvironmentIds === null |
| 191 | 204 | ? "line.3.horizontal.decrease" |
| ... | ... | @@ -201,14 +214,14 @@ export function UsageRouteScreen() { |
| 201 | 214 | ...environments.map((environment) => ({ |
| 202 | 215 | id: environment.environmentId, |
| 203 | 216 | title: environment.label, |
| 204 | subtitle: usageEnvironmentStatus(environment), | |
| 217 | subtitle: usageEnvironmentStatus(environment, refreshingUsage), | |
| 205 | 218 | state: |
| 206 | 219 | selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId) |
| 207 | 220 | ? ("on" as const) |
| 208 | 221 | : ("off" as const), |
| 209 | 222 | })), |
| 210 | 223 | ], |
| 211 | [environments, selectedEnvironmentIds], | |
| 224 | [environments, refreshingUsage, selectedEnvironmentIds], | |
| 212 | 225 | ); |
| 213 | 226 | const selectEnvironment = useCallback( |
| 214 | 227 | (value: string) => { |
| ... | ... | @@ -227,37 +240,24 @@ export function UsageRouteScreen() { |
| 227 | 240 | <ControlPillMenu |
| 228 | 241 | accessible |
| 229 | 242 | accessibilityRole="button" |
| 230 | accessibilityLabel={filterAccessibilityLabel} | |
| 243 | accessibilityLabel="Filter usage environments" | |
| 231 | 244 | title="Environments" |
| 232 | 245 | actions={environmentActions} |
| 233 | 246 | onPressAction={({ nativeEvent }) => selectEnvironment(nativeEvent.event)} |
| 234 | 247 | > |
| 235 | 248 | <Pressable |
| 236 | 249 | accessibilityRole="button" |
| 237 | accessibilityLabel={filterAccessibilityLabel} | |
| 250 | accessibilityLabel="Filter usage environments" | |
| 238 | 251 | className={cn( |
| 239 | 252 | "items-center justify-center rounded-full", |
| 240 | 253 | Platform.OS === "ios" ? "size-[28px]" : "size-[44px]", |
| 241 | 254 | )} |
| 242 | 255 | > |
| 243 | 256 | <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} | |
| 250 | 257 | </Pressable> |
| 251 | 258 | </ControlPillMenu> |
| 252 | 259 | ) : null, |
| 253 | [ | |
| 254 | showEnvironmentFilter, | |
| 255 | environmentActions, | |
| 256 | selectEnvironment, | |
| 257 | filterAccessibilityLabel, | |
| 258 | filterIcon, | |
| 259 | hasLoadingEnvironments, | |
| 260 | ], | |
| 260 | [showEnvironmentFilter, environmentActions, selectEnvironment, filterIcon], | |
| 261 | 261 | ); |
| 262 | 262 | |
| 263 | 263 | useLayoutEffect(() => { |
| ... | ... | @@ -361,26 +361,28 @@ export function UsageRouteScreen() { |
| 361 | 361 | {message} |
| 362 | 362 | </Text> |
| 363 | 363 | ))} |
| 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> | |
| 384 | 386 | </> |
| 385 | 387 | )} |
| 386 | 388 | </> |
| ... | ... | @@ -391,6 +393,53 @@ export function UsageRouteScreen() { |
| 391 | 393 | ); |
| 392 | 394 | } |
| 393 | 395 | |
| 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 | */ | |
| 401 | function 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 | ||
| 394 | 443 | function CursorEnableAction({ |
| 395 | 444 | environmentId, |
| 396 | 445 | label, |
| ... | ... | @@ -860,11 +909,7 @@ function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: U |
| 860 | 909 | * one that failed, or one whose transcripts another environment already |
| 861 | 910 | * reported. |
| 862 | 911 | */ |
| 863 | function isUsageLoading(environment: EnvironmentUsageStatus) { | |
| 864 | return environment.isPending || (environment.summary === null && environment.error === null); | |
| 865 | } | |
| 866 | ||
| 867 | function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { | |
| 912 | function usageEnvironmentStatus(environment: EnvironmentUsageStatus, refreshing: boolean): string { | |
| 868 | 913 | if ( |
| 869 | 914 | environment.summary && |
| 870 | 915 | !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION) |
| ... | ... | @@ -881,7 +926,10 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { |
| 881 | 926 | return environment.summary ? `${environment.error} Showing saved totals.` : environment.error; |
| 882 | 927 | if (!environment.isConnected) |
| 883 | 928 | 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); | |
| 886 | 934 | return "Usage up to date"; |
| 887 | 935 | } |
apps/server/src/server.ts+5-1| ... | ... | @@ -153,6 +153,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien |
| 153 | 153 | import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; |
| 154 | 154 | import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; |
| 155 | 155 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 156 | import * as CursorUsageReader from "./usage/cursorUsageReader.ts"; | |
| 156 | 157 | import * as UsageService from "./usage/UsageService.ts"; |
| 157 | 158 | import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts"; |
| 158 | 159 | import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; |
| ... | ... | @@ -225,7 +226,10 @@ const layerBackground = BackgroundPolicy.layer.pipe( |
| 225 | 226 | Layer.provideMerge(layerServerSettings), |
| 226 | 227 | ); |
| 227 | 228 | |
| 228 | const layerUsage = UsageService.layer.pipe(Layer.provide(layerServerSettings)); | |
| 229 | const layerUsage = UsageService.layer.pipe( | |
| 230 | Layer.provide(layerServerSettings), | |
| 231 | Layer.provide(CursorUsageReader.layer), | |
| 232 | ); | |
| 229 | 233 | |
| 230 | 234 | const layerResourceDiagnostics = Layer.mergeAll( |
| 231 | 235 | HostResources.layer, |
apps/server/src/usage/UsageService.test.ts+276-2| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | ProviderDriverKind, |
| 16 | 16 | ProviderInstanceId, |
| 17 | 17 | UsageDay, |
| 18 | type UsageSource, | |
| 18 | 19 | type UsageSummaryInput, |
| 19 | 20 | } from "@t3tools/contracts"; |
| 20 | 21 | import * as Duration from "effect/Duration"; |
| ... | ... | @@ -31,6 +32,8 @@ import { HttpClient, HttpClientResponse } from "effect/http"; |
| 31 | 32 | |
| 32 | 33 | import * as ServerConfig from "../config.ts"; |
| 33 | 34 | import * as ServerSettings from "../serverSettings.ts"; |
| 35 | import * as CursorUsageReader from "./cursorUsageReader.ts"; | |
| 36 | import type { UsageRecord } from "./usageTranscripts.ts"; | |
| 34 | 37 | import * as UsageService from "./UsageService.ts"; |
| 35 | 38 | |
| 36 | 39 | const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| ... | ... | @@ -90,6 +93,7 @@ const layerService = (input: { |
| 90 | 93 | }) => |
| 91 | 94 | ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( |
| 92 | 95 | Layer.provideMerge(NodeServices.layer), |
| 96 | Layer.provideMerge(CursorUsageReader.layer), | |
| 93 | 97 | Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")), |
| 94 | 98 | Layer.provideMerge(ServerSettings.layerTest(input.settings)), |
| 95 | 99 | Layer.provideMerge( |
| ... | ... | @@ -157,6 +161,71 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens |
| 157 | 161 | return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0); |
| 158 | 162 | } |
| 159 | 163 | |
| 164 | /** Inside `WINDOW`: the time Cursor tests run at. */ | |
| 165 | const CURSOR_NOW = Date.parse("2026-08-02T12:00:00Z"); | |
| 166 | const HOUR_MS = 60 * 60 * 1000; | |
| 167 | /** The service's cache retention, which the Cursor account cache always covers. */ | |
| 168 | const 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 | */ | |
| 175 | function 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 | ||
| 218 | const 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 | ||
| 225 | function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { | |
| 226 | return summary.sources.find((source) => source.fingerprint.provider === "cursor"); | |
| 227 | } | |
| 228 | ||
| 160 | 229 | describe("UsageService", () => { |
| 161 | 230 | it.live.each([ |
| 162 | 231 | { explicitDefault: true, label: "explicit" }, |
| ... | ... | @@ -196,6 +265,9 @@ describe("UsageService", () => { |
| 196 | 265 | const service = yield* UsageService.make; |
| 197 | 266 | return yield* service.readSummary(WINDOW); |
| 198 | 267 | }).pipe( |
| 268 | // Scoped inside the state directory, so pending cache writes land | |
| 269 | // before it is removed. | |
| 270 | Effect.scoped, | |
| 199 | 271 | Effect.provide( |
| 200 | 272 | layerService({ |
| 201 | 273 | prefix: "usage-managed-accounts", |
| ... | ... | @@ -282,12 +354,201 @@ describe("UsageService", () => { |
| 282 | 354 | layerService({ prefix: "usage-service-cursor-invalid-login", home, settings }), |
| 283 | 355 | ), |
| 284 | 356 | ); |
| 285 | const summary = yield* service.readSummary(WINDOW); | |
| 357 | const summary = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); | |
| 286 | 358 | const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); |
| 287 | 359 | assert.strictEqual(cursor?.message, "Cursor credentials could not be read."); |
| 288 | 360 | }).pipe(Effect.scoped), |
| 289 | 361 | ); |
| 290 | 362 | |
| 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 | ||
| 291 | 552 | it.live("does not read the macOS Cursor Keychain before account usage is enabled", () => |
| 292 | 553 | Effect.gen(function* () { |
| 293 | 554 | const { settings, home } = yield* setup; |
| ... | ... | @@ -642,6 +903,7 @@ describe("UsageService", () => { |
| 642 | 903 | environmentProjects, |
| 643 | 904 | ); |
| 644 | 905 | }).pipe( |
| 906 | Effect.scoped, | |
| 645 | 907 | Effect.provide( |
| 646 | 908 | layerService({ |
| 647 | 909 | prefix: "usage-service-home-refresh-test", |
| ... | ... | @@ -747,6 +1009,7 @@ describe("UsageService", () => { |
| 747 | 1009 | const restored = yield* service.readSummary(WINDOW); |
| 748 | 1010 | assert.deepStrictEqual(restored.buckets, original.buckets); |
| 749 | 1011 | }).pipe( |
| 1012 | Effect.scoped, | |
| 750 | 1013 | Effect.provide( |
| 751 | 1014 | layerService({ prefix: "usage-service-price-overrides-test", home, settings }), |
| 752 | 1015 | ), |
| ... | ... | @@ -801,9 +1064,11 @@ describe("UsageService", () => { |
| 801 | 1064 | appended.buckets.reduce((sum, bucket) => sum + bucket.totals.uncachedInputTokens, 0), |
| 802 | 1065 | 20, |
| 803 | 1066 | ); |
| 1067 | yield* service.awaitPersisted; | |
| 804 | 1068 | const restarted = yield* UsageService.make; |
| 805 | 1069 | const restored = yield* restarted.readSummary(WINDOW); |
| 806 | 1070 | assert.deepStrictEqual(restored.buckets, appended.buckets); |
| 1071 | yield* restarted.awaitPersisted; | |
| 807 | 1072 | yield* Effect.promise(() => NodeFSP.rm(transcript)); |
| 808 | 1073 | const afterCleanup = yield* UsageService.make; |
| 809 | 1074 | assert.deepStrictEqual( |
| ... | ... | @@ -811,6 +1076,7 @@ describe("UsageService", () => { |
| 811 | 1076 | appended.buckets, |
| 812 | 1077 | ); |
| 813 | 1078 | }).pipe( |
| 1079 | Effect.scoped, | |
| 814 | 1080 | Effect.provide( |
| 815 | 1081 | layerService({ |
| 816 | 1082 | prefix: "usage-service-large-record-test", |
| ... | ... | @@ -865,7 +1131,9 @@ describe("UsageService", () => { |
| 865 | 1131 | const { stateDir } = yield* ServerConfig.ServerConfig; |
| 866 | 1132 | const cachePath = NodePath.join(stateDir, "usage-scan-cache-v5.json"); |
| 867 | 1133 | 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; | |
| 869 | 1137 | |
| 870 | 1138 | // Rewrite the cache as a v4 server left it: every Codex record at |
| 871 | 1139 | // speed 0 (standard), and no tier in the reducer state. |
| ... | ... | @@ -937,6 +1205,7 @@ describe("UsageService", () => { |
| 937 | 1205 | assert.deepStrictEqual(deleted.buckets, first.buckets); |
| 938 | 1206 | assert.deepStrictEqual(deleted.sources, first.sources); |
| 939 | 1207 | |
| 1208 | yield* service.awaitPersisted; | |
| 940 | 1209 | const restarted = yield* UsageService.make; |
| 941 | 1210 | const restored = yield* restarted.readSummary(WINDOW); |
| 942 | 1211 | assert.deepStrictEqual(restored.buckets, first.buckets); |
| ... | ... | @@ -947,6 +1216,7 @@ describe("UsageService", () => { |
| 947 | 1216 | const moved = yield* restarted.readSummary(WINDOW); |
| 948 | 1217 | assert.deepStrictEqual(moved.buckets, first.buckets); |
| 949 | 1218 | assert.strictEqual(moved.sources[0]?.distinctSessions, 1); |
| 1219 | yield* restarted.awaitPersisted; | |
| 950 | 1220 | |
| 951 | 1221 | const replacementProjects = NodePath.join(home, "replacement-projects"); |
| 952 | 1222 | yield* Effect.promise(() => NodeFSP.mkdir(replacementProjects)); |
| ... | ... | @@ -994,6 +1264,9 @@ describe("UsageService", () => { |
| 994 | 1264 | assert.deepStrictEqual(outsideWindow.buckets, []); |
| 995 | 1265 | assert.strictEqual(outsideWindow.sources[0]?.distinctSessions, 0); |
| 996 | 1266 | }).pipe( |
| 1267 | // Scoped inside the state directory, so pending cache writes land | |
| 1268 | // before it is removed. | |
| 1269 | Effect.scoped, | |
| 997 | 1270 | Effect.provide( |
| 998 | 1271 | layerService({ |
| 999 | 1272 | prefix: "usage-service-cleanup-test", |
| ... | ... | @@ -1039,6 +1312,7 @@ describe("UsageService", () => { |
| 1039 | 1312 | const saved = yield* service.readSummary(WINDOW); |
| 1040 | 1313 | assert.deepStrictEqual(saved.buckets, live.buckets); |
| 1041 | 1314 | assert.deepStrictEqual(saved.sources, live.sources); |
| 1315 | yield* service.awaitPersisted; | |
| 1042 | 1316 | const restored = yield* (yield* UsageService.make).readSummary(WINDOW); |
| 1043 | 1317 | assert.deepStrictEqual(restored.buckets, live.buckets); |
| 1044 | 1318 | }).pipe( |
apps/server/src/usage/UsageService.ts+467-212| ... | ... | @@ -12,6 +12,10 @@ |
| 12 | 12 | * remain visible. Antigravity databases are memoised in memory while the |
| 13 | 13 | * database and its WAL keep the same `(size, mtime, ctime)`. |
| 14 | 14 | * |
| 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 | * | |
| 15 | 19 | * @module UsageService |
| 16 | 20 | */ |
| 17 | 21 | import * as NodeOS from "node:os"; |
| ... | ... | @@ -38,6 +42,8 @@ import * as Crypto from "effect/Crypto"; |
| 38 | 42 | import * as DateTime from "effect/DateTime"; |
| 39 | 43 | import * as Deferred from "effect/Deferred"; |
| 40 | 44 | import * as Effect from "effect/Effect"; |
| 45 | import * as Exit from "effect/Exit"; | |
| 46 | import * as Fiber from "effect/Fiber"; | |
| 41 | 47 | import * as FileSystem from "effect/FileSystem"; |
| 42 | 48 | import * as Layer from "effect/Layer"; |
| 43 | 49 | import * as Option from "effect/Option"; |
| ... | ... | @@ -55,7 +61,18 @@ import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAu |
| 55 | 61 | import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; |
| 56 | 62 | import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; |
| 57 | 63 | import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; |
| 58 | import { readCursorAccountUsage } from "./cursorUsageReader.ts"; | |
| 64 | import { | |
| 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"; | |
| 75 | import * as CursorUsageReader from "./cursorUsageReader.ts"; | |
| 59 | 76 | import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; |
| 60 | 77 | import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; |
| 61 | 78 | import { |
| ... | ... | @@ -91,8 +108,13 @@ const RATES_REFRESH_FLOOR_MS = 60 * 1000; |
| 91 | 108 | const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; |
| 92 | 109 | const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; |
| 93 | 110 | |
| 94 | /** Longest window the UI offers, plus slack. Older entries are pruned. */ | |
| 95 | const 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 | */ | |
| 115 | const CACHE_RETENTION_DAYS = 92; | |
| 116 | ||
| 117 | const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; | |
| 96 | 118 | |
| 97 | 119 | /** Transcripts parsed at once. More gains little once the disk stays busy. */ |
| 98 | 120 | const TRANSCRIPT_READ_CONCURRENCY = 4; |
| ... | ... | @@ -195,11 +217,26 @@ export const make = Effect.gen(function* () { |
| 195 | 217 | const httpClient = yield* HttpClient.HttpClient; |
| 196 | 218 | const hostEnvironment = yield* HostProcessEnvironment; |
| 197 | 219 | const platform = yield* HostProcessPlatform; |
| 220 | const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader; | |
| 198 | 221 | |
| 199 | 222 | const fileCache: ScanCache = new Map(); |
| 200 | 223 | const antigravityCache = makeAntigravityUsageCache(); |
| 201 | 224 | const sourceCache = new Map<string, typeof CachedSource.Type>(); |
| 202 | 225 | 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>>(); | |
| 203 | 240 | const isWithinDirectory = (filePath: string, dir: string) => { |
| 204 | 241 | const relative = path.relative(dir, filePath); |
| 205 | 242 | return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); |
| ... | ... | @@ -208,6 +245,7 @@ export const make = Effect.gen(function* () { |
| 208 | 245 | const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); |
| 209 | 246 | const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); |
| 210 | 247 | const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME); |
| 248 | const cursorCachePath = path.join(config.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); | |
| 211 | 249 | const writeCacheFile = (filePath: string, contents: string) => |
| 212 | 250 | writeFileStringAtomically({ filePath, contents }).pipe( |
| 213 | 251 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| ... | ... | @@ -398,7 +436,7 @@ export const make = Effect.gen(function* () { |
| 398 | 436 | }); |
| 399 | 437 | |
| 400 | 438 | /** |
| 401 | * Loads the persisted scan cache exactly once per process. | |
| 439 | * Loads the persisted scan and Cursor account caches exactly once per process. | |
| 402 | 440 | * |
| 403 | 441 | * `Effect.cached` makes concurrent first readers await the same load rather |
| 404 | 442 | * than each seeing a "loaded" flag set before the read finished and cold |
| ... | ... | @@ -411,6 +449,9 @@ export const make = Effect.gen(function* () { |
| 411 | 449 | Effect.flatMap((raw) => decodeScanCacheFile(raw)), |
| 412 | 450 | Effect.catchCause(() => Effect.succeed(null)), |
| 413 | 451 | ); |
| 452 | for (const [key, cache] of decodeCursorAccountCaches(yield* readDocument(cursorCachePath))) { | |
| 453 | cursorCaches.set(key, cache); | |
| 454 | } | |
| 414 | 455 | let document = yield* readDocument(scanCachePath); |
| 415 | 456 | if (document === null) { |
| 416 | 457 | document = yield* readDocument(legacyScanCachePath); |
| ... | ... | @@ -432,25 +473,52 @@ export const make = Effect.gen(function* () { |
| 432 | 473 | // keeps an older snapshot from landing after a newer one. |
| 433 | 474 | const persistLock = yield* Semaphore.make(1); |
| 434 | 475 | |
| 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); | |
| 454 | 522 | |
| 455 | 523 | /** |
| 456 | 524 | * Parses one transcript, reusing the cached result when it is unchanged. |
| ... | ... | @@ -541,58 +609,223 @@ export const make = Effect.gen(function* () { |
| 541 | 609 | readonly files: |
| 542 | 610 | | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] |
| 543 | 611 | | null; |
| 612 | /** Answered from a cache while a refresh runs. */ | |
| 613 | readonly refreshing?: true; | |
| 544 | 614 | } |
| 545 | 615 | |
| 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 | ||
| 546 | 818 | const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( |
| 547 | 819 | windowStartMs: number, |
| 548 | 820 | settings: ServerSettingsValue, |
| 549 | 821 | retentionCutoffMs: number, |
| 822 | awaitRefresh: boolean, | |
| 550 | 823 | ) { |
| 551 | 824 | // The home resolvers ask for `Path` themselves; satisfy them from the |
| 552 | 825 | // instance we already hold so the scan stays context-free. |
| 553 | 826 | const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( |
| 554 | 827 | Effect.provideService(Path.Path, path), |
| 555 | 828 | ); |
| 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 | } | |
| 596 | 829 | |
| 597 | 830 | const home = NodeOS.homedir(); |
| 598 | 831 | const envRoots = Effect.fnUntraced(function* (key: string, defaults: readonly string[]) { |
| ... | ... | @@ -610,156 +843,168 @@ export const make = Effect.gen(function* () { |
| 610 | 843 | return [...canonical]; |
| 611 | 844 | }); |
| 612 | 845 | 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" }, | |
| 675 | 869 | ); |
| 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")); | |
| 728 | 897 | } |
| 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}`), | |
| 737 | 922 | ); |
| 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 | } | |
| 752 | 932 | 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.", | |
| 762 | 933 | }); |
| 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 | ]; | |
| 763 | 1008 | return scanned; |
| 764 | 1009 | }); |
| 765 | 1010 | |
| ... | ... | @@ -818,7 +1063,10 @@ export const make = Effect.gen(function* () { |
| 818 | 1063 | // loads while transcripts stream instead of gating them: a cold rates |
| 819 | 1064 | // fetch on a slow network no longer delays the scan by its own timeout. |
| 820 | 1065 | 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 | ], | |
| 822 | 1070 | { concurrency: 2 }, |
| 823 | 1071 | ); |
| 824 | 1072 | |
| ... | ... | @@ -859,7 +1107,7 @@ export const make = Effect.gen(function* () { |
| 859 | 1107 | |
| 860 | 1108 | for (const [ |
| 861 | 1109 | index, |
| 862 | { provider, dir, volumeId, files, status, message, action, hostId: sourceHostId }, | |
| 1110 | { provider, dir, volumeId, files, status, message, action, refreshing, hostId: sourceHostId }, | |
| 863 | 1111 | ] of scannedDirs.entries()) { |
| 864 | 1112 | let scannedFiles = 0; |
| 865 | 1113 | let skippedFiles = 0; |
| ... | ... | @@ -910,12 +1158,12 @@ export const make = Effect.gen(function* () { |
| 910 | 1158 | message: |
| 911 | 1159 | message ?? (files === null ? "No transcript directory on this environment." : null), |
| 912 | 1160 | ...(action ? { action } : {}), |
| 1161 | ...(refreshing ? { refreshing } : {}), | |
| 913 | 1162 | }); |
| 914 | 1163 | } |
| 915 | 1164 | |
| 916 | 1165 | const pruned = pruneScanCache(fileCache, retentionCutoffMs); |
| 917 | 1166 | if (pruned > 0) cacheDirty = true; |
| 918 | yield* persistScanCache(); | |
| 919 | 1167 | |
| 920 | 1168 | const aggregated = aggregator.finish(); |
| 921 | 1169 | const readAt = yield* DateTime.now; |
| ... | ... | @@ -952,6 +1200,8 @@ export const make = Effect.gen(function* () { |
| 952 | 1200 | settings.usagePriceOverrides, |
| 953 | 1201 | settings.usageModelAliases, |
| 954 | 1202 | settings.cursorKeychainUsageEnabled, |
| 1203 | // A waiting read must never share a scan that answers with `refreshing`. | |
| 1204 | input.awaitRefresh === true, | |
| 955 | 1205 | ]); |
| 956 | 1206 | |
| 957 | 1207 | const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { |
| ... | ... | @@ -968,9 +1218,12 @@ export const make = Effect.gen(function* () { |
| 968 | 1218 | inflightScans.set(key, created); |
| 969 | 1219 | // Detached so one departing client cannot tear the scan out from under |
| 970 | 1220 | // 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. | |
| 971 | 1223 | yield* scanSummary(input, settings).pipe( |
| 972 | 1224 | Effect.onExit((exit) => |
| 973 | 1225 | Effect.sync(() => inflightScans.delete(key)).pipe( |
| 1226 | Effect.andThen(Exit.isSuccess(exit) ? schedulePersist : Effect.void), | |
| 974 | 1227 | Effect.andThen(Deferred.done(created, exit)), |
| 975 | 1228 | ), |
| 976 | 1229 | ), |
| ... | ... | @@ -984,7 +1237,9 @@ export const make = Effect.gen(function* () { |
| 984 | 1237 | return yield* Deferred.await(deferred); |
| 985 | 1238 | }); |
| 986 | 1239 | |
| 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; | |
| 988 | 1243 | }); |
| 989 | 1244 | |
| 990 | 1245 | export const layer = Layer.effect(UsageService, make); |
apps/server/src/usage/cursorAccountCache.test.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts"; | |
| 4 | import type { UsageRecord } from "./usageTranscripts.ts"; | |
| 5 | ||
| 6 | const DAY_MS = 24 * 60 * 60 * 1000; | |
| 7 | ||
| 8 | const 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 | ||
| 25 | describe("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 | */ | |
| 15 | import { cursorRateModel } from "./cursorUsageReader.ts"; | |
| 16 | import type { UsageRecord } from "./usageTranscripts.ts"; | |
| 17 | ||
| 18 | export type CursorCredentialSource = string | { readonly kind: "keychain" }; | |
| 19 | ||
| 20 | /** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */ | |
| 21 | export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000; | |
| 22 | ||
| 23 | /** Dashboard events can be finalized after they were first read. */ | |
| 24 | const REFETCH_OVERLAP_MS = 60 * 60 * 1000; | |
| 25 | ||
| 26 | export 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 | ||
| 36 | interface CursorFetchRange { | |
| 37 | readonly sinceMs: number; | |
| 38 | readonly untilMs: number; | |
| 39 | } | |
| 40 | ||
| 41 | /** Whether the cache answers without a refresh. */ | |
| 42 | export 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 | */ | |
| 53 | export 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 | */ | |
| 71 | export 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 | */ | |
| 107 | export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json"; | |
| 108 | const CURSOR_ACCOUNT_CACHE_VERSION = 1; | |
| 109 | ||
| 110 | /** Positional rows with interned strings, as in the scan cache. */ | |
| 111 | type 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 | ||
| 123 | interface 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. */ | |
| 132 | export 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 | ||
| 167 | const 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 | */ | |
| 175 | export 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"; |
| 4 | 4 | import * as NodeCrypto from "node:crypto"; |
| 5 | 5 | import * as NodeTimersPromises from "node:timers/promises"; |
| 6 | 6 | |
| 7 | import * as Context from "effect/Context"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | ||
| 7 | 11 | import type { UsageRecord } from "./usageTranscripts.ts"; |
| 8 | 12 | import { |
| 9 | 13 | CursorKeychainTimeoutError, |
| ... | ... | @@ -289,3 +293,24 @@ export async function readCursorAccountUsage( |
| 289 | 293 | cancel.abort(); |
| 290 | 294 | } |
| 291 | 295 | } |
| 296 | ||
| 297 | /** Reads one range of Cursor account usage. A service so tests can stand in for Cursor's API. */ | |
| 298 | export 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. */ | |
| 310 | export 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", () => ({ |
| 50 | 50 | }, |
| 51 | 51 | ], |
| 52 | 52 | isPending: false, |
| 53 | shown: null, | |
| 53 | 54 | isPartial: false, |
| 54 | 55 | refresh: async () => undefined, |
| 55 | 56 | }), |
apps/web/src/components/usage/UsagePage.test.tsx+1| ... | ... | @@ -75,6 +75,7 @@ beforeEach(() => { |
| 75 | 75 | environments, |
| 76 | 76 | selectedEnvironments: environments, |
| 77 | 77 | isPending: false, |
| 78 | shown: null, | |
| 78 | 79 | isPartial: false, |
| 79 | 80 | refresh: vi.fn(), |
| 80 | 81 | }); |
apps/web/src/components/usage/UsagePage.tsx+201-76| ... | ... | @@ -8,18 +8,17 @@ import { |
| 8 | 8 | type EnvironmentId, |
| 9 | 9 | type UsageProviderKind, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | import { | |
| 12 | CircleAlertIcon, | |
| 13 | ChevronDownIcon, | |
| 14 | CircleDashedIcon, | |
| 15 | InfoIcon, | |
| 16 | SlidersHorizontalIcon, | |
| 17 | } from "lucide-react"; | |
| 11 | import { CircleAlertIcon, ChevronDownIcon, InfoIcon, SlidersHorizontalIcon } from "lucide-react"; | |
| 18 | 12 | import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 19 | 13 | import { |
| 20 | 14 | cursorKeychainAccessEnvironments, |
| 21 | 15 | refreshUsageLimits, |
| 22 | 16 | } from "@t3tools/client-runtime/state/usage"; |
| 17 | import { | |
| 18 | updatingProvidersLabel, | |
| 19 | usageEnvironmentProgress, | |
| 20 | usageLoadingState, | |
| 21 | } from "@t3tools/client-runtime/state/usage-progress"; | |
| 23 | 22 | |
| 24 | 23 | import { |
| 25 | 24 | isCompatibleUsageContractVersion, |
| ... | ... | @@ -109,6 +108,8 @@ function isUsageWindowDays(value: number): value is UsagePagePreferences["window |
| 109 | 108 | return WINDOW_OPTIONS.some((option) => option.days === value); |
| 110 | 109 | } |
| 111 | 110 | |
| 111 | const providerLabel = (provider: UsageProviderKind) => PROVIDER_PRESENTATION[provider].label; | |
| 112 | ||
| 112 | 113 | export function UsagePage() { |
| 113 | 114 | const [preferences, setPreferences] = useState(readUsagePagePreferences); |
| 114 | 115 | useEscapeToGoBack(); |
| ... | ... | @@ -145,11 +146,36 @@ export function UsagePage() { |
| 145 | 146 | ); |
| 146 | 147 | const { days: windowDays, window } = windowSelection; |
| 147 | 148 | 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); | |
| 153 | 179 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 154 | 180 | const cursorAccessEnvironments = hiddenProviders.has("cursor") |
| 155 | 181 | ? [] |
| ... | ... | @@ -180,21 +206,21 @@ export function UsagePage() { |
| 180 | 206 | ); |
| 181 | 207 | |
| 182 | 208 | 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], | |
| 185 | 211 | ); |
| 186 | 212 | const hours = useMemo( |
| 187 | 213 | () => |
| 188 | window.sinceTime === undefined || window.untilTime === undefined | |
| 214 | shownWindow.sinceTime === undefined || shownWindow.untilTime === undefined | |
| 189 | 215 | ? [] |
| 190 | : enumerateHourStarts(window.sinceTime, window.untilTime), | |
| 191 | [window.sinceTime, window.untilTime], | |
| 216 | : enumerateHourStarts(shownWindow.sinceTime, shownWindow.untilTime), | |
| 217 | [shownWindow.sinceTime, shownWindow.untilTime], | |
| 192 | 218 | ); |
| 193 | 219 | // Newest first: the window can run 90 periods, so the interesting end |
| 194 | 220 | // belongs at the top of the table. |
| 195 | 221 | 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], | |
| 198 | 224 | ); |
| 199 | 225 | const breakdownModels = useMemo( |
| 200 | 226 | () => |
| ... | ... | @@ -203,7 +229,24 @@ export function UsagePage() { |
| 203 | 229 | : merged.models, |
| 204 | 230 | [breakdown, merged.models, metric], |
| 205 | 231 | ); |
| 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 | ); | |
| 207 | 250 | const selectedModel = |
| 208 | 251 | selectedModelKey === null |
| 209 | 252 | ? undefined |
| ... | ... | @@ -332,10 +375,11 @@ export function UsagePage() { |
| 332 | 375 | if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits(); |
| 333 | 376 | }, [showingLimits, connectedLimitsEnvironments]); |
| 334 | 377 | |
| 378 | // Names the period on screen, which is the previous one until the new one answers. | |
| 335 | 379 | 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)}`; | |
| 339 | 383 | const topbarContent = ( |
| 340 | 384 | <div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 xl:flex"> |
| 341 | 385 | <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0"> |
| ... | ... | @@ -350,6 +394,7 @@ export function UsagePage() { |
| 350 | 394 | selectedEnvironmentIds={selectedEnvironmentIds} |
| 351 | 395 | onSelectionChange={setSelectedEnvironmentIds} |
| 352 | 396 | showUsageStatus={!showingLimits} |
| 397 | refreshing={refreshingUsage} | |
| 353 | 398 | isPartial={isPartial} |
| 354 | 399 | duplicateSources={merged.duplicateSources} |
| 355 | 400 | contractMismatches={merged.contractMismatches} |
| ... | ... | @@ -510,7 +555,7 @@ export function UsagePage() { |
| 510 | 555 | ) : null |
| 511 | 556 | } |
| 512 | 557 | /> |
| 513 | ) : isPending ? ( | |
| 558 | ) : shown === null ? ( | |
| 514 | 559 | <UsageSkeleton /> |
| 515 | 560 | ) : !canReadDiagnostics ? ( |
| 516 | 561 | <div className="space-y-2 py-12 text-center text-sm text-muted-foreground"> |
| ... | ... | @@ -522,7 +567,7 @@ export function UsagePage() { |
| 522 | 567 | ))} |
| 523 | 568 | </div> |
| 524 | 569 | ) : ( |
| 525 | <> | |
| 570 | <div aria-busy={loading.partial} className="flex flex-col gap-6"> | |
| 526 | 571 | {sourceMessages.map((message) => ( |
| 527 | 572 | <p key={message} className="mb-4 text-sm text-muted-foreground"> |
| 528 | 573 | {message} |
| ... | ... | @@ -531,13 +576,20 @@ export function UsagePage() { |
| 531 | 576 | <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]"> |
| 532 | 577 | <div className="flex min-w-0 flex-col gap-5"> |
| 533 | 578 | <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 | > | |
| 535 | 585 | {metric === "cost" |
| 536 | 586 | ? formatUsd(merged.costUsd) |
| 537 | 587 | : formatTokens(merged.totalTokens)} |
| 538 | 588 | </span> |
| 539 | 589 | <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> | |
| 541 | 593 | {metric === "cost" && ( |
| 542 | 594 | <> |
| 543 | 595 | {" · API estimate"} |
| ... | ... | @@ -600,6 +652,8 @@ export function UsagePage() { |
| 600 | 652 | const sessionLabel = `${formatCount(providerSessions)} ${ |
| 601 | 653 | providerSessions === 1 ? "session" : "sessions" |
| 602 | 654 | }`; |
| 655 | const providerLoading = isProviderLoading(provider); | |
| 656 | const awaitingData = providerLoading && !providersWithData.includes(provider); | |
| 603 | 657 | return ( |
| 604 | 658 | <div key={provider} className="flex flex-col gap-1"> |
| 605 | 659 | <div className="flex items-baseline justify-between gap-4"> |
| ... | ... | @@ -616,18 +670,38 @@ export function UsagePage() { |
| 616 | 670 | <span className="truncate"> |
| 617 | 671 | {PROVIDER_PRESENTATION[provider].label} |
| 618 | 672 | </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 | > | |
| 620 | 680 | {sessionLabel} |
| 621 | 681 | </span> |
| 622 | 682 | </span> |
| 623 | 683 | </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)} | |
| 628 | 695 | </span> |
| 629 | 696 | </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 | > | |
| 631 | 705 | {metric === "cost" |
| 632 | 706 | ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` |
| 633 | 707 | : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} |
| ... | ... | @@ -639,19 +713,20 @@ export function UsagePage() { |
| 639 | 713 | |
| 640 | 714 | <div className="flex min-w-0 flex-col gap-3"> |
| 641 | 715 | <h2 className="text-sm font-medium text-foreground"> |
| 642 | {isPast24Hours ? "Hourly" : "Daily"}{" "} | |
| 716 | {shownHourly ? "Hourly" : "Daily"}{" "} | |
| 643 | 717 | {metric === "tokens" ? "processed tokens" : "cost"} |
| 644 | 718 | </h2> |
| 645 | 719 | <UsageProviderChart |
| 646 | 720 | providers={activeProviders} |
| 721 | loadingProviders={chartLoadingProviders} | |
| 647 | 722 | days={days} |
| 648 | 723 | daily={merged.daily} |
| 649 | 724 | hours={hours} |
| 650 | 725 | hourly={merged.hourly} |
| 651 | 726 | 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} | |
| 655 | 730 | /> |
| 656 | 731 | </div> |
| 657 | 732 | </section> |
| ... | ... | @@ -659,14 +734,28 @@ export function UsagePage() { |
| 659 | 734 | <section className="flex flex-col gap-2"> |
| 660 | 735 | <h2 className="text-sm font-medium text-foreground">Totals</h2> |
| 661 | 736 | <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)} /> | |
| 664 | 737 | <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} | |
| 665 | 749 | label="Uncached input" |
| 666 | 750 | value={formatTokens(merged.uncachedInputTokens)} |
| 667 | 751 | /> |
| 668 | <Metric label="Output" value={formatTokens(merged.outputTokens)} /> | |
| 669 | 752 | <Metric |
| 753 | loading={loading.partial} | |
| 754 | label="Output" | |
| 755 | value={formatTokens(merged.outputTokens)} | |
| 756 | /> | |
| 757 | <Metric | |
| 758 | loading={loading.partial} | |
| 670 | 759 | label="Cache savings" |
| 671 | 760 | value={formatUsd(merged.costQuality.cacheSavingsUsd)} |
| 672 | 761 | /> |
| ... | ... | @@ -674,7 +763,12 @@ export function UsagePage() { |
| 674 | 763 | </section> |
| 675 | 764 | |
| 676 | 765 | {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 | > | |
| 678 | 772 | {metric === "tokens" ? ( |
| 679 | 773 | <UsageShareBar |
| 680 | 774 | label="Tokens by type" |
| ... | ... | @@ -716,7 +810,7 @@ export function UsagePage() { |
| 716 | 810 | {( |
| 717 | 811 | [ |
| 718 | 812 | { value: "model", label: "Model" }, |
| 719 | { value: "time", label: isPast24Hours ? "Hour" : "Day" }, | |
| 813 | { value: "time", label: shownHourly ? "Hour" : "Day" }, | |
| 720 | 814 | ] as const |
| 721 | 815 | ).map((option) => ( |
| 722 | 816 | <Toggle key={option.value} value={option.value}> |
| ... | ... | @@ -752,6 +846,7 @@ export function UsagePage() { |
| 752 | 846 | model, |
| 753 | 847 | metric === "tokens" ? "tokens" : "cost", |
| 754 | 848 | ); |
| 849 | const rowFigures = figureClass(isProviderLoading(model.provider)); | |
| 755 | 850 | return ( |
| 756 | 851 | <tr |
| 757 | 852 | key={key} |
| ... | ... | @@ -769,7 +864,10 @@ export function UsagePage() { |
| 769 | 864 | <ProviderMark provider={model.provider} className="size-3.5" /> |
| 770 | 865 | {model.model} |
| 771 | 866 | </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 | > | |
| 773 | 871 | <div |
| 774 | 872 | className="h-full rounded-full" |
| 775 | 873 | style={{ |
| ... | ... | @@ -784,17 +882,19 @@ export function UsagePage() { |
| 784 | 882 | /> |
| 785 | 883 | </div> |
| 786 | 884 | </td> |
| 787 | <td className="py-2.5 pl-6 text-foreground"> | |
| 885 | <td className={cn("py-2.5 pl-6 text-foreground", rowFigures)}> | |
| 788 | 886 | {isModelCostUnknown(model) ? ( |
| 789 | 887 | <span className="text-muted-foreground">Unpriced</span> |
| 790 | 888 | ) : ( |
| 791 | 889 | formatUsd(model.costUsd) |
| 792 | 890 | )} |
| 793 | 891 | </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)}> | |
| 795 | 893 | {share === null ? "" : formatPercent(share)} |
| 796 | 894 | </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> | |
| 798 | 898 | </tr> |
| 799 | 899 | ); |
| 800 | 900 | }) |
| ... | ... | @@ -813,7 +913,7 @@ export function UsagePage() { |
| 813 | 913 | </colgroup> |
| 814 | 914 | <thead> |
| 815 | 915 | <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> | |
| 817 | 917 | {activeProviders.map((provider) => ( |
| 818 | 918 | <th key={provider} className="py-2 text-right font-normal"> |
| 819 | 919 | {PROVIDER_PRESENTATION[provider].label} |
| ... | ... | @@ -841,21 +941,34 @@ export function UsagePage() { |
| 841 | 941 | > |
| 842 | 942 | <td className="py-2 text-foreground"> |
| 843 | 943 | {"hourStart" in period |
| 844 | ? formatHourShort(period.hourStart, window.timeZone) | |
| 944 | ? formatHourShort(period.hourStart, shownWindow.timeZone) | |
| 845 | 945 | : formatDayShort(period.day)} |
| 846 | 946 | </td> |
| 847 | 947 | {activeProviders.map((provider) => ( |
| 848 | 948 | <td |
| 849 | 949 | 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 | )} | |
| 851 | 954 | > |
| 852 | 955 | {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)} |
| 853 | 956 | </td> |
| 854 | 957 | ))} |
| 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 | > | |
| 856 | 964 | {formatUsd(period.costUsd)} |
| 857 | 965 | </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 | > | |
| 859 | 972 | {formatTokens(period.totalTokens)} |
| 860 | 973 | </td> |
| 861 | 974 | </tr> |
| ... | ... | @@ -865,7 +978,7 @@ export function UsagePage() { |
| 865 | 978 | </table> |
| 866 | 979 | )} |
| 867 | 980 | </section> |
| 868 | </> | |
| 981 | </div> | |
| 869 | 982 | )} |
| 870 | 983 | </WorkspacePageContainer> |
| 871 | 984 | </ScrollArea> |
| ... | ... | @@ -878,9 +991,9 @@ export function UsagePage() { |
| 878 | 991 | chartWindow={{ |
| 879 | 992 | days, |
| 880 | 993 | 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, | |
| 884 | 997 | }} |
| 885 | 998 | onSetPrice={() => { |
| 886 | 999 | setSelectedModelKey(null); |
| ... | ... | @@ -1052,11 +1165,28 @@ function ProviderMark({ |
| 1052 | 1165 | ); |
| 1053 | 1166 | } |
| 1054 | 1167 | |
| 1055 | function 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. */ | |
| 1169 | function figureClass(loading: boolean) { | |
| 1170 | return cn("transition-opacity", loading && "opacity-40 delay-150"); | |
| 1171 | } | |
| 1172 | ||
| 1173 | function Metric({ | |
| 1174 | label, | |
| 1175 | value, | |
| 1176 | loading, | |
| 1177 | }: { | |
| 1178 | readonly label: string; | |
| 1179 | readonly value: string; | |
| 1180 | readonly loading: boolean; | |
| 1181 | }) { | |
| 1056 | 1182 | return ( |
| 1057 | 1183 | <div className="flex min-w-0 flex-col gap-0.5"> |
| 1058 | 1184 | <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> | |
| 1060 | 1190 | </div> |
| 1061 | 1191 | ); |
| 1062 | 1192 | } |
| ... | ... | @@ -1108,13 +1238,14 @@ function UsageCoverageNotice({ |
| 1108 | 1238 | ); |
| 1109 | 1239 | } |
| 1110 | 1240 | |
| 1111 | /** Environment selection and scan progress share a permanent header control. */ | |
| 1241 | /** Environment selection, with each environment's scan status in the menu. */ | |
| 1112 | 1242 | function UsageEnvironmentFilter({ |
| 1113 | 1243 | environments, |
| 1114 | 1244 | selectedEnvironments, |
| 1115 | 1245 | selectedEnvironmentIds, |
| 1116 | 1246 | onSelectionChange, |
| 1117 | 1247 | showUsageStatus, |
| 1248 | refreshing, | |
| 1118 | 1249 | isPartial, |
| 1119 | 1250 | duplicateSources, |
| 1120 | 1251 | contractMismatches, |
| ... | ... | @@ -1125,6 +1256,7 @@ function UsageEnvironmentFilter({ |
| 1125 | 1256 | readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null; |
| 1126 | 1257 | readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId> | null) => void; |
| 1127 | 1258 | readonly showUsageStatus: boolean; |
| 1259 | readonly refreshing: boolean; | |
| 1128 | 1260 | readonly isPartial: boolean; |
| 1129 | 1261 | readonly duplicateSources: readonly string[]; |
| 1130 | 1262 | readonly contractMismatches: MergedUsage["contractMismatches"]; |
| ... | ... | @@ -1136,10 +1268,6 @@ function UsageEnvironmentFilter({ |
| 1136 | 1268 | : selectedEnvironments.length === 1 |
| 1137 | 1269 | ? selectedEnvironments[0]!.label |
| 1138 | 1270 | : `${selectedEnvironments.length} environments`; |
| 1139 | const pendingCount = selectedEnvironments.filter( | |
| 1140 | (environment) => | |
| 1141 | environment.error === null && (environment.isPending || environment.summary === null), | |
| 1142 | ).length; | |
| 1143 | 1271 | const hasIssue = |
| 1144 | 1272 | selectedEnvironments.some((environment) => environment.error !== null) || |
| 1145 | 1273 | contractMismatches.length > 0; |
| ... | ... | @@ -1149,15 +1277,7 @@ function UsageEnvironmentFilter({ |
| 1149 | 1277 | <MenuTrigger render={<InlineButton />} className="group/usage-environment min-w-0 max-w-full"> |
| 1150 | 1278 | <span className="min-w-0 truncate">{label}</span> |
| 1151 | 1279 | <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 ? ( | |
| 1161 | 1281 | <CircleAlertIcon |
| 1162 | 1282 | className="size-3.5 text-warning-foreground" |
| 1163 | 1283 | aria-label="Some environments could not report usage" |
| ... | ... | @@ -1183,6 +1303,7 @@ function UsageEnvironmentFilter({ |
| 1183 | 1303 | const checked = |
| 1184 | 1304 | selectedEnvironmentIds === null || |
| 1185 | 1305 | selectedEnvironmentIds.has(environment.environmentId); |
| 1306 | const progress = usageEnvironmentProgress(environment, refreshing); | |
| 1186 | 1307 | const status = |
| 1187 | 1308 | environment.error !== null |
| 1188 | 1309 | ? "Unavailable" |
| ... | ... | @@ -1192,11 +1313,15 @@ function UsageEnvironmentFilter({ |
| 1192 | 1313 | USAGE_CONTRACT_VERSION, |
| 1193 | 1314 | ) |
| 1194 | 1315 | ? "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"; | |
| 1200 | 1325 | return ( |
| 1201 | 1326 | <MenuCheckboxItem |
| 1202 | 1327 | key={environment.environmentId} |
apps/web/src/components/usage/UsageProviderChart.test.ts+22-1| ... | ... | @@ -1,8 +1,29 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { buildPeriodColumns, niceScale } from "./UsageProviderChart"; | |
| 3 | import { buildPeriodColumns, chartScale, niceScale } from "./UsageProviderChart"; | |
| 4 | 4 | import { providersWithUsage } from "./usageProviders"; |
| 5 | 5 | |
| 6 | describe("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 | ||
| 6 | 27 | describe("niceScale", () => { |
| 7 | 28 | it("never puts the peak above the top of the scale", () => { |
| 8 | 29 | // 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 { |
| 10 | 10 | formatTokens, |
| 11 | 11 | formatUsd, |
| 12 | 12 | } from "@t3tools/shared/usageFormat"; |
| 13 | import { cn } from "~/lib/utils"; | |
| 13 | 14 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; |
| 14 | 15 | |
| 15 | 16 | const VIEW_WIDTH = 960; |
| 16 | 17 | const VIEW_HEIGHT = 260; |
| 17 | 18 | const TICK_COUNT = 4; |
| 18 | 19 | const PLOT_TOP = 8; |
| 20 | const NONE_LOADING: ReadonlySet<UsageProviderKind> = new Set(); | |
| 19 | 21 | |
| 20 | 22 | export type UsageChartMetric = "tokens" | "cost"; |
| 21 | 23 | |
| 22 | 24 | interface UsageProviderChartProps { |
| 23 | 25 | readonly providers: readonly UsageProviderKind[]; |
| 26 | /** Providers whose figures are still coming in: their lines are muted. */ | |
| 27 | readonly loadingProviders?: ReadonlySet<UsageProviderKind>; | |
| 24 | 28 | readonly days: readonly string[]; |
| 25 | 29 | readonly daily: readonly DailyTotals[]; |
| 26 | 30 | readonly hours: readonly string[]; |
| ... | ... | @@ -148,6 +152,10 @@ function curvePath(segments: readonly CurveSegment[]): string { |
| 148 | 152 | return path; |
| 149 | 153 | } |
| 150 | 154 | |
| 155 | function areaPath(line: string) { | |
| 156 | return line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`; | |
| 157 | } | |
| 158 | ||
| 151 | 159 | /** |
| 152 | 160 | * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the |
| 153 | 161 | * peak. |
| ... | ... | @@ -170,8 +178,71 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re |
| 170 | 178 | return { max, ticks }; |
| 171 | 179 | } |
| 172 | 180 | |
| 181 | const 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 | */ | |
| 188 | export 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. | |
| 205 | function 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. */ | |
| 210 | function 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 | ||
| 173 | 243 | export function UsageProviderChart({ |
| 174 | 244 | providers, |
| 245 | loadingProviders = NONE_LOADING, | |
| 175 | 246 | days, |
| 176 | 247 | daily, |
| 177 | 248 | hours, |
| ... | ... | @@ -194,59 +265,14 @@ export function UsageProviderChart({ |
| 194 | 265 | const tooltipRef = useRef<HTMLDivElement | null>(null); |
| 195 | 266 | const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); |
| 196 | 267 | |
| 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"); | |
| 250 | 276 | |
| 251 | 277 | const format = metric === "tokens" ? formatTokens : formatUsd; |
| 252 | 278 | |
| ... | ... | @@ -307,7 +333,8 @@ export function UsageProviderChart({ |
| 307 | 333 | ); |
| 308 | 334 | |
| 309 | 335 | 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)); | |
| 311 | 338 | const formatPeriod = (period: string) => |
| 312 | 339 | resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); |
| 313 | 340 | const formatTooltipPeriod = (period: string) => |
| ... | ... | @@ -320,15 +347,17 @@ export function UsageProviderChart({ |
| 320 | 347 | <div className="flex gap-2"> |
| 321 | 348 | {/* Axis labels sit outside the plot so they stay aligned to gridlines. */} |
| 322 | 349 | <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} | |
| 332 | 361 | </div> |
| 333 | 362 | |
| 334 | 363 | <div |
| ... | ... | @@ -347,7 +376,7 @@ export function UsageProviderChart({ |
| 347 | 376 | role="img" |
| 348 | 377 | aria-label={`${resolution === "hour" ? "Hourly" : "Daily"} ${metric === "tokens" ? "processed tokens" : "cost"} by provider`} |
| 349 | 378 | > |
| 350 | {ticks.map((tick) => { | |
| 379 | {scale.ticks.map((tick) => { | |
| 351 | 380 | const y = toY(tick); |
| 352 | 381 | return ( |
| 353 | 382 | <line |
| ... | ... | @@ -365,18 +394,20 @@ export function UsageProviderChart({ |
| 365 | 394 | })} |
| 366 | 395 | |
| 367 | 396 | {/* Fills first, then every stroke, so no series covers another's line. */} |
| 368 | {paths.map(({ provider, area }) => ( | |
| 397 | {paths.map(({ provider, loading, area }) => ( | |
| 369 | 398 | <path |
| 370 | 399 | key={provider} |
| 371 | 400 | d={area} |
| 401 | className={seriesClassName(loading)} | |
| 372 | 402 | fill={PROVIDER_PRESENTATION[provider].color} |
| 373 | 403 | fillOpacity={0.12} |
| 374 | 404 | /> |
| 375 | 405 | ))} |
| 376 | {paths.map(({ provider, line }) => ( | |
| 406 | {paths.map(({ provider, loading, line }) => ( | |
| 377 | 407 | <path |
| 378 | 408 | key={provider} |
| 379 | 409 | d={line} |
| 410 | className={seriesClassName(loading)} | |
| 380 | 411 | fill="none" |
| 381 | 412 | stroke={PROVIDER_PRESENTATION[provider].color} |
| 382 | 413 | strokeWidth={2} |
| ... | ... | @@ -420,7 +451,14 @@ export function UsageProviderChart({ |
| 420 | 451 | /> |
| 421 | 452 | {label} |
| 422 | 453 | </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 | > | |
| 424 | 462 | {format( |
| 425 | 463 | hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0, |
| 426 | 464 | )} |
| ... | ... | @@ -430,7 +468,12 @@ export function UsageProviderChart({ |
| 430 | 468 | })} |
| 431 | 469 | <div className="mt-1 flex items-center justify-between gap-3 border-t border-border pt-1"> |
| 432 | 470 | <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 | > | |
| 434 | 477 | {format(hoveredColumn?.total ?? 0)} |
| 435 | 478 | </span> |
| 436 | 479 | </div> |
apps/web/src/state/usage.test.tsx+43-1| ... | ... | @@ -33,6 +33,7 @@ function environment( |
| 33 | 33 | label: id, |
| 34 | 34 | isPending: cost === null, |
| 35 | 35 | canReadDiagnostics: true, |
| 36 | isConnected: true, | |
| 36 | 37 | error: null, |
| 37 | 38 | needsCursorKeychainAccess: false, |
| 38 | 39 | summary: |
| ... | ... | @@ -90,11 +91,13 @@ let latest: UsageView; |
| 90 | 91 | function Probe({ |
| 91 | 92 | selected, |
| 92 | 93 | hidden, |
| 94 | window = input, | |
| 93 | 95 | }: { |
| 94 | 96 | selected: ReadonlySet<EnvironmentId> | null; |
| 95 | 97 | hidden?: ReadonlySet<UsageProviderKind>; |
| 98 | window?: typeof input; | |
| 96 | 99 | }) { |
| 97 | const usage = useUsage(input, selected, hidden); | |
| 100 | const usage = useUsage(window, selected, hidden); | |
| 98 | 101 | useLayoutEffect(() => { |
| 99 | 102 | latest = usage; |
| 100 | 103 | }, [usage]); |
| ... | ... | @@ -181,6 +184,45 @@ describe("usage environment selection", () => { |
| 181 | 184 | expect(latest.isPending).toBe(false); |
| 182 | 185 | expect(latest.isPartial).toBe(false); |
| 183 | 186 | }); |
| 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 | }); | |
| 184 | 226 | }); |
| 185 | 227 | |
| 186 | 228 | describe("usage provider filter", () => { |
apps/web/src/state/usage.ts+48-2| ... | ... | @@ -20,7 +20,7 @@ import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime |
| 20 | 20 | import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; |
| 21 | 21 | import * as Option from "effect/Option"; |
| 22 | 22 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 23 | import { useCallback, useMemo } from "react"; | |
| 23 | import { useCallback, useMemo, useState } from "react"; | |
| 24 | 24 | |
| 25 | 25 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; |
| 26 | 26 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| ... | ... | @@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus { |
| 33 | 33 | readonly label: string; |
| 34 | 34 | readonly isPending: boolean; |
| 35 | 35 | readonly canReadDiagnostics: boolean; |
| 36 | readonly isConnected: boolean; | |
| 36 | 37 | readonly error: string | null; |
| 37 | 38 | readonly summary: UsageSummary | null; |
| 38 | 39 | readonly needsCursorKeychainAccess: boolean; |
| ... | ... | @@ -52,6 +53,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => |
| 52 | 53 | |
| 53 | 54 | const statuses: EnvironmentUsageStatus[] = []; |
| 54 | 55 | for (const [environmentId, presentation] of presentations) { |
| 56 | const isConnected = presentation.connection.phase === "connected"; | |
| 55 | 57 | const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); |
| 56 | 58 | const access = resolveUsageAccess({ |
| 57 | 59 | connectionPhase: presentation.connection.phase, |
| ... | ... | @@ -62,6 +64,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => |
| 62 | 64 | statuses.push({ |
| 63 | 65 | environmentId, |
| 64 | 66 | label: presentation.entry.target.label, |
| 67 | isConnected, | |
| 65 | 68 | ...access, |
| 66 | 69 | summary: null, |
| 67 | 70 | needsCursorKeychainAccess: false, |
| ... | ... | @@ -75,6 +78,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => |
| 75 | 78 | label: presentation.entry.target.label, |
| 76 | 79 | isPending: result.waiting, |
| 77 | 80 | canReadDiagnostics: true, |
| 81 | isConnected, | |
| 78 | 82 | error: result._tag === "Failure" ? "This environment could not report usage." : null, |
| 79 | 83 | summary, |
| 80 | 84 | needsCursorKeychainAccess: needsCursorKeychainAccess( |
| ... | ... | @@ -93,6 +97,12 @@ export interface UsageView { |
| 93 | 97 | readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; |
| 94 | 98 | /** True until at least one selected environment has answered. */ |
| 95 | 99 | 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; | |
| 96 | 106 | /** |
| 97 | 107 | * True while environments that have not failed are still answering. Failed |
| 98 | 108 | * environments are reported through their own error rows: totals will not |
| ... | ... | @@ -225,12 +235,48 @@ export function useUsage( |
| 225 | 235 | const stillReporting = selectedEnvironments.filter( |
| 226 | 236 | (environment) => environment.summary === null && environment.error === null, |
| 227 | 237 | ).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 })); | |
| 228 | 273 | |
| 229 | 274 | return { |
| 230 | 275 | merged, |
| 231 | 276 | environments, |
| 232 | 277 | selectedEnvironments, |
| 233 | isPending: answeredCount === 0 && stillReporting > 0, | |
| 278 | isPending, | |
| 279 | shown, | |
| 234 | 280 | isPartial: answeredCount > 0 && stillReporting > 0, |
| 235 | 281 | refresh, |
| 236 | 282 | }; |
docs/user/usage.md+2-1| ... | ... | @@ -39,7 +39,8 @@ your app understands. Update the app to include newly supported providers. |
| 39 | 39 | |
| 40 | 40 | On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All |
| 41 | 41 | environments are selected by default. The dropdown shows which environments are still scanning; |
| 42 | results appear as each one responds. | |
| 42 | results appear as each one responds, and figures still updating are dimmed. Cursor shows its last | |
| 43 | saved totals first, then updates them when Cursor's API responds. | |
| 43 | 44 | |
| 44 | 45 | If recent work is missing or a new model shows no cost, refresh to rescan session history and |
| 45 | 46 | update model pricing. |
packages/client-runtime/package.json+4| ... | ... | @@ -231,6 +231,10 @@ |
| 231 | 231 | "types": "./src/state/usage.ts", |
| 232 | 232 | "default": "./src/state/usage.ts" |
| 233 | 233 | }, |
| 234 | "./state/usage-progress": { | |
| 235 | "types": "./src/state/usageProgress.ts", | |
| 236 | "default": "./src/state/usageProgress.ts" | |
| 237 | }, | |
| 234 | 238 | "./state/server": { |
| 235 | 239 | "types": "./src/state/server.ts", |
| 236 | 240 | "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< |
| 53 | 53 | ) => Effect.Effect<A, E, R>; |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | interface EnvironmentQueryAtomOptions<Input, A, E, R> extends EnvironmentAtomOptions< | |
| 57 | Input, | |
| 58 | A, | |
| 59 | E, | |
| 60 | R | |
| 56 | interface EnvironmentQueryAtomOptions<Input, A, E, R> extends Omit< | |
| 57 | EnvironmentAtomOptions<Input, A, E, R>, | |
| 58 | "execute" | |
| 61 | 59 | > { |
| 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>; | |
| 62 | 68 | readonly staleTimeMs?: number; |
| 63 | 69 | readonly idleTtlMs?: number; |
| 64 | 70 | readonly refreshIntervalMs?: number; |
| ... | ... | @@ -547,10 +553,18 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>( |
| 547 | 553 | return Effect.never; |
| 548 | 554 | } |
| 549 | 555 | 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 | ); | |
| 550 | 564 | switch (connectionState.phase) { |
| 551 | 565 | case "connected": |
| 552 | 566 | return Option.isSome(session) |
| 553 | ? runInEnvironment(target.environmentId, options.execute(target.input)) | |
| 567 | ? runInEnvironment(target.environmentId, options.execute(target.input, emit)) | |
| 554 | 568 | : Effect.never; |
| 555 | 569 | case "connecting": |
| 556 | 570 | case "backoff": |
| ... | ... | @@ -650,6 +664,7 @@ export function createEnvironmentRpcQueryAtomFamily< |
| 650 | 664 | readonly tag: TTag; |
| 651 | 665 | readonly execute?: ( |
| 652 | 666 | input: EnvironmentRpcInput<TTag>, |
| 667 | emit: (value: EnvironmentRpcSuccess<TTag>) => Effect.Effect<void>, | |
| 653 | 668 | ) => Effect.Effect< |
| 654 | 669 | EnvironmentRpcSuccess<TTag>, |
| 655 | 670 | EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError, |
| ... | ... | @@ -672,8 +687,10 @@ export function createEnvironmentRpcQueryAtomFamily< |
| 672 | 687 | ? {} |
| 673 | 688 | : { refreshIntervalMs: options.refreshIntervalMs }), |
| 674 | 689 | ...(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), | |
| 677 | 694 | }); |
| 678 | 695 | } |
| 679 | 696 |
packages/client-runtime/src/state/server.ts+22| ... | ... | @@ -1091,11 +1091,33 @@ export function createServerEnvironmentAtoms<R, E>( |
| 1091 | 1091 | }), |
| 1092 | 1092 | // A cold transcript scan is measured in seconds, so keep the result around |
| 1093 | 1093 | // 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. | |
| 1094 | 1096 | usageSummary: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 1095 | 1097 | label: "environment-data:server:usage-summary", |
| 1096 | 1098 | tag: WS_METHODS.serverGetUsageSummary, |
| 1097 | 1099 | staleTimeMs: 60_000, |
| 1098 | 1100 | 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 | ), | |
| 1099 | 1121 | }), |
| 1100 | 1122 | resourceTelemetry: createEnvironmentRpcSubscriptionAtomFamily(runtime, { |
| 1101 | 1123 | label: "environment-data:server:resource-telemetry", |
packages/client-runtime/src/state/serverUsage.test.ts+150-4| ... | ... | @@ -2,11 +2,13 @@ import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | 3 | EnvironmentId, |
| 4 | 4 | UsageDay, |
| 5 | UsageReadError, | |
| 5 | 6 | USAGE_CONTRACT_VERSION, |
| 6 | 7 | WS_METHODS, |
| 7 | 8 | type ServerConfig, |
| 8 | 9 | type ServerConfigStreamEvent, |
| 9 | 10 | type ServerSettings, |
| 11 | type UsageSource, | |
| 10 | 12 | type UsageSummary, |
| 11 | 13 | type UsageSummaryInput, |
| 12 | 14 | } from "@t3tools/contracts"; |
| ... | ... | @@ -16,6 +18,7 @@ import * as Effect from "effect/Effect"; |
| 16 | 18 | import * as Layer from "effect/Layer"; |
| 17 | 19 | import * as Option from "effect/Option"; |
| 18 | 20 | import * as Queue from "effect/Queue"; |
| 21 | import * as Result from "effect/Result"; | |
| 19 | 22 | import * as Stream from "effect/Stream"; |
| 20 | 23 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 21 | 24 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| ... | ... | @@ -26,12 +29,14 @@ import { |
| 26 | 29 | type PreparedConnection, |
| 27 | 30 | type SupervisorConnectionState, |
| 28 | 31 | } from "../connection/model.ts"; |
| 32 | import type { EnvironmentPresentation } from "../connection/presentation.ts"; | |
| 29 | 33 | import * as EnvironmentRegistry from "../connection/registry.ts"; |
| 30 | 34 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 31 | 35 | import * as Persistence from "../platform/persistence.ts"; |
| 32 | 36 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 33 | 37 | import type { RpcSession } from "../rpc/session.ts"; |
| 34 | 38 | import { createServerEnvironmentAtoms } from "./server.ts"; |
| 39 | import { refreshUsage } from "./usage.ts"; | |
| 35 | 40 | |
| 36 | 41 | const TARGET = new PrimaryConnectionTarget({ |
| 37 | 42 | environmentId: EnvironmentId.make("usage-environment"), |
| ... | ... | @@ -51,13 +56,37 @@ const CONFIG = { |
| 51 | 56 | environment: { serverVersion: "0.0.1", capabilities: {} }, |
| 52 | 57 | } as ServerConfig; |
| 53 | 58 | |
| 59 | const PRICING = { status: "unavailable" as const, source: "test", fetchedAt: null, knownModels: 0 }; | |
| 60 | ||
| 61 | const 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 | ||
| 54 | 77 | const 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[] = () => [], | |
| 56 | 83 | ) { |
| 57 | 84 | const events = yield* Queue.unbounded<ServerConfigStreamEvent>(); |
| 58 | 85 | let settings = DEFAULT_SERVER_SETTINGS; |
| 59 | 86 | let requests = 0; |
| 87 | const inputs: UsageSummaryInput[] = []; | |
| 60 | 88 | const client = { |
| 89 | [WS_METHODS.serverRefreshUsageRates]: () => Effect.succeed(PRICING), | |
| 61 | 90 | [WS_METHODS.subscribeServerConfig]: () => |
| 62 | 91 | Stream.concat( |
| 63 | 92 | Stream.make({ version: 1 as const, type: "snapshot" as const, config: CONFIG }), |
| ... | ... | @@ -66,8 +95,9 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( |
| 66 | 95 | [WS_METHODS.serverGetUsageSummary]: (input: UsageSummaryInput) => |
| 67 | 96 | Effect.gen(function* () { |
| 68 | 97 | requests += 1; |
| 98 | inputs.push(input); | |
| 69 | 99 | const price = settings.usagePriceOverrides["custom-model"]?.inputCostPerMillionTokens ?? 0; |
| 70 | yield* beforeRead(requests); | |
| 100 | yield* beforeRead(requests, input); | |
| 71 | 101 | return { |
| 72 | 102 | contractVersion: USAGE_CONTRACT_VERSION, |
| 73 | 103 | readAt: "2026-09-04T12:00:00Z", |
| ... | ... | @@ -92,8 +122,8 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( |
| 92 | 122 | sessions: 1, |
| 93 | 123 | }, |
| 94 | 124 | ], |
| 95 | sources: [], | |
| 96 | pricing: { status: "unavailable", source: "test", fetchedAt: null, knownModels: 0 }, | |
| 125 | sources: sources(input), | |
| 126 | pricing: PRICING, | |
| 97 | 127 | scanDurationMs: 0, |
| 98 | 128 | } satisfies UsageSummary; |
| 99 | 129 | }), |
| ... | ... | @@ -167,12 +197,44 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( |
| 167 | 197 | }); |
| 168 | 198 | return { |
| 169 | 199 | registry, |
| 200 | atoms, | |
| 170 | 201 | requests: () => requests, |
| 202 | inputs: () => inputs, | |
| 171 | 203 | updateSettings, |
| 172 | 204 | summary: (input = INPUT) => atoms.usageSummary({ environmentId: TARGET.environmentId, input }), |
| 173 | 205 | }; |
| 174 | 206 | }); |
| 175 | 207 | |
| 208 | function 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. */ | |
| 224 | const 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 | ||
| 176 | 238 | function waitForCost<E>( |
| 177 | 239 | registry: AtomRegistry.AtomRegistry, |
| 178 | 240 | atom: Atom.Atom<AsyncResult.AsyncResult<UsageSummary, E>>, |
| ... | ... | @@ -261,3 +323,87 @@ it.effect("restarts a pending usage read after a price change", () => |
| 261 | 323 | }), |
| 262 | 324 | ), |
| 263 | 325 | ); |
| 326 | ||
| 327 | it.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 | ||
| 340 | it.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 | ||
| 366 | it.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 | ||
| 384 | it.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 @@ |
| 1 | import { | |
| 2 | USAGE_CONTRACT_VERSION, | |
| 3 | UsageDay, | |
| 4 | type UsageProviderKind, | |
| 5 | type UsageSummary, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { describe, expect, it } from "vite-plus/test"; | |
| 8 | ||
| 9 | import { usageEnvironmentProgress, usageLoadingState, usageProgress } from "./usageProgress.ts"; | |
| 10 | ||
| 11 | function 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 | ||
| 34 | function 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 | ||
| 53 | const options = { providerLabel: (provider: UsageProviderKind) => `${provider} label` }; | |
| 54 | ||
| 55 | describe("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 | ||
| 127 | describe("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 @@ |
| 1 | import type { UsageProviderKind, UsageSummary } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | interface 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 | */ | |
| 22 | export 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 | ||
| 42 | export 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 | */ | |
| 58 | export 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 | */ | |
| 101 | export 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({ |
| 183 | 183 | message: Schema.NullOr(TrimmedNonEmptyString), |
| 184 | 184 | /** An action the client can offer to make this source available. */ |
| 185 | 185 | 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)), | |
| 186 | 192 | }); |
| 187 | 193 | export type UsageSource = typeof UsageSource.Type; |
| 188 | 194 | |
| ... | ... | @@ -217,6 +223,12 @@ export const UsageSummaryInput = Schema.Struct({ |
| 217 | 223 | sinceTime: Schema.optional(TrimmedNonEmptyString), |
| 218 | 224 | /** Exclusive UTC instant for an hourly rolling window. */ |
| 219 | 225 | 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), | |
| 220 | 232 | }); |
| 221 | 233 | export type UsageSummaryInput = typeof UsageSummaryInput.Type; |
| 222 | 234 |