diff --git a/apps/mobile/src/components/AppSymbol.ios.tsx b/apps/mobile/src/components/AppSymbol.ios.tsx index f1a28ed3f338..8f3f5f8abf75 100644 --- a/apps/mobile/src/components/AppSymbol.ios.tsx +++ b/apps/mobile/src/components/AppSymbol.ios.tsx @@ -1,14 +1,15 @@ -import { SymbolView as ExpoSymbolView, type SymbolViewProps } from "expo-symbols"; +import { SymbolView as ExpoSymbolView } from "expo-symbols"; import { withUniwind } from "uniwind"; +import type { AppSymbolViewProps } from "./AppSymbol"; export type { SFSymbol } from "expo-symbols"; -export type AppSymbolName = SymbolViewProps["name"]; +export type { AppSymbolName } from "./AppSymbol"; /** * Keep the iOS implementation isolated from the Android Tabler fallback so * Metro does not initialize the icon package when iOS renders SF Symbols. */ -function AppSymbolView(props: SymbolViewProps) { +function AppSymbolView(props: AppSymbolViewProps) { return ; } diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index b65fb769e75e..6002dc48cd91 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -4,11 +4,14 @@ import type { Icon } from "@tabler/icons-react-native/types"; * the entire Tabler icon set in Metro. */ import IconAdjustmentsHorizontal from "@tabler/icons-react-native/IconAdjustmentsHorizontal"; +import IconAlignLeft from "@tabler/icons-react-native/IconAlignLeft"; import IconAlertCircle from "@tabler/icons-react-native/IconAlertCircle"; import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; import IconApps from "@tabler/icons-react-native/IconApps"; import IconArchive from "@tabler/icons-react-native/IconArchive"; import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; +import IconArrowDown from "@tabler/icons-react-native/IconArrowDown"; +import IconArrowForwardUp from "@tabler/icons-react-native/IconArrowForwardUp"; import IconArrowLeft from "@tabler/icons-react-native/IconArrowLeft"; import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle"; import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle"; @@ -30,15 +33,17 @@ import IconChevronDown from "@tabler/icons-react-native/IconChevronDown"; import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft"; import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; +import IconCircle from "@tabler/icons-react-native/IconCircle"; import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; import IconTicket from "@tabler/icons-react-native/IconTicket"; import IconClock from "@tabler/icons-react-native/IconClock"; import IconCode from "@tabler/icons-react-native/IconCode"; import IconCopy from "@tabler/icons-react-native/IconCopy"; -import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; import IconDatabase from "@tabler/icons-react-native/IconDatabase"; +import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; +import IconDeviceMobile from "@tabler/icons-react-native/IconDeviceMobile"; import IconDots from "@tabler/icons-react-native/IconDots"; import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical"; import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; @@ -62,13 +67,15 @@ import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; import IconLetterSpacing from "@tabler/icons-react-native/IconLetterSpacing"; -import IconMenu2 from "@tabler/icons-react-native/IconMenu2"; +import IconMicrophone from "@tabler/icons-react-native/IconMicrophone"; import IconLink from "@tabler/icons-react-native/IconLink"; +import IconMenu2 from "@tabler/icons-react-native/IconMenu2"; import IconMessage from "@tabler/icons-react-native/IconMessage"; import IconMinus from "@tabler/icons-react-native/IconMinus"; import IconMoon from "@tabler/icons-react-native/IconMoon"; import IconNetwork from "@tabler/icons-react-native/IconNetwork"; import IconPalette from "@tabler/icons-react-native/IconPalette"; +import IconPencil from "@tabler/icons-react-native/IconPencil"; import IconPhoto from "@tabler/icons-react-native/IconPhoto"; import IconPin from "@tabler/icons-react-native/IconPin"; import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff"; @@ -97,13 +104,14 @@ import IconUserCircle from "@tabler/icons-react-native/IconUserCircle"; import IconWifiOff from "@tabler/icons-react-native/IconWifiOff"; import IconWorld from "@tabler/icons-react-native/IconWorld"; import IconX from "@tabler/icons-react-native/IconX"; -import type { SFSymbol, SymbolViewProps } from "expo-symbols"; +import type { AndroidSymbol, SFSymbol, SymbolViewProps } from "expo-symbols"; import { withUniwind } from "uniwind"; -const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { +const ANDROID_ICON_BY_SF_SYMBOL = { "arrow.branch": IconGitBranch, "arrow.left": IconArrowLeft, "arrow.clockwise": IconRefresh, + "arrow.down": IconArrowDown, "arrow.down.circle": IconArrowDownCircle, "arrow.right.circle": IconArrowRightCircle, "arrow.triangle.branch": IconGitBranch, @@ -117,6 +125,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.up.right": IconArrowUpRight, "arrow.up.right.circle": IconArrowUpRightCircle, "arrow.uturn.backward": IconArrowBackUp, + "arrow.uturn.forward": IconArrowForwardUp, archivebox: IconArchive, "archivebox.fill": IconArchive, "bell.badge": IconBellRinging, @@ -127,6 +136,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "chart.bar.xaxis": IconChartBar, checkmark: IconCheck, "checkmark.circle": IconCircleCheck, + circle: IconCircle, clock: IconClock, ticket: IconTicket, cloud: IconCloud, @@ -143,11 +153,13 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { moon: IconMoon, "ellipsis.circle": IconDotsCircleHorizontal, "exclamationmark.triangle": IconAlertTriangle, + "exclamationmark.circle": IconAlertCircle, eye: IconEye, folder: IconFolder, "folder.badge.plus": IconFolderPlus, "folder.fill": IconFolder, gearshape: IconSettings, + globe: IconWorld, hammer: IconHammer, "info.circle": IconInfoCircle, internaldrive: IconDatabase, @@ -162,7 +174,9 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { macmini: IconServer, macstudio: IconDeviceDesktop, magnifyingglass: IconSearch, + mic: IconMicrophone, paintbrush: IconPalette, + pencil: IconPencil, "person.crop.circle": IconUserCircle, photo: IconPhoto, pin: IconPin, @@ -187,6 +201,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "sun.max": IconSun, "stop.fill": IconPlayerStopFilled, terminal: IconTerminal2, + "text.alignleft": IconAlignLeft, "text.bubble": IconMessage, "text.word.spacing": IconLetterSpacing, "textformat.size": IconTypography, @@ -197,13 +212,14 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "wifi.slash": IconWifiOff, xmark: IconX, "xmark.circle.fill": IconCircleXFilled, -}; +} satisfies Partial>; +const SF_ICON_LOOKUP: Partial> = ANDROID_ICON_BY_SF_SYMBOL; // Callers can pass `{ ios, android }` names where `android` is a Material // icon name (the raw expo-symbols contract). Resolve those here too so the // android key keeps working through this wrapper — it wins over the SF map // when both match (e.g. folder vs folder_open for expanded project groups). -const ANDROID_ICON_BY_MATERIAL_NAME: Record = { +const ANDROID_ICON_BY_MATERIAL_NAME = { auto_awesome: IconSparkles, bolt: IconBolt, build: IconTool, @@ -222,21 +238,34 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record = { keyboard_arrow_up: IconChevronUp, keyboard_hide: IconKeyboardHide, more_vert: IconDotsVertical, + merge: IconGitMerge, public: IconWorld, remove: IconMinus, + smartphone: IconDeviceMobile, terminal: IconTerminal2, visibility: IconEye, -}; +} satisfies Partial>; export type { SFSymbol } from "expo-symbols"; -export type AppSymbolName = SymbolViewProps["name"]; +export type AppSymbolName = + | keyof typeof ANDROID_ICON_BY_SF_SYMBOL + | { + ios: SFSymbol; + android: keyof typeof ANDROID_ICON_BY_MATERIAL_NAME; + }; + +export type AppSymbolViewProps = Omit & { name: AppSymbolName }; + +export function isAppSymbolName(name: string): name is Extract { + return Object.prototype.hasOwnProperty.call(ANDROID_ICON_BY_SF_SYMBOL, name); +} -function AppSymbolView(props: SymbolViewProps) { +function AppSymbolView(props: AppSymbolViewProps) { const materialName = typeof props.name === "string" ? undefined : props.name.android; const sfSymbol = typeof props.name === "string" ? props.name : props.name.ios; const AndroidIcon = (materialName ? ANDROID_ICON_BY_MATERIAL_NAME[materialName] : undefined) ?? - (sfSymbol ? ANDROID_ICON_BY_SF_SYMBOL[sfSymbol] : undefined); + (sfSymbol ? SF_ICON_LOOKUP[sfSymbol] : undefined); if (!AndroidIcon) { return props.fallback ?? null; diff --git a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx index 968040fd4113..5586f3a22130 100644 --- a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx +++ b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx @@ -1,9 +1,7 @@ import type { EnvironmentMachineKind } from "@t3tools/contracts"; -import type { SFSymbol } from "expo-symbols"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; -import { SymbolView } from "./AppSymbol"; - -const SYMBOL_BY_KIND: Record = { +const SYMBOL_BY_KIND: Record = { server: "server.rack", cloud: "cloud", linux: "terminal", diff --git a/apps/mobile/src/components/MaterialMenuPopup.android.tsx b/apps/mobile/src/components/MaterialMenuPopup.android.tsx index 87b467af9675..c49db1815039 100644 --- a/apps/mobile/src/components/MaterialMenuPopup.android.tsx +++ b/apps/mobile/src/components/MaterialMenuPopup.android.tsx @@ -12,7 +12,7 @@ import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import type { MaterialMenuPopupProps } from "./MaterialMenuPopup"; -import { SymbolView, type AppSymbolName } from "./AppSymbol"; +import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol"; function MenuIcon(props: { readonly name: AppSymbolName; @@ -96,10 +96,10 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) { ) : null} - {action.image ? ( + {action.image && isAppSymbolName(action.image) ? ( diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 27c3a3ff3efe..b391e213ab9a 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -7,7 +7,9 @@ import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { mergeUsage } from "@t3tools/shared/usageMerge"; import { + EnvironmentId, ProviderDriverKind, ProviderInstanceId, UsageDay, @@ -131,14 +133,15 @@ describe("UsageService", () => { [ { type: "session_meta", payload: { id: "codex-account-session" } }, { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, - { + // A-B-A at one timestamp must preserve both equal A events. + ...[11, 12, 11].map((outputTokens) => ({ type: "event_msg", timestamp: "2026-08-01T10:00:00Z", payload: { type: "token_count", - info: { last_token_usage: { input_tokens: 10, output_tokens: 11 } }, + info: { last_token_usage: { input_tokens: 10, output_tokens: outputTokens } }, }, - }, + })), ] .map((line) => encodeUnknownJsonString(line)) .join("\n") + "\n", @@ -198,7 +201,21 @@ describe("UsageService", () => { ), ); const summary = yield* service.readSummary(WINDOW); - assert.strictEqual(totalOutputTokens(summary), 36); + assert.strictEqual(totalOutputTokens(summary), 59); + yield* Effect.promise(() => + NodeFSP.rename( + NodePath.join(codexHome, "sessions", "rollout.jsonl"), + NodePath.join(codexHome, "sessions", "moved.jsonl"), + ), + ); + const moved = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(moved.buckets, summary.buckets); + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(codexHome, "sessions"), { recursive: true }), + ); + const removed = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(removed.buckets, summary.buckets); + const sources = summary.sources.filter((source) => source.status === "ok"); assert.strictEqual(sources.length, 4); assert.strictEqual( @@ -393,6 +410,97 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const alias = NodePath.join(home, "claude-alias"); + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(home, "claude"), alias, "junction"), + ); + const content = claudeLine(1, 5); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, content)); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + assert.isAbove(first.buckets[0]?.costUsd ?? 0, 0); + + yield* Effect.promise(() => NodeFSP.rm(transcript)); + const deleted = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(deleted.buckets, first.buckets); + assert.deepStrictEqual(deleted.sources, first.sources); + + const restarted = yield* UsageService.make; + const restored = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, first.buckets); + assert.deepStrictEqual(restored.sources, first.sources); + + // A moved transcript must not count the saved usage twice. + yield* Effect.promise(() => NodeFSP.writeFile(transcript + ".jsonl", content)); + const moved = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(moved.buckets, first.buckets); + assert.strictEqual(moved.sources[0]?.distinctSessions, 1); + + const replacementProjects = NodePath.join(home, "replacement-projects"); + yield* Effect.promise(() => NodeFSP.mkdir(replacementProjects)); + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }), + ); + const afterRootCleanup = yield* UsageService.make; + const missingRoot = yield* afterRootCleanup.readSummary(WINDOW); + assert.deepStrictEqual(missingRoot.buckets, first.buckets); + assert.strictEqual(missingRoot.sources[0]?.distinctSessions, 1); + assert.strictEqual(missingRoot.sources[0]?.status, "ok"); + assert.deepStrictEqual(missingRoot.sources[0]?.fingerprint, first.sources[0]?.fingerprint); + yield* Effect.promise(async () => { + const projects = NodePath.join(home, "claude", "projects"); + await NodeFSP.rename(replacementProjects, projects); + await NodeFSP.writeFile(NodePath.join(projects, "new.jsonl"), claudeLine(2, 7)); + }); + const recreated = yield* afterRootCleanup.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(recreated), 12); + assert.deepStrictEqual(recreated.sources[0]?.fingerprint, first.sources[0]?.fingerprint); + + const merged = mergeUsage( + [ + { + environmentId: EnvironmentId.make("cleanup-test"), + label: "test", + summary: recreated, + }, + { + environmentId: EnvironmentId.make("other-environment"), + label: "before cleanup", + summary: first, + }, + ], + missingRoot.contractVersion, + ); + assert.strictEqual(merged.outputTokens, 12); + assert.strictEqual(merged.sessions, 1); + assert.strictEqual(merged.costUsd, recreated.buckets[0]?.costUsd); + + const outsideWindow = yield* restarted.readSummary({ + ...WINDOW, + sinceDay: UsageDay.make("2026-08-02"), + }); + assert.deepStrictEqual(outsideWindow.buckets, []); + assert.strictEqual(outsideWindow.sources[0]?.distinctSessions, 0); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-cleanup-test", + home, + settings: { providers: { ...settings.providers, claudeAgent: { homePath: alias } } }, + ratesDocument: { + "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("does not share an in-flight scan after custom prices change", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 4c92412c2cca..72a258bcf322 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -101,6 +101,11 @@ const encodeRatesCache = Schema.encodeEffect( const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); +const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson); +const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String }); +const decodeCachedSources = Schema.decodeUnknownOption( + Schema.Struct({ sources: Schema.Record(Schema.String, CachedSource) }), +); export class UsageService extends Context.Service< UsageService, @@ -147,7 +152,12 @@ export const make = Effect.gen(function* () { const hostEnvironment = yield* HostProcessEnvironment; const fileCache: ScanCache = new Map(); + const sourceCache = new Map(); let cacheDirty = false; + const isWithinDirectory = (filePath: string, dir: string) => { + const relative = path.relative(dir, filePath); + return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); + }; const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); @@ -241,8 +251,14 @@ export const make = Effect.gen(function* () { /** Resolves the transcript directory for each provider. */ const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( settings: ServerSettingsValue, + retentionCutoffMs: number, ) { - const dirs: Array<{ provider: UsageProviderKind; dir: string; fileName?: string }> = []; + const dirs: Array<{ + provider: UsageProviderKind; + dir: string; + volumeId: string; + fileName?: string; + }> = []; const seen = new Set(); for (const driver of ["claudeAgent", "codex", "grok"] as const) { // Disabled accounts still have history. Explicit default slots replace @@ -286,14 +302,41 @@ export const make = Effect.gen(function* () { ); } const directory = path.resolve(home, provider === "claude" ? "projects" : "sessions"); - // Account aliases and Codex auth overlays can share the same history. + const sourceKey = provider + "\0" + directory; + const previous = sourceCache.get(sourceKey); + // Keep canonical paths and source fingerprints stable after root cleanup, + // including aliases and clients merging pre-cleanup environment summaries. const dir = yield* fileSystem .realPath(directory) - .pipe(Effect.orElseSucceed(() => directory)); + .pipe(Effect.orElseSucceed(() => previous?.dir ?? directory)); + const currentVolumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const hasRetainedHistory = fileCache + .entries() + .some( + ([filePath, entry]) => + entry.provider === provider && + entry.mtimeMs >= retentionCutoffMs && + entry.records.length + entry.tailRecords.length > 0 && + isWithinDirectory(filePath, dir), + ); + // A recreated directory still reports the retained history under its old identity. + const volumeId = + previous?.dir === dir && (hasRetainedHistory || !currentVolumeId) + ? previous.volumeId || currentVolumeId + : currentVolumeId; + if (previous?.dir !== dir || previous.volumeId !== volumeId) { + sourceCache.set(sourceKey, { dir, volumeId }); + cacheDirty = true; + } const key = `${provider}\0${dir}`; if (seen.has(key)) continue; seen.add(key); - dirs.push({ provider, dir, ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}) }); + dirs.push({ + provider, + dir, + volumeId, + ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}), + }); } } return dirs; @@ -314,6 +357,11 @@ export const make = Effect.gen(function* () { ); if (document === null) return; for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); + const sources = decodeCachedSources(document); + if (Option.isSome(sources)) { + for (const [key, source] of Object.entries(sources.value.sources)) + sourceCache.set(key, source); + } }), ); @@ -321,7 +369,10 @@ export const make = Effect.gen(function* () { if (!cacheDirty) return; // Cleared only after the write lands, so a failed persist is retried on // the next scan instead of leaving disk permanently stale. - yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe( + yield* encodeScanCacheFile({ + ...encodeScanCache(fileCache), + sources: Object.fromEntries(sourceCache), + }).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), Effect.map(() => { cacheDirty = false; @@ -372,7 +423,8 @@ export const make = Effect.gen(function* () { ); // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. - if (parsed === null) return []; + if (parsed === null) + return cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : []; // Stored already de-duplicated within the file, which is 99% of all // duplicates. The aggregator still runs the cross-file dedupe pass. One @@ -409,15 +461,15 @@ export const make = Effect.gen(function* () { const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( windowStartMs: number, settings: ServerSettingsValue, + retentionCutoffMs: number, ) { // The home resolvers ask for `Path` themselves; satisfy them from the // instance we already hold so the scan stays context-free. - const dirs = yield* resolveTranscriptDirs(settings).pipe( + const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( Effect.provideService(Path.Path, path), ); const scanned: ScannedDir[] = []; - for (const { provider, dir, fileName } of dirs) { - const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + for (const { provider, dir, volumeId, fileName } of dirs) { const exists = yield* fileSystem .exists(dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); @@ -487,11 +539,13 @@ export const make = Effect.gen(function* () { const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; + // Pricing only matters once records are aggregated, so the rate table // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. const [, scannedDirs] = yield* Effect.all( - [ensureRates(false), collectDirs(windowStartMs, settings)], + [ensureRates(false), collectDirs(windowStartMs, settings, retentionCutoffMs)], { concurrency: 2 }, ); @@ -506,41 +560,54 @@ export const make = Effect.gen(function* () { }); const sources: UsageSource[] = []; - const livePaths = new Set(); - const walkedRoots: string[] = []; for (const { provider, dir, volumeId, files } of scannedDirs) { - if (files === null) { - sources.push({ - fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, - status: "missing", - scannedFiles: 0, - skippedFiles: 0, - malformedRecords: 0, - distinctSessions: 0, - message: "No transcript directory on this environment.", - }); - continue; + const retainedFiles = [...(files ?? [])]; + const livePaths = new Set(retainedFiles.map((file) => file.path)); + // Cleanup may remove transcripts, but the usage we already saved still + // contributes to this source. Keep the normal aggregation and dedupe path. + for (const [filePath, entry] of fileCache) { + if ( + entry.provider !== provider || + entry.mtimeMs < retentionCutoffMs || + livePaths.has(filePath) || + !isWithinDirectory(filePath, dir) + ) + continue; + retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] }); } - - walkedRoots.push(dir); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a // session spans days and models, so clients total this figure instead. const sessionIds = new Set(); - for (const file of files) { - livePaths.add(file.path); + for (const file of retainedFiles) { if (file.records.length === 0) { skippedFiles += 1; continue; } scannedFiles += 1; + const codexEventOccurrences = new Map(); for (const record of file.records) { - // Only sessions that contributed in-window count: the mtime slack - // admits boundary files whose records fall outside the range. - if (aggregator.add(record) && record.sessionId.length > 0) { + let usageRecord = record; + if (record.provider === "codex" && record.sessionId.length > 0) { + // Match moved rollout copies without collapsing repeated equal events + // within one rollout (timestamps can have only second precision). + const key = encodeUsageRecordKey([ + record.provider, + record.sessionId, + record.timestampMs, + record.model, + record.totals, + ]); + const occurrence = (codexEventOccurrences.get(key) ?? 0) + 1; + codexEventOccurrences.set(key, occurrence); + usageRecord = { ...record, dedupeKey: key + ":" + occurrence }; + } + // Only sessions contributing in-window count; the mtime slack can + // admit boundary files whose records fall outside the range. + if (aggregator.add(usageRecord) && record.sessionId.length > 0) { sessionIds.add(record.sessionId); } } @@ -548,21 +615,17 @@ export const make = Effect.gen(function* () { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, - status: "ok", + // Clients exclude missing sources, so saved records remain an available source. + status: files === null && scannedFiles === 0 ? "missing" : "ok", scannedFiles, skippedFiles, malformedRecords: 0, distinctSessions: sessionIds.size, - message: null, + message: files === null ? "No transcript directory on this environment." : null, }); } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); + const pruned = pruneScanCache(fileCache, retentionCutoffMs); if (pruned > 0) cacheDirty = true; yield* persistScanCache(); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index fdb0aabafa40..cc1bdbcc1626 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -194,88 +194,17 @@ describe("pruneScanCache", () => { it("drops entries older than retention", () => { const cache = cacheWith([["/old.jsonl", 500, [record()]]]); - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/"], - windowStartMs: 400, - retentionCutoffMs, - }); + const removed = pruneScanCache(cache, retentionCutoffMs); expect(removed).toBe(1); expect(cache.size).toBe(0); }); - it("drops in-window entries whose file has disappeared", () => { + it("keeps entries whose file has disappeared", () => { const cache = cacheWith([["/gone.jsonl", 5000, [record()]]]); - pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/"], - windowStartMs: 4000, - retentionCutoffMs, - }); - - expect(cache.size).toBe(0); - }); - - it("keeps entries outside the walked window that are still within retention", () => { - // Viewing 7 days must not evict the 30-day entries, which that walk never - // looked for and so cannot prove are gone. - const cache = cacheWith([["/older-but-valid.jsonl", 2000, [record()]]]); - - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/"], - windowStartMs: 4000, - retentionCutoffMs, - }); - - expect(removed).toBe(0); - expect(cache.size).toBe(1); - }); - - it("keeps entries the walk saw", () => { - const cache = cacheWith([["/live.jsonl", 5000, [record()]]]); - - pruneScanCache(cache, { - livePaths: new Set(["/live.jsonl"]), - walkedRoots: ["/"], - windowStartMs: 4000, - retentionCutoffMs, - }); - - expect(cache.size).toBe(1); - }); -}); - -describe("pruneScanCache with an unwalked root", () => { - it("keeps in-window entries for a provider whose directory was not walked", () => { - // A missing provider root or failed settings read leaves livePaths without - // that provider's files. Its warm entries must survive the pass. - const cache = cacheWith([["/codex/sessions/a.jsonl", 5000, [record()]]]); - - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/claude/projects"], - windowStartMs: 4000, - retentionCutoffMs: 1000, - }); - - expect(removed).toBe(0); - expect(cache.size).toBe(1); - }); - - it("keeps entries under a sibling path that only shares the walked root prefix", () => { - const cache = cacheWith([["/claude/projects-copy/a.jsonl", 5000, [record()]]]); - - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/claude/projects"], - windowStartMs: 4000, - retentionCutoffMs: 1000, - }); + pruneScanCache(cache, retentionCutoffMs); - expect(removed).toBe(0); expect(cache.size).toBe(1); }); }); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 224f109147e4..71ef25051eb6 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -14,9 +14,6 @@ * * @module usageScanCache */ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodePath from "node:path"; - import type { UsageProviderKind } from "@t3tools/contracts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; @@ -295,47 +292,11 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { }; } -export interface PruneOptions { - /** Files the walk just saw. Only meaningful inside the walked window. */ - readonly livePaths: ReadonlySet; - /** - * Roots the walk actually completed. Absence from `livePaths` only proves a - * file is gone when its root was walked: a provider whose directory failed to - * resolve this pass must not have its warm entries purged. - */ - readonly walkedRoots: readonly string[]; - /** Start of the walked window; entries older than this were not looked for. */ - readonly windowStartMs: number; - /** Entries older than this are dropped regardless. */ - readonly retentionCutoffMs: number; -} - -/** - * Drops aged-out entries, and entries for files that have disappeared. - * - * The walk only covers the requested window, so absence from `livePaths` only - * proves deletion for entries *inside* that window. Pruning everything the walk - * missed would evict the 30-day entries every time someone looked at 7 days. - * - * Replaces an earlier record cap that cleared the whole cache once exceeded, - * which meant a large enough window never warmed up at all. - */ -export function pruneScanCache(cache: ScanCache, options: PruneOptions): number { +/** Keeps saved usage after transcript cleanup, until the reporting retention expires. */ +export function pruneScanCache(cache: ScanCache, retentionCutoffMs: number): number { let removed = 0; for (const [path, entry] of cache) { - const agedOut = entry.mtimeMs < options.retentionCutoffMs; - const underWalkedRoot = options.walkedRoots.some((root) => { - const relative = NodePath.relative(root, path); - return ( - relative === "" || - (relative !== ".." && - !relative.startsWith(`..${NodePath.sep}`) && - !NodePath.isAbsolute(relative)) - ); - }); - const deleted = - underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); - if (agedOut || deleted) { + if (entry.mtimeMs < retentionCutoffMs) { cache.delete(path); removed += 1; } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c2c5e75683ae..41b6d120d5e6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2647,6 +2647,7 @@ function ActivityGroupTimelineRow({ details.push( 0} @@ -2673,7 +2674,7 @@ function ActivityGroupTimelineRow({ shimmer={thinking} /> - {row.expanded ? {details} : null} + {row.expanded ? {details} : null} ); } @@ -2691,15 +2692,16 @@ function ThinkingTimelineRow() { } /** - * Thinking inside an expanded activity group: the trace is already one click - * deep, so the text renders under its "Thought" header without another toggle. + * Thinking inside a tool group has its own disclosure, preserved across recycling. * A group whose row already reads "Thought" (no visible tool) skips the header. */ function ReasoningTraceBlock({ + anchorKey, messages, live, showHeader, }: { + anchorKey: string; messages: ReadonlyArray; live: boolean; showHeader: boolean; @@ -2707,6 +2709,7 @@ function ReasoningTraceBlock({ const ctx = use(TimelineRowCtx); const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx); const first = messages[0]!; + const expanded = !showHeader || ctx.expandedReasoningMessageIds.has(first.id); const streaming = live && messages.some((reasoningMessage) => reasoningMessage.streaming) && @@ -2720,10 +2723,17 @@ function ReasoningTraceBlock({ return null; } const label = streaming ? "Thinking" : "Thought"; + const collapsedPreview = messages.find((message) => message.text.trim().length > 0)?.text.trim(); + const headerText = expanded ? label : (collapsedPreview ?? label); return ( {showHeader ? ( - + 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" + > @@ -2731,28 +2741,38 @@ function ReasoningTraceBlock({ ref={streaming ? observeVisibleAnimation : undefined} className="relative min-w-0 flex-1 truncate text-secondary-label" > - {label} - {streaming ? {label} : null} + {headerText} + {streaming ? {headerText} : null} + + + + + ) : null} + {expanded ? ( + + {messages.map((reasoningMessage) => ( + + ))} ) : null} - - {messages.map((reasoningMessage) => ( - - ))} - ); }