Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 58ccf84

Browse files
authored
Fix menubar per-provider performance (24s → 2s) and session cache safety (getagentseal#344)
Per-provider menubar calls now use loadDailyCache() instead of hydrateCache(), splitting history into cache-based and fallback paths. Fixes session cache wipe when scanProjectDirs/parseProviderSources receive empty dirs. Strips _dirty flag before session cache serialization to prevent unnecessary 132MB rewrites. Removes dead keychain code from both credential stores.
1 parent b0131f6 commit 58ccf84

5 files changed

Lines changed: 101 additions & 177 deletions

File tree

mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift

Lines changed: 0 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,7 @@ enum ClaudeCredentialStore {
3737
private static let maxCredentialBytes = 64 * 1024
3838

3939
/// Legacy local cache file. New writes use the macOS Keychain; this path is
40-
/// read once for migration and then removed.
4140
private static let cacheFilename = "claude-credentials.v1.json"
42-
private static let ourKeychainService = "org.agentseal.codeburn.menubar.claude.oauth.v1"
43-
private static let ourKeychainAccount = "default"
4441

4542
private static let lock = NSLock()
4643
private nonisolated(unsafe) static var memoryCache: CachedRecord?
@@ -279,13 +276,6 @@ enum ClaudeCredentialStore {
279276
}
280277

281278
private static func readOurCache() throws -> CredentialRecord? {
282-
// Migrate: if credentials exist in keychain from a previous build, move to file.
283-
if let keychainRecord = try? readOurKeychainCache() {
284-
try? writeOurFileCache(record: keychainRecord)
285-
deleteOurKeychainCache()
286-
return keychainRecord
287-
}
288-
289279
let url = cacheFileURL()
290280
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
291281
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
@@ -304,63 +294,10 @@ enum ClaudeCredentialStore {
304294
try data.write(to: url, options: [.atomic, .completeFileProtection])
305295
}
306296

307-
private static func readOurKeychainCache() throws -> CredentialRecord? {
308-
let query: [String: Any] = [
309-
kSecClass as String: kSecClassGenericPassword,
310-
kSecAttrService as String: ourKeychainService,
311-
kSecAttrAccount as String: ourKeychainAccount,
312-
kSecMatchLimit as String: kSecMatchLimitOne,
313-
kSecReturnData as String: true,
314-
]
315-
var result: CFTypeRef?
316-
let status = SecItemCopyMatching(query as CFDictionary, &result)
317-
if status == errSecItemNotFound { return nil }
318-
guard status == errSecSuccess, let data = result as? Data else {
319-
throw StoreError.keychainReadFailed(status)
320-
}
321-
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
322-
}
323-
324-
private static func writeOurKeychainCache(record: CredentialRecord) throws {
325-
let url = cacheFileURL()
326-
let data = try JSONEncoder().encode(record)
327-
let query: [String: Any] = [
328-
kSecClass as String: kSecClassGenericPassword,
329-
kSecAttrService as String: ourKeychainService,
330-
kSecAttrAccount as String: ourKeychainAccount,
331-
]
332-
let attributes: [String: Any] = [
333-
kSecValueData as String: data,
334-
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
335-
]
336-
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
337-
if status == errSecItemNotFound {
338-
var add = query
339-
add.merge(attributes) { _, new in new }
340-
let addStatus = SecItemAdd(add as CFDictionary, nil)
341-
guard addStatus == errSecSuccess else {
342-
throw StoreError.keychainWriteFailed(addStatus)
343-
}
344-
} else if status != errSecSuccess {
345-
throw StoreError.keychainWriteFailed(status)
346-
}
347-
try? FileManager.default.removeItem(at: url)
348-
}
349-
350297
private static func deleteOurCache() {
351-
deleteOurKeychainCache()
352298
try? FileManager.default.removeItem(at: cacheFileURL())
353299
}
354300

355-
private static func deleteOurKeychainCache() {
356-
let query: [String: Any] = [
357-
kSecClass as String: kSecClassGenericPassword,
358-
kSecAttrService as String: ourKeychainService,
359-
kSecAttrAccount as String: ourKeychainAccount,
360-
]
361-
SecItemDelete(query as CFDictionary)
362-
}
363-
364301
private static func cacheInMemory(_ record: CredentialRecord) {
365302
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) }
366303
}

mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift

Lines changed: 0 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ enum CodexCredentialStore {
1818
private static let maxCredentialBytes = 64 * 1024
1919

2020
private static let cacheFilename = "codex-credentials.v1.json"
21-
private static let ourKeychainService = "org.agentseal.codeburn.menubar.codex.oauth.v1"
22-
private static let ourKeychainAccount = "default"
2321

2422
private static let lock = NSLock()
2523
private nonisolated(unsafe) static var memoryCache: CachedRecord?
@@ -201,12 +199,6 @@ enum CodexCredentialStore {
201199
}
202200

203201
private static func readOurCache() throws -> CredentialRecord? {
204-
if let keychainRecord = try? readOurKeychainCache() {
205-
try? writeOurFileCache(record: keychainRecord)
206-
deleteOurKeychainCache()
207-
return keychainRecord
208-
}
209-
210202
let url = cacheFileURL()
211203
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
212204
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
@@ -225,63 +217,10 @@ enum CodexCredentialStore {
225217
try data.write(to: url, options: [.atomic, .completeFileProtection])
226218
}
227219

228-
private static func readOurKeychainCache() throws -> CredentialRecord? {
229-
let query: [String: Any] = [
230-
kSecClass as String: kSecClassGenericPassword,
231-
kSecAttrService as String: ourKeychainService,
232-
kSecAttrAccount as String: ourKeychainAccount,
233-
kSecMatchLimit as String: kSecMatchLimitOne,
234-
kSecReturnData as String: true,
235-
]
236-
var result: CFTypeRef?
237-
let status = SecItemCopyMatching(query as CFDictionary, &result)
238-
if status == errSecItemNotFound { return nil }
239-
guard status == errSecSuccess, let data = result as? Data else {
240-
throw StoreError.fileWriteFailed("keychain read failed with status \(status)")
241-
}
242-
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
243-
}
244-
245-
private static func writeOurKeychainCache(record: CredentialRecord) throws {
246-
let url = cacheFileURL()
247-
let data = try JSONEncoder().encode(record)
248-
let query: [String: Any] = [
249-
kSecClass as String: kSecClassGenericPassword,
250-
kSecAttrService as String: ourKeychainService,
251-
kSecAttrAccount as String: ourKeychainAccount,
252-
]
253-
let attributes: [String: Any] = [
254-
kSecValueData as String: data,
255-
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
256-
]
257-
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
258-
if status == errSecItemNotFound {
259-
var add = query
260-
add.merge(attributes) { _, new in new }
261-
let addStatus = SecItemAdd(add as CFDictionary, nil)
262-
guard addStatus == errSecSuccess else {
263-
throw StoreError.fileWriteFailed("keychain write failed with status \(addStatus)")
264-
}
265-
} else if status != errSecSuccess {
266-
throw StoreError.fileWriteFailed("keychain update failed with status \(status)")
267-
}
268-
try? FileManager.default.removeItem(at: url)
269-
}
270-
271220
private static func deleteOurCache() {
272-
deleteOurKeychainCache()
273221
try? FileManager.default.removeItem(at: cacheFileURL())
274222
}
275223

276-
private static func deleteOurKeychainCache() {
277-
let query: [String: Any] = [
278-
kSecClass as String: kSecClassGenericPassword,
279-
kSecAttrService as String: ourKeychainService,
280-
kSecAttrAccount as String: ourKeychainAccount,
281-
]
282-
SecItemDelete(query as CFDictionary)
283-
}
284-
285224
private static func cacheInMemory(_ record: CredentialRecord) {
286225
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) }
287226
}

src/main.ts

Lines changed: 83 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { convertCost } from './currency.js'
77
import { renderStatusBar } from './format.js'
88
import { type PeriodData, type ProviderCost } from './menubar-json.js'
99
import { buildMenubarPayload } from './menubar-json.js'
10-
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js'
10+
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js'
1111
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
1212
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
1313
import { aggregateModelEfficiency } from './model-efficiency.js'
@@ -444,7 +444,6 @@ program
444444
const rangeEndStr = toDateString(periodInfo.range.end)
445445
const isAllProviders = pf === 'all'
446446

447-
const cache = await hydrateCache()
448447
let todayAllProjects: ProjectSummary[] | null = null
449448
let todayAllDays: ReturnType<typeof aggregateProjectsIntoDays> | null = null
450449

@@ -462,44 +461,51 @@ program
462461
return todayAllDays
463462
}
464463

465-
// CURRENT PERIOD DATA
466-
// - .all provider: assemble from cache + today (fast)
467-
// - specific provider: parse the period range with provider filter (correct, but slower)
468464
let currentData: PeriodData
469465
let scanProjects: ProjectSummary[]
470466
let scanRange: DateRange
467+
let cache: DailyCache
468+
let todayProviderData: PeriodData | null = null
469+
let usedPerProviderCachePath = false
471470

472471
if (isAllProviders) {
473-
// Parse today's all-provider sessions once; historical data comes from cache to avoid
474-
// double-counting. Reusing the same parsed object is important for the menubar path:
475-
// large active sessions can OOM if this command retains multiple near-identical scans.
472+
cache = await hydrateCache()
476473
const todayProjects = await getTodayAllProjects()
477474
const todayDays = await getTodayAllDays()
478475
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
479476
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
480477
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
481478
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
482479
scanProjects = todayProjects
483-
scanRange = periodInfo.range
480+
scanRange = todayRange
484481
} else {
485-
// Per-provider: parse only today (fast), use cache for historical days.
486-
// The cache stores per-provider cost+calls per day, so we extract those
487-
// and combine with today's fully-parsed provider data.
488-
const todayProviderProjects = fp(await parseAllSessions(todayRange, pf))
489-
const todayData = buildPeriodData(periodInfo.label, todayProviderProjects)
490-
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
491-
let histCost = 0, histCalls = 0
492-
for (const d of historicalDays) {
493-
const prov = d.providers[pf]
494-
if (prov) { histCost += prov.cost; histCalls += prov.calls }
495-
}
496-
currentData = {
497-
...todayData,
498-
cost: todayData.cost + histCost,
499-
calls: todayData.calls + histCalls,
482+
cache = await loadDailyCache()
483+
const cacheIsCurrent = cache.lastComputedDate !== null
484+
&& cache.lastComputedDate >= yesterdayStr
485+
if (cacheIsCurrent && rangeStartStr < todayStr) {
486+
const todayProviderProjects = fp(await parseAllSessions(todayRange, pf))
487+
todayProviderData = buildPeriodData(periodInfo.label, todayProviderProjects)
488+
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
489+
let histCost = 0, histCalls = 0
490+
for (const d of historicalDays) {
491+
const prov = d.providers[pf]
492+
if (prov) { histCost += prov.cost; histCalls += prov.calls }
493+
}
494+
currentData = {
495+
...todayProviderData,
496+
cost: todayProviderData.cost + histCost,
497+
calls: todayProviderData.calls + histCalls,
498+
}
499+
scanProjects = todayProviderProjects
500+
scanRange = todayRange
501+
usedPerProviderCachePath = true
502+
} else {
503+
const fullProjects = fp(await parseAllSessions(periodInfo.range, pf))
504+
todayProviderData = buildPeriodData(periodInfo.label, fullProjects)
505+
currentData = todayProviderData
506+
scanProjects = fullProjects
507+
scanRange = periodInfo.range
500508
}
501-
scanProjects = todayProviderProjects
502-
scanRange = todayRange
503509
}
504510

505511
// PROVIDERS
@@ -538,9 +544,12 @@ program
538544
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
539545
const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
540546
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
541-
const fullHistory = [...allCacheDays, ...(await getTodayAllDays()).filter(d => d.date === todayStr)]
542-
const dailyHistory = fullHistory.map(d => {
543-
if (isAllProviders) {
547+
548+
let dailyHistory
549+
if (isAllProviders) {
550+
const todayDays = (await getTodayAllDays()).filter(d => d.date === todayStr)
551+
const fullHistory = [...allCacheDays, ...todayDays]
552+
dailyHistory = fullHistory.map(d => {
544553
const topModels = Object.entries(d.models)
545554
.filter(([name]) => name !== '<synthetic>')
546555
.sort(([, a], [, b]) => b.cost - a.cost)
@@ -562,19 +571,52 @@ program
562571
cacheWriteTokens: d.cacheWriteTokens,
563572
topModels,
564573
}
574+
})
575+
} else if (usedPerProviderCachePath) {
576+
const historyFromCache = allCacheDays.map(d => {
577+
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
578+
return {
579+
date: d.date,
580+
cost: prov.cost,
581+
calls: prov.calls,
582+
inputTokens: 0,
583+
outputTokens: 0,
584+
cacheReadTokens: 0,
585+
cacheWriteTokens: 0,
586+
topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[],
587+
}
588+
})
589+
const todayCost = todayProviderData!.cost
590+
const todayCalls = todayProviderData!.calls
591+
if (todayCost > 0 || todayCalls > 0) {
592+
historyFromCache.push({
593+
date: todayStr,
594+
cost: todayCost,
595+
calls: todayCalls,
596+
inputTokens: 0,
597+
outputTokens: 0,
598+
cacheReadTokens: 0,
599+
cacheWriteTokens: 0,
600+
topModels: [],
601+
})
565602
}
566-
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
567-
return {
568-
date: d.date,
569-
cost: prov.cost,
570-
calls: prov.calls,
571-
inputTokens: 0,
572-
outputTokens: 0,
573-
cacheReadTokens: 0,
574-
cacheWriteTokens: 0,
575-
topModels: [],
576-
}
577-
})
603+
dailyHistory = historyFromCache
604+
} else {
605+
const fallbackDays = aggregateProjectsIntoDays(scanProjects)
606+
dailyHistory = fallbackDays.map(d => {
607+
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
608+
return {
609+
date: d.date,
610+
cost: prov.cost,
611+
calls: prov.calls,
612+
inputTokens: 0,
613+
outputTokens: 0,
614+
cacheReadTokens: 0,
615+
cacheWriteTokens: 0,
616+
topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[],
617+
}
618+
})
619+
}
578620

579621
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
580622
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))

0 commit comments

Comments
 (0)