diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index b3bec8830ec6..c2012299802d 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -118,6 +118,9 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", + "OTEL_TRACES_EXPORTER", + "OTEL_METRICS_EXPORTER", + "OTEL_LOGS_EXPORTER", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; diff --git a/apps/mobile/src/features/connection/ConnectionFormField.test.tsx b/apps/mobile/src/features/connection/ConnectionFormField.test.tsx new file mode 100644 index 000000000000..343d44cf376b --- /dev/null +++ b/apps/mobile/src/features/connection/ConnectionFormField.test.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("react-native", () => ({ + View: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../../components/AppText", () => ({ + AppText: ({ + accessibilityElementsHidden, + importantForAccessibility, + children, + }: { + accessibilityElementsHidden?: boolean; + importantForAccessibility?: string; + children: ReactNode; + }) => ( + + {children} + + ), + AppTextInput: ({ accessibilityLabel }: { accessibilityLabel?: string }) => ( + + ), +})); + +import { ConnectionFormField } from "./ConnectionFormField"; + +describe("ConnectionFormField accessibility", () => { + it.each(["Host", "Pairing code"])("exposes %s once as the input name", (label) => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain(``); + expect(markup).toContain(``); + }); +}); diff --git a/apps/mobile/src/features/connection/ConnectionFormField.tsx b/apps/mobile/src/features/connection/ConnectionFormField.tsx index 0d5e21148e54..b492dd9ce43c 100644 --- a/apps/mobile/src/features/connection/ConnectionFormField.tsx +++ b/apps/mobile/src/features/connection/ConnectionFormField.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { AppText, AppTextInput, type AppTextInputProps } from "../../components/AppText"; import { cn } from "../../lib/cn"; -type ConnectionFormFieldProps = Omit & { +type ConnectionFormFieldProps = Omit & { readonly label: string; readonly className?: string; }; @@ -12,12 +12,16 @@ type ConnectionFormFieldProps = Omit & { export function ConnectionFormField({ label, className, ...inputProps }: ConnectionFormFieldProps) { return ( - + {label} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 10878179d2ac..4582c3135690 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,6 +1,7 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; +import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; import { isCompatibleUsageContractVersion, isModelCostUnknown, @@ -15,6 +16,7 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageContractMismatch, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -101,9 +103,7 @@ export function UsageRouteScreen() { ); const isFocused = useIsFocused(); const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); - const cursorAccessEnvironments = selectedEnvironments.filter( - (environment) => environment.needsCursorKeychainAccess, - ); + const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); const refreshAfterCursorEnable = () => { void refresh(); void limits.refreshAfterEnable(); @@ -753,7 +753,12 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { environment.summary && !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION) ) { - return "Older server · excluded from usage totals"; + return formatUsageContractMismatch(environment.label, { + direction: + environment.summary.contractVersion < USAGE_CONTRACT_VERSION + ? "serverBehind" + : "clientBehind", + }); } if (!environment.isConnected) return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; diff --git a/apps/server/src/provider/cursorCredentialStore.test.ts b/apps/server/src/provider/cursorCredentialStore.test.ts index 051cb5425c69..f212ce110ea6 100644 --- a/apps/server/src/provider/cursorCredentialStore.test.ts +++ b/apps/server/src/provider/cursorCredentialStore.test.ts @@ -1,6 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; -import { makeCachedCursorAccessTokenReader } from "./cursorCredentialStore.ts"; +import { + CursorKeychainTimeoutError, + makeCachedCursorAccessTokenReader, +} from "./cursorCredentialStore.ts"; describe("Cursor Keychain reader", () => { it("shares concurrent reads and rechecks after the cache expires", async () => { @@ -19,4 +22,25 @@ describe("Cursor Keychain reader", () => { time = 5 * 60_000; assert.strictEqual(await read(), "token-2"); }); + + it("gives up on an unanswered prompt and reuses it on the next read", async () => { + let reads = 0; + let allow: (token: string) => void = () => {}; + const read = makeCachedCursorAccessTokenReader( + () => { + reads++; + return new Promise((resolve) => { + allow = resolve; + }); + }, + () => 0, + 1, + ); + const error = await read().catch((cause: unknown) => cause); + assert.instanceOf(error, CursorKeychainTimeoutError); + const retry = read(); + allow("token"); + assert.strictEqual(await retry, "token"); + assert.strictEqual(reads, 1); + }); }); diff --git a/apps/server/src/provider/cursorCredentialStore.ts b/apps/server/src/provider/cursorCredentialStore.ts index 9d7d1c3bccff..2857683e4821 100644 --- a/apps/server/src/provider/cursorCredentialStore.ts +++ b/apps/server/src/provider/cursorCredentialStore.ts @@ -4,17 +4,31 @@ const CACHE_MS = 5 * 60_000; const requireForKeyring = NodeModule.createRequire(import.meta.url); -/** Share one Keychain request across usage history and limits in this server process. */ +/** Rejected when nobody answers the macOS Keychain prompt in time. */ +export class CursorKeychainTimeoutError extends Error { + constructor() { + super("Timed out waiting for Keychain access."); + } +} + +/** + * Share one Keychain request across usage history and limits in this server process. + * + * macOS shows the access prompt on the server's own screen, which a remote + * client cannot answer, so callers give up after `timeoutMs`. The read stays in + * flight: the next call reuses it instead of stacking a second prompt, and picks + * up the token once someone allows access. + */ export function makeCachedCursorAccessTokenReader( read: () => Promise, now: () => number = Date.now, + timeoutMs = 30_000, ): () => Promise { let cached: { token: string; until: number } | null = null; let pending: Promise | null = null; return () => { if (cached && cached.until > now()) return Promise.resolve(cached.token); - if (pending) return pending; - pending = read() + pending ??= read() .then((token) => { cached = token ? { token, until: now() + CACHE_MS } : null; return token; @@ -22,7 +36,15 @@ export function makeCachedCursorAccessTokenReader( .finally(() => { pending = null; }); - return pending; + const deadline = AbortSignal.timeout(timeoutMs); + return Promise.race([ + pending, + new Promise((_, reject) => { + deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), { + once: true, + }); + }), + ]); }; } diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index a80a31f48ae6..15ea4b673f22 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -121,6 +121,46 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("omits Cursor account usage when no file login is saved", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + for (const platform of ["linux", "win32", "darwin"] as const) { + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: `usage-service-cursor-no-login-${platform}`, + home, + settings, + platform, + environment: { AGENT_CLI_CREDENTIAL_STORE: "file" }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor")); + } + }).pipe(Effect.scoped), + ); + + it.live("keeps Cursor credential errors visible when a saved login cannot be read", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const authPath = NodePath.join(home, "config", "cursor", "auth.json"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true }); + await NodeFSP.writeFile(authPath, "invalid json"); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-cursor-invalid-login", home, settings }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.message, "Cursor credentials could not be read."); + }).pipe(Effect.scoped), + ); + it.live("does not read the macOS Cursor Keychain before account usage is enabled", () => Effect.gen(function* () { const { settings, home } = yield* setup; @@ -233,10 +273,7 @@ describe("UsageService", () => { const summary = yield* service.readSummary(WINDOW); assert.strictEqual(summary.buckets[0]?.provider, "opencode"); assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor")); - assert.strictEqual( - summary.sources.find((source) => source.fingerprint.provider === "cursor")?.status, - "missing", - ); + assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor")); assert.strictEqual( summary.buckets[0]?.sourcePath, yield* Effect.promise(() => NodeFSP.realpath(root)), @@ -247,10 +284,6 @@ describe("UsageService", () => { ?.distinctSessions, 1, ); - assert.include( - summary.sources.find((source) => source.fingerprint.provider === "cursor")?.message ?? "", - "Cursor account history needs a Cursor CLI login", - ); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 6930c7307d8c..1e5cb6db20fe 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -635,6 +635,8 @@ export const make = Effect.gen(function* () { cursorUntilMs, ), ); + // No saved login means there is no account source to report, not a setup error. + if (account.missing && account.error === null) return scanned; if (account.accountKey !== null && account.error === null && !account.missing) { // The same account includes CLI and desktop history from every machine. // A stable remote fingerprint prevents connected environments counting it twice. diff --git a/apps/server/src/usage/cursorUsageReader.test.ts b/apps/server/src/usage/cursorUsageReader.test.ts new file mode 100644 index 000000000000..4bfa647c0aff --- /dev/null +++ b/apps/server/src/usage/cursorUsageReader.test.ts @@ -0,0 +1,22 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { CursorKeychainTimeoutError } from "../provider/cursorCredentialStore.ts"; +import { readCursorAccountUsage } from "./cursorUsageReader.ts"; + +describe("readCursorAccountUsage", () => { + it("asks for Keychain approval when the prompt goes unanswered", async () => { + const result = await readCursorAccountUsage( + { kind: "keychain" }, + 0, + 1, + () => Promise.reject(new Error("no network expected")), + () => Promise.reject(new CursorKeychainTimeoutError()), + ); + assert.deepStrictEqual(result, { + accountKey: null, + records: [], + missing: false, + error: "Allow Keychain access on the Mac running T3 Code, then refresh.", + }); + }); +}); diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 1e07fcc7a825..573506d818a1 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -5,7 +5,10 @@ import * as NodeCrypto from "node:crypto"; import * as NodeTimersPromises from "node:timers/promises"; import type { UsageRecord } from "./usageTranscripts.ts"; -import { readMacCursorAccessToken } from "../provider/cursorCredentialStore.ts"; +import { + CursorKeychainTimeoutError, + readMacCursorAccessToken, +} from "../provider/cursorCredentialStore.ts"; function object(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -86,7 +89,9 @@ export async function readCursorAccountUsage( ? null : typeof credentialSource === "string" ? "Cursor credentials could not be read." - : "Cursor Keychain credentials could not be read.", + : cause instanceof CursorKeychainTimeoutError + ? "Allow Keychain access on the Mac running T3 Code, then refresh." + : "Cursor Keychain credentials could not be read.", }; } if (typeof accessToken !== "string" || !accessToken) { diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 7e3eaad1125b..05d965d95d06 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -304,6 +304,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { side="left" collapsible="offcanvas" data-app-sidebar="" + role="navigation" + aria-label={isOnSettings ? "Settings" : "Threads"} resizable={{ maxWidth: sidebarMaximumWidth, minWidth: THREAD_SIDEBAR_MIN_WIDTH, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3262ff9cc4a3..61f1770c4bef 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2705,68 +2705,40 @@ export default function ChatView(props: ChatViewProps) { unavailableConnection !== null && (unavailableConnection.phase === "connecting" || unavailableConnection.phase === "reconnecting"); - // Reconnecting to a version-skewed server with no update in flight - // usually means the server is restarting mid-update and a refresh wiped - // the in-memory update state. Fold the reconnect and version banners - // into one calm line instead of stacking "Failed to connect" on - // "versions differ". A failed update never folds: its error and retry - // action must stay visible. - const reconnectingThroughVersionSkew = - serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null; // While an update runs, transient connect blips are expected (the server // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. const suppressUnavailableBanner = - environmentReconnecting && - (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed)); + environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { - if (reconnectingThroughVersionSkew) { - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: "default", - // Prioritize live connection progress among the notices. - priority: "urgent", - icon: ( - ) : null; + const accessibility = resolveSidebarRowAccessibility({ + title: thread.title, + statusLabel: topStatus?.label ?? null, + projectDisplayName: props.projectDisplayName, + isActive: props.isActive, + }); + const title = isRenaming ? ( ) : ( ); + const accessibleTitle = isRenaming ? null : {thread.title}; // Stacks show their layer count; multiple unrelated links show their total count. // Plain clicks open T3; individual PR links also support opening the host in a new tab. @@ -1593,6 +1614,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ref={rowRef} role="button" tabIndex={0} + aria-label={accessibility.label} + aria-current={accessibility.current} data-testid="sidebar-row-slim" aria-busy={isRegeneratingTitle || undefined} className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")} @@ -1603,6 +1626,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> } > + {accessibleTitle} {/* Settled history recedes: dimmed favicon at rest, restored on hover so the tail stays scannable when you're hunting. */} } > + {accessibleTitle}
{draftIndicator} @@ -2008,6 +2035,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; }) { const { thread } = props; + const accessibility = resolveSidebarRowAccessibility({ + title: thread.title, + statusLabel: null, + projectDisplayName: props.projectDisplayName, + isActive: props.isRouteActive, + }); const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -2084,12 +2117,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { // which owns all keyboard interaction for the listbox. tabIndex={-1} aria-selected={props.isHighlighted} - aria-current={props.isRouteActive ? "page" : undefined} - aria-label={ - props.projectDisplayName - ? `${thread.title}, ${props.projectDisplayName}` - : thread.title - } + aria-current={accessibility.current} + aria-label={accessibility.label} onMouseMove={props.onHighlight} onClick={props.onSelect} className={cn( @@ -4573,7 +4602,7 @@ export default function Sidebar() { } > - + {isSearchingThreads ? ( threadSearchResults.length > 0 ? (
    0 && "flex-1", diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a1d8176207ae..bf7faa735fed 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2783,7 +2783,7 @@ function ReasoningTraceBlock({ type="button" aria-expanded={expanded} onClick={() => ctx.onToggleReasoning(first.id, !expanded, anchorKey)} - className="flex min-h-6 cursor-pointer select-none items-center gap-1.5 rounded-md px-0.5 text-start text-sm leading-relaxed transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" + className="flex min-h-6 cursor-pointer select-none items-center gap-1.5 rounded-md ps-0.5 pe-2 text-start text-sm leading-relaxed transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" > diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 7792b9606e41..ce9d41edaa5d 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -30,6 +30,7 @@ function ScrollArea({ hideScrollbars = false, chainVerticalScroll = false, radius = "inherit", + viewportTabIndex, ...props }: ScrollAreaPrimitive.Root.Props & { scrollFade?: boolean; @@ -41,6 +42,8 @@ function ScrollArea({ chainVerticalScroll?: boolean; /** The viewport clips to the parent's radius; "none" for a region flush to an edge. */ radius?: "inherit" | "none"; + /** Override Base UI's focusable viewport when focusable descendants provide scroll access. */ + viewportTabIndex?: number; }) { return ( - + {children}
@@ -597,6 +598,10 @@ function SidebarContent({ hideScrollbars scrollFade scrollFadePadding={false} + // Thread rows provide keyboard access to this scroll region. Keeping + // Base UI's viewport out of the tab order lets its presentational role + // flatten in WebKit instead of becoming a VoiceOver interaction group. + viewportTabIndex={-1} className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]" >
[0]>) { - const html = renderToStaticMarkup(); - return html.match(/]*role="switch"[^>]*>/)?.[0] ?? ""; +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +function ControlledSwitch({ + initialChecked, + initialMixed = false, +}: { + initialChecked: boolean; + initialMixed?: boolean; +}) { + const [checked, setChecked] = useState(initialChecked); + const [mixed, setMixed] = useState(initialMixed); + + return ( + { + setMixed(false); + setChecked(nextChecked); + }} + /> + ); } -describe("Switch accessibility", () => { - it("exposes the checked state to assistive tech", () => { - expect(renderSwitchRoot({ checked: true })).toContain('aria-checked="true"'); - expect(renderSwitchRoot({ checked: false })).toContain('aria-checked="false"'); +function switchRoot() { + const element = container.querySelector('[role="switch"]'); + if (!element) throw new Error("Switch was not rendered"); + return element; +} + +async function clickSwitch() { + await act(async () => switchRoot().click()); +} + +async function renderSwitch(initialChecked: boolean, initialMixed = false) { + await act(async () => { + root.render(); }); +} + +describe("Switch accessibility", () => { + it.each([ + { initialChecked: false, initialMixed: false, before: false, after: true }, + { initialChecked: true, initialMixed: false, before: true, after: false }, + { initialChecked: false, initialMixed: true, before: "mixed", after: true }, + ])("exposes $before before activation and $after after activation", async (state) => { + await renderSwitch(state.initialChecked, state.initialMixed); + expect(switchRoot().getAttribute("aria-checked")).toBe(String(state.before)); + + await clickSwitch(); - it("exposes the mixed state", () => { - expect(renderSwitchRoot({ checked: false, mixed: true })).toContain('aria-checked="mixed"'); + expect(switchRoot().getAttribute("aria-checked")).toBe(String(state.after)); + expect(container.querySelector('input[type="checkbox"]')?.checked).toBe( + state.after, + ); }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7d306d1a5c07..3ced333c9633 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -13,13 +13,17 @@ import { SlidersHorizontalIcon, } from "lucide-react"; import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; +import { + cursorKeychainAccessEnvironments, + refreshUsageLimits, +} from "@t3tools/client-runtime/state/usage"; import { isCompatibleUsageContractVersion, isModelCostUnknown, type DailyTotals, type HourlyTotals, + type MergedUsage, } from "@t3tools/shared/usageMerge"; import { isElectron } from "../../env"; @@ -40,6 +44,7 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageContractMismatch, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -127,9 +132,7 @@ export function UsagePage() { selectedEnvironmentIds, ); const presentations = useAtomValue(environmentPresentations.presentationsAtom); - const cursorAccessEnvironments = selectedEnvironments.filter( - (environment) => environment.needsCursorKeychainAccess, - ); + const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); const sourceMessages = [ ...new Set( selectedEnvironments.flatMap( @@ -314,7 +317,7 @@ export function UsagePage() { showUsageStatus={!showingLimits} isPartial={isPartial} duplicateSources={merged.duplicateSources} - staleEnvironments={merged.staleEnvironments} + contractMismatches={merged.contractMismatches} /> @@ -899,17 +902,21 @@ function Metric({ label, value }: { readonly label: string; readonly value: stri function UsageCoverageNotice({ environments, duplicateSources, - staleEnvironments, + contractMismatches, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly duplicateSources: readonly string[]; - readonly staleEnvironments: readonly string[]; + readonly contractMismatches: MergedUsage["contractMismatches"]; }) { const failed = environments.filter((environment) => environment.error !== null); - const stale = environments.filter((environment) => - staleEnvironments.includes(environment.environmentId), + const mismatchByEnvironment = new Map( + contractMismatches.map((mismatch) => [mismatch.environmentId, mismatch]), ); - if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0) { + const incompatible = environments.flatMap((environment) => { + const mismatch = mismatchByEnvironment.get(environment.environmentId); + return mismatch === undefined ? [] : [{ environment, mismatch }]; + }); + if (failed.length === 0 && incompatible.length === 0 && duplicateSources.length === 0) { return null; } @@ -918,9 +925,9 @@ function UsageCoverageNotice({ {failed.map((environment) => ( {environment.label} could not report usage. ))} - {stale.map((environment) => ( - - {environment.label} runs an older server version and is excluded from totals. + {incompatible.map(({ environment, mismatch }) => ( + + {formatUsageContractMismatch(environment.label, mismatch)} ))} {duplicateSources.length > 0 ? ( @@ -942,7 +949,7 @@ function UsageEnvironmentFilter({ showUsageStatus, isPartial, duplicateSources, - staleEnvironments, + contractMismatches, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; @@ -951,7 +958,7 @@ function UsageEnvironmentFilter({ readonly showUsageStatus: boolean; readonly isPartial: boolean; readonly duplicateSources: readonly string[]; - readonly staleEnvironments: readonly string[]; + readonly contractMismatches: MergedUsage["contractMismatches"]; }) { const [modelPricesOpen, setModelPricesOpen] = useState(false); const allSelected = selectedEnvironmentIds === null; @@ -966,7 +973,7 @@ function UsageEnvironmentFilter({ ).length; const hasIssue = selectedEnvironments.some((environment) => environment.error !== null) || - staleEnvironments.length > 0; + contractMismatches.length > 0; return ( <> @@ -1066,7 +1073,7 @@ function UsageEnvironmentFilter({ ) : null} diff --git a/docs/operations/observability.md b/docs/operations/observability.md index c6537f6eac71..910bd018bcc7 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -631,6 +631,11 @@ machine that sets `OTEL_SDK_DISABLED` for everything else. It accepts the usual OpenTelemetry specification and only `true` disables export, so `OTEL_SDK_DISABLED=1` does not. Values are case-insensitive and trimmed. An unrecognized value is ignored with a startup warning. +`OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER`, or `OTEL_LOGS_EXPORTER` set to `none` turns off +just that signal, overriding an OTEL endpoint and the Settings endpoint. A `T3CODE_OTLP_*_URL` still +wins for its signal. `otlp` is the default, and any other exporter name, such as `console` or +`prometheus`, is ignored with a startup warning. + ### What Is Instrumented Today Current high-value span and metric boundaries include: diff --git a/docs/user/usage.md b/docs/user/usage.md index dc55aba41023..64f392d6e941 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -31,6 +31,9 @@ variable. Use absolute paths or `~/` paths in the account's environment settings environment paths depend on each project's working directory and cannot be reliably discovered by Usage. Accounts sharing a history directory count once. +When your app and server support different providers, usage totals may cover only the providers +your app understands. Update the app to include newly supported providers. + On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All environments are selected by default. The dropdown shows which environments are still scanning; results appear as each one responds. diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 55fe46306ddd..86914c768e3b 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -13,7 +13,12 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; -import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits } from "./usage.ts"; +import { + cursorKeychainAccessEnvironments, + needsCursorKeychainAccess, + refreshUsage, + refreshUsageLimits, +} from "./usage.ts"; const input = { sinceDay: UsageDay.make("2026-09-05"), @@ -285,4 +290,34 @@ describe("needsCursorKeychainAccess", () => { expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false); expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false); }); + + it("stops offering access once any environment reads the Cursor account", () => { + const off = { summary: cursorPrompt, needsCursorKeychainAccess: true }; + const account: UsageSummary = { + ...summary, + sources: [ + { + fingerprint: { + hostId: "cursor.com", + provider: "cursor", + resolvedHomePath: "cursor-account:abc", + volumeId: "abc", + }, + status: "ok", + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 1, + message: null, + }, + ], + }; + expect(cursorKeychainAccessEnvironments([off, off])).toEqual([off, off]); + expect( + cursorKeychainAccessEnvironments([ + off, + { summary: account, needsCursorKeychainAccess: false }, + ]), + ).toEqual([]); + }); }); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index dc959c0fd8f0..1f2377a89f42 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -26,6 +26,27 @@ export function needsCursorKeychainAccess( ); } +/** + * Environments to offer the Cursor Keychain prompt in the usage summary. + * + * Any environment reading a Cursor account already reports that account's + * history from every machine, so the prompt only adds duplicates there. A + * different account on another machine stays reachable from provider settings. + */ +export function cursorKeychainAccessEnvironments< + E extends { readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean }, +>(environments: readonly E[]): readonly E[] { + const hasCursorAccount = environments.some((environment) => + environment.summary?.sources.some( + (source) => + source.fingerprint.provider === "cursor" && source.fingerprint.hostId === "cursor.com", + ), + ); + return hasCursorAccount + ? [] + : environments.filter((environment) => environment.needsCursorKeychainAccess); +} + const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index a6431330558a..97db8594379a 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -11,12 +11,14 @@ */ import * as Schema from "effect/Schema"; -import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The * client renders partial coverage when an environment reports an older version * rather than failing the whole page. + * Adding providers or other array-element variants is additive: unknown + * entries are skipped on decode and do not require a version bump. */ export const USAGE_CONTRACT_VERSION = 6 as const; @@ -201,8 +203,8 @@ export const UsageSummary = Schema.Struct({ timeZone: TrimmedNonEmptyString, sinceDay: UsageDay, untilDay: UsageDay, - buckets: Schema.Array(UsageBucket), - sources: Schema.Array(UsageSource), + buckets: ForwardCompatibleArray(UsageBucket), + sources: ForwardCompatibleArray(UsageSource), pricing: UsagePricing, /** Wall-clock cost of the scan, surfaced in diagnostics. */ scanDurationMs: NonNegativeInt, diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts index cd4e683faa6b..e436c64c6360 100644 --- a/packages/shared/src/otelEnvironment.test.ts +++ b/packages/shared/src/otelEnvironment.test.ts @@ -204,6 +204,73 @@ describe("OtelEnvironment", () => { logs: "Unset", warnings: [T3_OFF], }, + { + name: "an exporter of none turns off only its signal", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_LOGS_EXPORTER: "none", + }, + traces: "https://collector:4318/v1/traces", + metrics: "https://collector:4318/v1/metrics", + logs: "Off", + warnings: [], + }, + { + name: "an exporter of none turns its signal off with no endpoint named", + env: { OTEL_METRICS_EXPORTER: " NONE " }, + traces: "Unset", + metrics: "Off", + logs: "Unset", + warnings: [], + }, + { + name: "an exporter of none leaves its signal's endpoint unread", + env: { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "not-a-url", OTEL_TRACES_EXPORTER: "none" }, + traces: "Off", + metrics: "Unset", + logs: "Unset", + warnings: [], + }, + { + name: "an exporter of otlp or blank is the default", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_TRACES_EXPORTER: "OTLP", + OTEL_LOGS_EXPORTER: " ", + }, + traces: "https://collector:4318/v1/traces", + metrics: "https://collector:4318/v1/metrics", + logs: "https://collector:4318/v1/logs", + warnings: [], + }, + { + name: "an exporter T3 Code does not have is ignored with a warning", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_METRICS_EXPORTER: "prometheus", + }, + traces: "https://collector:4318/v1/traces", + metrics: "https://collector:4318/v1/metrics", + logs: "https://collector:4318/v1/logs", + warnings: [ + "OTEL_METRICS_EXPORTER names prometheus, which T3 Code does not export to, so it was ignored", + ], + }, + { + name: "a list honors none and otlp and names what it ignored", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_TRACES_EXPORTER: "console,none", + OTEL_LOGS_EXPORTER: "otlp,console,otlpp", + }, + traces: "Off", + metrics: "https://collector:4318/v1/metrics", + logs: "https://collector:4318/v1/logs", + warnings: [ + "OTEL_TRACES_EXPORTER names console, which T3 Code does not export to, so it was ignored", + "OTEL_LOGS_EXPORTER names console, otlpp, which T3 Code does not export to, so they were ignored", + ], + }, ])("$name", ({ env, traces, metrics, logs, warnings }) => Effect.gen(function* () { const resolved = yield* load(env); @@ -396,6 +463,25 @@ describe("OtelEnvironment", () => { }); }); + it.effect("an exporter of none keeps the Settings endpoint from re-enabling its signal", () => + Effect.gen(function* () { + const otel = yield* load({ OTEL_LOGS_EXPORTER: "none" }); + const t3 = { + url: undefined, + export: { protocol: "http/json", headers: undefined, exportIntervalMs: 10_000 }, + } as const; + assert.strictEqual( + OtelEnvironment.resolveSignalEndpoint(otel, "logs", t3, "http://settings:4318/v1/logs"), + undefined, + ); + assert.strictEqual( + OtelEnvironment.resolveSignalEndpoint(otel, "traces", t3, "http://settings:4318/v1/traces") + ?.url, + "http://settings:4318/v1/traces", + ); + }), + ); + describe("layerResourceAttributes", () => { it.effect.each([ { name: "a list that does not decode", raw: "team=%zz", attributes: [] }, diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index 05a787dcfede..c2496bd28a52 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -1,5 +1,5 @@ /** - * otelEnvironment: the OpenTelemetry kill switch and endpoint variables, + * otelEnvironment: the OpenTelemetry kill switch, exporter, and endpoint variables, * shared by the server and the desktop main process so both agree on what * turns export off and where it goes. * @@ -23,8 +23,9 @@ type OtlpSignalName = "TRACES" | "METRICS" | "LOGS"; /** * What the OTEL variables say about one signal. `Off` is a signal they - * claimed with an endpoint, protocol, or headers that do not read, so it is - * exported nowhere rather than to the bootstrap or Settings collector. + * claimed with an endpoint, protocol, or headers that do not read, or turned + * off with `OTEL__EXPORTER=none`, so it is exported nowhere rather + * than to the bootstrap or Settings collector. */ export type OtelSignal = Data.TaggedEnum<{ Unset: {}; @@ -179,6 +180,38 @@ const headers = (name: string) => `${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`, ); +type Exporter = "otlp" | "none"; + +const EXPORTERS: ReadonlySet = new Set(["otlp", "none"]); + +const isExporter = (entry: string): entry is Exporter => EXPORTERS.has(entry); + +/** + * `OTEL__EXPORTER`, a case-insensitive list whose default is `otlp`. + * Entries T3 Code has no exporter for are named in a warning and dropped, and + * a list left with nothing to honor reads as unset, as the specification asks + * of any enum value an implementation does not recognize. + */ +const exporter = (name: string): Config.Config> => + Config.String(name).pipe( + Config.option, + Config.map((option): Setting => { + const entries = (Option.getOrUndefined(option) ?? "") + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry !== ""); + const ignored = [...new Set(entries.filter((entry) => !isExporter(entry)))]; + const known = new Set(entries.filter(isExporter)); + const value = known.has("otlp") ? "otlp" : known.has("none") ? "none" : undefined; + return ignored.length === 0 + ? { value } + : { + value, + warning: `${name} names ${ignored.join(", ")}, which T3 Code does not export to, so ${ignored.length === 1 ? "it was" : "they were"} ignored`, + }; + }), + ); + interface Settings { readonly endpoint: Setting; readonly protocol: Setting; @@ -215,9 +248,23 @@ interface ResolvedSignal { /** * A signal whose endpoint, protocol, or headers do not read is not exported * rather than sent somewhere, in a format, or without the credentials its - * collector expects. + * collector expects. `none` turns the signal off before any of those are read, + * whether or not an endpoint was named. */ -const signal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => { +const signal = ( + name: OtlpSignalName, + exporter: Setting, + own: Settings, + generic: Settings, +): ResolvedSignal => { + if (exporter.value === "none") { + return { signal: OtelSignal.Off(), used: [exporter] }; + } + const resolved = endpointSignal(name, own, generic); + return { signal: resolved.signal, used: [exporter, ...resolved.used] }; +}; + +const endpointSignal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => { const ownEndpoint = isClaimed(own.endpoint); const endpoint = ownEndpoint ? own.endpoint : generic.endpoint; if (endpoint.value === undefined) { @@ -263,16 +310,21 @@ export const load: Effect.Effect = Config.all({ traces: settings("OTEL_EXPORTER_OTLP_TRACES_"), metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"), logs: settings("OTEL_EXPORTER_OTLP_LOGS_"), + exporters: Config.all({ + traces: exporter("OTEL_TRACES_EXPORTER"), + metrics: exporter("OTEL_METRICS_EXPORTER"), + logs: exporter("OTEL_LOGS_EXPORTER"), + }), }).pipe( - Effect.map(({ t3, spec, resource, generic, ...own }) => { + Effect.map(({ t3, spec, resource, generic, exporters, ...own }) => { const disabled = t3.value ?? spec.value ?? false; // The kill switch wins outright, so the signals say nothing once it is set. const signals = disabled ? undefined : { - traces: signal("TRACES", own.traces, generic), - metrics: signal("METRICS", own.metrics, generic), - logs: signal("LOGS", own.logs, generic), + traces: signal("TRACES", exporters.traces, own.traces, generic), + metrics: signal("METRICS", exporters.metrics, own.metrics, generic), + logs: signal("LOGS", exporters.logs, own.logs, generic), }; // A generic variable read by several signals warns once. const used = new Set( diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 328e0cf814e7..83b2d53fb99f 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -6,6 +6,8 @@ */ import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts"; +import type { UsageContractMismatch } from "./usageMerge.ts"; + const CURRENCY = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", @@ -49,6 +51,15 @@ export function formatPercent(share: number, digits = 1): string { return `${percent.toFixed(digits)}%`; } +export function formatUsageContractMismatch( + environmentLabel: string, + mismatch: Pick, +): string { + return mismatch.direction === "serverBehind" + ? `${environmentLabel} runs an older server version and is excluded from totals.` + : `This client is older than the server on ${environmentLabel}; its usage is excluded from totals.`; +} + /** `2026-08-07` to `Aug 7`. */ export function formatDayShort(day: string): string { const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 178282238482..81d1e8565a6c 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -5,12 +5,16 @@ import { type UsageBucket, type UsageDay, type UsageProviderKind, - type UsageSummary, + UsageSummary, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; +const decodeSummary = Schema.decodeUnknownSync(UsageSummary); +const encodeSummary = Schema.encodeSync(UsageSummary); + function bucket(overrides: Partial = {}): UsageBucket { return { day: "2026-08-07" as UsageDay, @@ -325,7 +329,7 @@ describe("mergeUsage", () => { expect(merged.contributingEnvironments).toEqual(["old"]); }); - it("excludes an environment reporting an older contract version", () => { + it("identifies an environment reporting an older contract version", () => { const merged = mergeUsage( [ environment( @@ -345,7 +349,38 @@ describe("mergeUsage", () => { ); expect(merged.costUsd).toBe(10); - expect(merged.staleEnvironments).toEqual(["env-b"]); + expect(merged.contractMismatches).toEqual([ + { + environmentId: "env-b", + direction: "serverBehind", + contractVersion: USAGE_MERGE_COMPATIBLE_SINCE - 1, + }, + ]); + }); + + it("identifies an environment reporting a newer contract version", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket()], + [{ provider: "claude", hostId: "mac", homePath: "/a" }], + USAGE_CONTRACT_VERSION + 1, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(0); + expect(merged.contractMismatches).toEqual([ + { + environmentId: "env-a", + direction: "clientBehind", + contractVersion: USAGE_CONTRACT_VERSION + 1, + }, + ]); }); it("keeps the previous compatible contract version so additive provider expansions still merge", () => { @@ -371,7 +406,86 @@ describe("mergeUsage", () => { ); expect(merged.costUsd).toBe(14); - expect(merged.staleEnvironments).toEqual([]); + expect(merged.contractMismatches).toEqual([]); + }); + + it("keeps known usage when newer providers and bucket variants cannot be decoded", () => { + const known = summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]); + const decoded = decodeSummary({ + ...known, + buckets: [ + ...known.buckets, + { ...bucket(), provider: "future-provider", costUsd: 100 }, + { ...bucket(), costSource: "future-pricing", costUsd: 200 }, + ], + sources: [ + ...known.sources, + { + ...known.sources[0], + fingerprint: { + ...known.sources[0]?.fingerprint, + provider: "future-provider", + }, + }, + ], + }); + expect(decoded).toEqual(known); + const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(10); + expect(merged.totalTokens).toBe(1160); + expect(merged.contractMismatches).toEqual([]); + }); + + it("normalizes model names during decoding before grouping usage", () => { + const decoded = decodeSummary( + summary( + [ + bucket({ provider: "codex", model: " gpt-5 " }), + bucket({ provider: "codex", model: "gpt-5" }), + ], + [{ provider: "codex", hostId: "mac", homePath: "/a" }], + ), + ); + + expect(decoded.buckets.map((entry) => entry.model)).toEqual(["gpt-5", "gpt-5"]); + const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); + expect(merged.models).toHaveLength(1); + expect(merged.models[0]).toMatchObject({ model: "gpt-5", costUsd: 20, totalTokens: 2320 }); + }); + + it("keeps all supported providers when encoding a response", () => { + const current = summary( + [bucket(), bucket({ provider: "grok" })], + [ + { provider: "claude", hostId: "mac", homePath: "/a" }, + { provider: "grok", hostId: "mac", homePath: "/b" }, + ], + ); + expect(encodeSummary(current)).toEqual(current); + expect(decodeSummary(encodeSummary(current))).toEqual(current); + }); + + it("still rejects a malformed summary envelope", () => { + expect(() => decodeSummary({ ...summary([], []), buckets: null })).toThrow(); + }); + + it("excludes a future incompatible contract even when its buckets still decode", () => { + const decoded = decodeSummary( + summary( + [bucket()], + [{ provider: "claude", hostId: "mac", homePath: "/a" }], + USAGE_CONTRACT_VERSION + 1, + ), + ); + const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(0); + expect(merged.contractMismatches).toEqual([ + { + environmentId: "env-a", + direction: "clientBehind", + contractVersion: USAGE_CONTRACT_VERSION + 1, + }, + ]); }); it("derives provider shares and cost quality", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index c4a56829a420..90c9a1ff4261 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -76,6 +76,12 @@ export interface CostQuality { readonly cacheSavingsUsd: number; } +export interface UsageContractMismatch { + readonly environmentId: EnvironmentId; + readonly direction: "serverBehind" | "clientBehind"; + readonly contractVersion: number; +} + export interface MergedUsage { readonly costUsd: number; readonly uncachedInputTokens: number; @@ -94,7 +100,7 @@ export interface MergedUsage { /** Environments whose data was dropped as a duplicate of another's. */ readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; - readonly staleEnvironments: readonly EnvironmentId[]; + readonly contractMismatches: readonly UsageContractMismatch[]; } /** @@ -303,15 +309,15 @@ const EMPTY_MERGED: MergedUsage = { }, duplicateSources: [], contributingEnvironments: [], - staleEnvironments: [], + contractMismatches: [], }; /** * Merges every connected environment's summary. * - * `expectedContractVersion` guards against an environment running older server - * code: rather than blocking the page, incompatible data is excluded and its - * id is reported so the UI can say coverage is partial. Versions in + * `expectedContractVersion` guards against incompatible server code: rather + * than blocking the page, its data is excluded and the mismatch direction is + * reported so the UI can identify which side needs updating. Versions in * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive * provider expansion does not drop Claude/Codex totals from older servers. */ @@ -322,14 +328,21 @@ export function mergeUsage( if (environments.length === 0) return EMPTY_MERGED; const current: EnvironmentUsage[] = []; - const staleEnvironments: EnvironmentId[] = []; + const contractMismatches: UsageContractMismatch[] = []; for (const environment of environments) { if ( isCompatibleUsageContractVersion(environment.summary.contractVersion, expectedContractVersion) ) { current.push(environment); } else { - staleEnvironments.push(environment.environmentId); + contractMismatches.push({ + environmentId: environment.environmentId, + direction: + environment.summary.contractVersion < expectedContractVersion + ? "serverBehind" + : "clientBehind", + contractVersion: environment.summary.contractVersion, + }); } } @@ -544,6 +557,6 @@ export function mergeUsage( }, duplicateSources: duplicates, contributingEnvironments, - staleEnvironments, + contractMismatches, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c001be981c10..f579bfa8a5a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10438,10 +10438,6 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} - engines: {node: '>=16'} - tough-cookie@6.0.2: resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} @@ -18908,7 +18904,7 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.2 + lru-cache: 11.5.3 parse5: 8.0.1 saxes: 6.0.0 tough-cookie: 6.0.2 @@ -20013,7 +20009,7 @@ snapshots: rettime: 0.10.1 statuses: 2.0.2 strict-event-emitter: 0.5.1 - tough-cookie: 6.0.1 + tough-cookie: 6.0.2 type-fest: 5.7.0 until-async: 3.0.2 yargs: 17.7.2 @@ -20039,7 +20035,7 @@ snapshots: rettime: 0.10.1 statuses: 2.0.2 strict-event-emitter: 0.5.1 - tough-cookie: 6.0.1 + tough-cookie: 6.0.2 type-fest: 5.7.0 until-async: 3.0.2 yargs: 17.7.2 @@ -20516,7 +20512,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.5.2 + lru-cache: 11.5.3 minipass: 7.1.3 path-to-regexp@6.1.0: {} @@ -21953,11 +21949,6 @@ snapshots: totalist@3.0.1: {} - tough-cookie@6.0.1: - dependencies: - tldts: 7.4.2 - optional: true - tough-cookie@6.0.2: dependencies: tldts: 7.4.2 diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 4e97301ddba8..98ba0aaefbc8 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -175,6 +175,8 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu readonly copyUnpackedNatives: boolean; readonly serverEntrySource?: string; readonly wslRuntime?: "valid" | "loose-server-tree" | "missing-pty" | "bad-digest"; + readonly targetArch?: "x64" | "arm64"; + readonly ptyPrebuildArch?: "x64" | "arm64"; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -212,7 +214,7 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad"); if (input.wslRuntime !== undefined) { - const stem = wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64"); + const stem = wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, input.targetArch ?? "x64"); const sourceArchivePath = input.wslRuntime === "loose-server-tree" ? // The old hand-rolled runtime: apps/server/dist + node_modules at the @@ -226,9 +228,16 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu : yield* makeLinuxCliArchiveFixture({ root: path.join(tempDir, "wsl-runtime"), stem, - ...(input.wslRuntime === "missing-pty" + ...(input.wslRuntime === "missing-pty" || input.ptyPrebuildArch !== undefined ? { omitMembers: [`${stem}/node_modules/node-pty/build/Release/pty.node`] } : {}), + ...(input.ptyPrebuildArch !== undefined + ? { + extraMembers: [ + `${stem}/node_modules/node-pty/prebuilds/linux-${input.ptyPrebuildArch}/pty.node`, + ], + } + : {}), }); const archivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME); const hashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME); @@ -1206,6 +1215,55 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ).pipe(Effect.provideService(HostProcessPlatform, "linux")), ); + for (const targetArch of ["x64", "arm64"] as const) { + it.effect(`accepts an embedded archive with the Linux ${targetArch} node-pty prebuild`, () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + wslRuntime: "valid", + targetArch, + ptyPrebuildArch: targetArch, + }); + const result = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch, + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + expectWslRuntime: true, + }); + + assert.equal(result.packagedAppDir, fixture.packagedAppDir); + }), + ).pipe(Effect.provideService(HostProcessPlatform, "linux")), + ); + + it.effect( + `rejects a node-pty prebuild for the wrong architecture in a Linux ${targetArch} archive`, + () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + wslRuntime: "valid", + targetArch, + ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64", + }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch, + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + expectWslRuntime: true, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "wsl-runtime-invalid"); + }), + ), + ); + } + it.effect("rejects an embedded archive built for a different release version", () => Effect.scoped( Effect.gen(function* () { @@ -1285,6 +1343,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(error.reason, "wsl-runtime-invalid"); assert.deepStrictEqual(error.missingFiles, [ `${wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64")}/node_modules/node-pty/build/Release/pty.node`, + `${wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64")}/node_modules/node-pty/prebuilds/linux-x64/pty.node`, ]); }), ), diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index d2d9df767946..5b22fddbb39a 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -3291,13 +3291,16 @@ export const validateWindowsPackagedPayload = Effect.fn( ), ); } - const requiredMembers = [ - `${stem}/t3`, - `${stem}/client`, - `${stem}/node_modules`, + const requiredMembers = [`${stem}/t3`, `${stem}/client`, `${stem}/node_modules`]; + const missingMembers = requiredMembers.filter((member) => !members.includes(member)); + // node-pty can load a source build or the prebuild for the WSL target. + const ptyCandidates = [ `${stem}/node_modules/node-pty/build/Release/pty.node`, + `${stem}/node_modules/node-pty/prebuilds/linux-${input.targetArch}/pty.node`, ]; - const missingMembers = requiredMembers.filter((member) => !members.includes(member)); + if (!ptyCandidates.some((member) => members.includes(member))) { + missingMembers.push(...ptyCandidates); + } if (missingMembers.length > 0) { return yield* new WindowsPackagedPayloadValidationError({ reason: "wsl-runtime-invalid",