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