| ... | @@ -101,6 +101,11 @@ const encodeRatesCache = Schema.encodeEffect( | ... | @@ -101,6 +101,11 @@ const encodeRatesCache = Schema.encodeEffect( |
| 101 | const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec<unknown>); | 101 | const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec<unknown>); |
| 102 | const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); | 102 | const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); |
| 103 | const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); | 103 | const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); |
| | 104 | const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson); |
| | 105 | const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String }); |
| | 106 | const decodeCachedSources = Schema.decodeUnknownOption( |
| | 107 | Schema.Struct({ sources: Schema.Record(Schema.String, CachedSource) }), |
| | 108 | ); |
| 104 | | 109 | |
| 105 | export class UsageService extends Context.Service< | 110 | export class UsageService extends Context.Service< |
| 106 | UsageService, | 111 | UsageService, |
| ... | @@ -147,7 +152,12 @@ export const make = Effect.gen(function* () { | ... | @@ -147,7 +152,12 @@ export const make = Effect.gen(function* () { |
| 147 | const hostEnvironment = yield* HostProcessEnvironment; | 152 | const hostEnvironment = yield* HostProcessEnvironment; |
| 148 | | 153 | |
| 149 | const fileCache: ScanCache = new Map(); | 154 | const fileCache: ScanCache = new Map(); |
| | 155 | const sourceCache = new Map<string, typeof CachedSource.Type>(); |
| 150 | let cacheDirty = false; | 156 | let cacheDirty = false; |
| | 157 | const isWithinDirectory = (filePath: string, dir: string) => { |
| | 158 | const relative = path.relative(dir, filePath); |
| | 159 | return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); |
| | 160 | }; |
| 151 | | 161 | |
| 152 | const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); | 162 | const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); |
| 153 | const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); | 163 | const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); |
| ... | @@ -241,8 +251,14 @@ export const make = Effect.gen(function* () { | ... | @@ -241,8 +251,14 @@ export const make = Effect.gen(function* () { |
| 241 | /** Resolves the transcript directory for each provider. */ | 251 | /** Resolves the transcript directory for each provider. */ |
| 242 | const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( | 252 | const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( |
| 243 | settings: ServerSettingsValue, | 253 | settings: ServerSettingsValue, |
| | 254 | retentionCutoffMs: number, |
| 244 | ) { | 255 | ) { |
| 245 | const dirs: Array<{ provider: UsageProviderKind; dir: string; fileName?: string }> = []; | 256 | const dirs: Array<{ |
| | 257 | provider: UsageProviderKind; |
| | 258 | dir: string; |
| | 259 | volumeId: string; |
| | 260 | fileName?: string; |
| | 261 | }> = []; |
| 246 | const seen = new Set<string>(); | 262 | const seen = new Set<string>(); |
| 247 | for (const driver of ["claudeAgent", "codex", "grok"] as const) { | 263 | for (const driver of ["claudeAgent", "codex", "grok"] as const) { |
| 248 | // Disabled accounts still have history. Explicit default slots replace | 264 | // Disabled accounts still have history. Explicit default slots replace |
| ... | @@ -280,14 +296,41 @@ export const make = Effect.gen(function* () { | ... | @@ -280,14 +296,41 @@ export const make = Effect.gen(function* () { |
| 280 | ); | 296 | ); |
| 281 | } | 297 | } |
| 282 | const directory = path.resolve(home, provider === "claude" ? "projects" : "sessions"); | 298 | const directory = path.resolve(home, provider === "claude" ? "projects" : "sessions"); |
| 283 | // Account aliases and Codex auth overlays can share the same history. | 299 | const sourceKey = provider + "\0" + directory; |
| | 300 | const previous = sourceCache.get(sourceKey); |
| | 301 | // Keep canonical paths and source fingerprints stable after root cleanup, |
| | 302 | // including aliases and clients merging pre-cleanup environment summaries. |
| 284 | const dir = yield* fileSystem | 303 | const dir = yield* fileSystem |
| 285 | .realPath(directory) | 304 | .realPath(directory) |
| 286 | .pipe(Effect.orElseSucceed(() => directory)); | 305 | .pipe(Effect.orElseSucceed(() => previous?.dir ?? directory)); |
| | 306 | const currentVolumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); |
| | 307 | const hasRetainedHistory = fileCache |
| | 308 | .entries() |
| | 309 | .some( |
| | 310 | ([filePath, entry]) => |
| | 311 | entry.provider === provider && |
| | 312 | entry.mtimeMs >= retentionCutoffMs && |
| | 313 | entry.records.length + entry.tailRecords.length > 0 && |
| | 314 | isWithinDirectory(filePath, dir), |
| | 315 | ); |
| | 316 | // A recreated directory still reports the retained history under its old identity. |
| | 317 | const volumeId = |
| | 318 | previous?.dir === dir && (hasRetainedHistory || !currentVolumeId) |
| | 319 | ? previous.volumeId || currentVolumeId |
| | 320 | : currentVolumeId; |
| | 321 | if (previous?.dir !== dir || previous.volumeId !== volumeId) { |
| | 322 | sourceCache.set(sourceKey, { dir, volumeId }); |
| | 323 | cacheDirty = true; |
| | 324 | } |
| 287 | const key = `${provider}\0${dir}`; | 325 | const key = `${provider}\0${dir}`; |
| 288 | if (seen.has(key)) continue; | 326 | if (seen.has(key)) continue; |
| 289 | seen.add(key); | 327 | seen.add(key); |
| 290 | dirs.push({ provider, dir, ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}) }); | 328 | dirs.push({ |
| | 329 | provider, |
| | 330 | dir, |
| | 331 | volumeId, |
| | 332 | ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}), |
| | 333 | }); |
| 291 | } | 334 | } |
| 292 | } | 335 | } |
| 293 | return dirs; | 336 | return dirs; |
| ... | @@ -308,6 +351,11 @@ export const make = Effect.gen(function* () { | ... | @@ -308,6 +351,11 @@ export const make = Effect.gen(function* () { |
| 308 | ); | 351 | ); |
| 309 | if (document === null) return; | 352 | if (document === null) return; |
| 310 | for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); | 353 | for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); |
| | 354 | const sources = decodeCachedSources(document); |
| | 355 | if (Option.isSome(sources)) { |
| | 356 | for (const [key, source] of Object.entries(sources.value.sources)) |
| | 357 | sourceCache.set(key, source); |
| | 358 | } |
| 311 | }), | 359 | }), |
| 312 | ); | 360 | ); |
| 313 | | 361 | |
| ... | @@ -315,7 +363,10 @@ export const make = Effect.gen(function* () { | ... | @@ -315,7 +363,10 @@ export const make = Effect.gen(function* () { |
| 315 | if (!cacheDirty) return; | 363 | if (!cacheDirty) return; |
| 316 | // Cleared only after the write lands, so a failed persist is retried on | 364 | // Cleared only after the write lands, so a failed persist is retried on |
| 317 | // the next scan instead of leaving disk permanently stale. | 365 | // the next scan instead of leaving disk permanently stale. |
| 318 | yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe( | 366 | yield* encodeScanCacheFile({ |
| | 367 | ...encodeScanCache(fileCache), |
| | 368 | sources: Object.fromEntries(sourceCache), |
| | 369 | }).pipe( |
| 319 | Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), | 370 | Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), |
| 320 | Effect.map(() => { | 371 | Effect.map(() => { |
| 321 | cacheDirty = false; | 372 | cacheDirty = false; |
| ... | @@ -366,7 +417,8 @@ export const make = Effect.gen(function* () { | ... | @@ -366,7 +417,8 @@ export const make = Effect.gen(function* () { |
| 366 | ); | 417 | ); |
| 367 | // A read failure is not an empty transcript: caching it under this | 418 | // A read failure is not an empty transcript: caching it under this |
| 368 | // (size, mtime) would silently drop the file's usage until it changes. | 419 | // (size, mtime) would silently drop the file's usage until it changes. |
| 369 | if (parsed === null) return []; | 420 | if (parsed === null) |
| | 421 | return cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : []; |
| 370 | | 422 | |
| 371 | // Stored already de-duplicated within the file, which is 99% of all | 423 | // Stored already de-duplicated within the file, which is 99% of all |
| 372 | // duplicates. The aggregator still runs the cross-file dedupe pass. One | 424 | // duplicates. The aggregator still runs the cross-file dedupe pass. One |
| ... | @@ -403,15 +455,15 @@ export const make = Effect.gen(function* () { | ... | @@ -403,15 +455,15 @@ export const make = Effect.gen(function* () { |
| 403 | const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( | 455 | const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( |
| 404 | windowStartMs: number, | 456 | windowStartMs: number, |
| 405 | settings: ServerSettingsValue, | 457 | settings: ServerSettingsValue, |
| | 458 | retentionCutoffMs: number, |
| 406 | ) { | 459 | ) { |
| 407 | // The home resolvers ask for `Path` themselves; satisfy them from the | 460 | // The home resolvers ask for `Path` themselves; satisfy them from the |
| 408 | // instance we already hold so the scan stays context-free. | 461 | // instance we already hold so the scan stays context-free. |
| 409 | const dirs = yield* resolveTranscriptDirs(settings).pipe( | 462 | const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( |
| 410 | Effect.provideService(Path.Path, path), | 463 | Effect.provideService(Path.Path, path), |
| 411 | ); | 464 | ); |
| 412 | const scanned: ScannedDir[] = []; | 465 | const scanned: ScannedDir[] = []; |
| 413 | for (const { provider, dir, fileName } of dirs) { | 466 | for (const { provider, dir, volumeId, fileName } of dirs) { |
| 414 | const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); | | |
| 415 | const exists = yield* fileSystem | 467 | const exists = yield* fileSystem |
| 416 | .exists(dir) | 468 | .exists(dir) |
| 417 | .pipe(Effect.catchCause(() => Effect.succeed(false))); | 469 | .pipe(Effect.catchCause(() => Effect.succeed(false))); |
| ... | @@ -481,11 +533,13 @@ export const make = Effect.gen(function* () { | ... | @@ -481,11 +533,13 @@ export const make = Effect.gen(function* () { |
| 481 | const windowStartMs = | 533 | const windowStartMs = |
| 482 | (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; | 534 | (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; |
| 483 | | 535 | |
| | 536 | const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; |
| | 537 | |
| 484 | // Pricing only matters once records are aggregated, so the rate table | 538 | // Pricing only matters once records are aggregated, so the rate table |
| 485 | // loads while transcripts stream instead of gating them: a cold rates | 539 | // loads while transcripts stream instead of gating them: a cold rates |
| 486 | // fetch on a slow network no longer delays the scan by its own timeout. | 540 | // fetch on a slow network no longer delays the scan by its own timeout. |
| 487 | const [, scannedDirs] = yield* Effect.all( | 541 | const [, scannedDirs] = yield* Effect.all( |
| 488 | [ensureRates(false), collectDirs(windowStartMs, settings)], | 542 | [ensureRates(false), collectDirs(windowStartMs, settings, retentionCutoffMs)], |
| 489 | { concurrency: 2 }, | 543 | { concurrency: 2 }, |
| 490 | ); | 544 | ); |
| 491 | | 545 | |
| ... | @@ -500,41 +554,54 @@ export const make = Effect.gen(function* () { | ... | @@ -500,41 +554,54 @@ export const make = Effect.gen(function* () { |
| 500 | }); | 554 | }); |
| 501 | | 555 | |
| 502 | const sources: UsageSource[] = []; | 556 | const sources: UsageSource[] = []; |
| 503 | const livePaths = new Set<string>(); | | |
| 504 | const walkedRoots: string[] = []; | | |
| 505 | | 557 | |
| 506 | for (const { provider, dir, volumeId, files } of scannedDirs) { | 558 | for (const { provider, dir, volumeId, files } of scannedDirs) { |
| 507 | if (files === null) { | 559 | const retainedFiles = [...(files ?? [])]; |
| 508 | sources.push({ | 560 | const livePaths = new Set(retainedFiles.map((file) => file.path)); |
| 509 | fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, | 561 | // Cleanup may remove transcripts, but the usage we already saved still |
| 510 | status: "missing", | 562 | // contributes to this source. Keep the normal aggregation and dedupe path. |
| 511 | scannedFiles: 0, | 563 | for (const [filePath, entry] of fileCache) { |
| 512 | skippedFiles: 0, | 564 | if ( |
| 513 | malformedRecords: 0, | 565 | entry.provider !== provider || |
| 514 | distinctSessions: 0, | 566 | entry.mtimeMs < retentionCutoffMs || |
| 515 | message: "No transcript directory on this environment.", | 567 | livePaths.has(filePath) || |
| 516 | }); | 568 | !isWithinDirectory(filePath, dir) |
| 517 | continue; | 569 | ) |
| | 570 | continue; |
| | 571 | retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] }); |
| 518 | } | 572 | } |
| 519 | | | |
| 520 | walkedRoots.push(dir); | | |
| 521 | let scannedFiles = 0; | 573 | let scannedFiles = 0; |
| 522 | let skippedFiles = 0; | 574 | let skippedFiles = 0; |
| 523 | // Distinct per directory. Buckets carry per-cell session counts, but a | 575 | // Distinct per directory. Buckets carry per-cell session counts, but a |
| 524 | // session spans days and models, so clients total this figure instead. | 576 | // session spans days and models, so clients total this figure instead. |
| 525 | const sessionIds = new Set<string>(); | 577 | const sessionIds = new Set<string>(); |
| 526 | | 578 | |
| 527 | for (const file of files) { | 579 | for (const file of retainedFiles) { |
| 528 | livePaths.add(file.path); | | |
| 529 | if (file.records.length === 0) { | 580 | if (file.records.length === 0) { |
| 530 | skippedFiles += 1; | 581 | skippedFiles += 1; |
| 531 | continue; | 582 | continue; |
| 532 | } | 583 | } |
| 533 | scannedFiles += 1; | 584 | scannedFiles += 1; |
| | 585 | const codexEventOccurrences = new Map<string, number>(); |
| 534 | for (const record of file.records) { | 586 | for (const record of file.records) { |
| 535 | // Only sessions that contributed in-window count: the mtime slack | 587 | let usageRecord = record; |
| 536 | // admits boundary files whose records fall outside the range. | 588 | if (record.provider === "codex" && record.sessionId.length > 0) { |
| 537 | if (aggregator.add(record) && record.sessionId.length > 0) { | 589 | // Match moved rollout copies without collapsing repeated equal events |
| | 590 | // within one rollout (timestamps can have only second precision). |
| | 591 | const key = encodeUsageRecordKey([ |
| | 592 | record.provider, |
| | 593 | record.sessionId, |
| | 594 | record.timestampMs, |
| | 595 | record.model, |
| | 596 | record.totals, |
| | 597 | ]); |
| | 598 | const occurrence = (codexEventOccurrences.get(key) ?? 0) + 1; |
| | 599 | codexEventOccurrences.set(key, occurrence); |
| | 600 | usageRecord = { ...record, dedupeKey: key + ":" + occurrence }; |
| | 601 | } |
| | 602 | // Only sessions contributing in-window count; the mtime slack can |
| | 603 | // admit boundary files whose records fall outside the range. |
| | 604 | if (aggregator.add(usageRecord) && record.sessionId.length > 0) { |
| 538 | sessionIds.add(record.sessionId); | 605 | sessionIds.add(record.sessionId); |
| 539 | } | 606 | } |
| 540 | } | 607 | } |
| ... | @@ -542,21 +609,17 @@ export const make = Effect.gen(function* () { | ... | @@ -542,21 +609,17 @@ export const make = Effect.gen(function* () { |
| 542 | | 609 | |
| 543 | sources.push({ | 610 | sources.push({ |
| 544 | fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, | 611 | fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, |
| 545 | status: "ok", | 612 | // Clients exclude missing sources, so saved records remain an available source. |
| | 613 | status: files === null && scannedFiles === 0 ? "missing" : "ok", |
| 546 | scannedFiles, | 614 | scannedFiles, |
| 547 | skippedFiles, | 615 | skippedFiles, |
| 548 | malformedRecords: 0, | 616 | malformedRecords: 0, |
| 549 | distinctSessions: sessionIds.size, | 617 | distinctSessions: sessionIds.size, |
| 550 | message: null, | 618 | message: files === null ? "No transcript directory on this environment." : null, |
| 551 | }); | 619 | }); |
| 552 | } | 620 | } |
| 553 | | 621 | |
| 554 | const pruned = pruneScanCache(fileCache, { | 622 | const pruned = pruneScanCache(fileCache, retentionCutoffMs); |
| 555 | livePaths, | | |
| 556 | walkedRoots, | | |
| 557 | windowStartMs, | | |
| 558 | retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, | | |
| 559 | }); | | |
| 560 | if (pruned > 0) cacheDirty = true; | 623 | if (pruned > 0) cacheDirty = true; |
| 561 | yield* persistScanCache(); | 624 | yield* persistScanCache(); |
| 562 | | 625 | |