diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh index ee0dc06..6df7abb 100755 --- a/mac/Scripts/package-app.sh +++ b/mac/Scripts/package-app.sh @@ -9,6 +9,8 @@ set -euo pipefail VERSION="${1:-dev}" +ASSET_VERSION="${VERSION#mac-}" +BUNDLE_VERSION="${ASSET_VERSION#v}" BUNDLE_NAME="CodeBurnMenubar.app" BUNDLE_ID="org.agentseal.codeburn-menubar" EXECUTABLE_NAME="CodeBurnMenubar" @@ -66,9 +68,9 @@ cat > "${BUNDLE}/Contents/Info.plist" <CFBundlePackageType APPL CFBundleShortVersionString - ${VERSION} + ${BUNDLE_VERSION} CFBundleVersion - ${VERSION} + ${BUNDLE_VERSION} LSMinimumSystemVersion ${MIN_MACOS} LSUIElement @@ -93,7 +95,7 @@ echo "▸ Ad-hoc signing..." codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)" -ZIP_NAME="CodeBurnMenubar-${VERSION}.zip" +ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip" ZIP_PATH="${DIST_DIR}/${ZIP_NAME}" echo "▸ Packaging ${ZIP_NAME}..." (cd "${DIST_DIR}" && COPYFILE_DISABLE=1 /usr/bin/ditto -c -k --norsrc --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}") diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index ec5fdfa..94bb1c2 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -2,6 +2,7 @@ import Foundation import Observation private let cacheTTLSeconds: TimeInterval = 30 +private let interactiveRefreshResetSeconds: TimeInterval = 120 struct CachedPayload { let payload: MenubarPayload @@ -51,6 +52,7 @@ final class AppStore { private var cache: [PayloadCacheKey: CachedPayload] = [:] private var cacheDate: String = "" private var switchTask: Task? + private var payloadRefreshGeneration: UInt64 = 0 /// Tracks the last successful fetch timestamp per key for stuck-loading /// diagnostics. NOT used for cache-freshness logic — `CachedPayload.fetchedAt` /// is authoritative there. This map persists across cache wipes (day @@ -87,6 +89,44 @@ final class AppStore { cache[currentKey] != nil } + var hasStaleLoading: Bool { + let now = Date() + return loadingStartedAtByKey.values.contains { + now.timeIntervalSince($0) > loadingWatchdogSeconds + } + } + + var hasStaleInteractivePayload: Bool { + staleInteractivePayloadAgeSeconds != nil + } + + var shouldResetInteractiveRefreshPipeline: Bool { + hasStaleLoading || hasStaleInteractivePayload + } + + var staleInteractivePayloadAgeSeconds: Int? { + let keys = Set([ + currentKey, + PayloadCacheKey(period: .today, provider: .all), + PayloadCacheKey(period: selectedPeriod, provider: .all), + ]) + let staleAges = keys.compactMap { key -> TimeInterval? in + guard let cached = cache[key] else { return nil } + let age = Date().timeIntervalSince(cached.fetchedAt) + return age > interactiveRefreshResetSeconds ? age : nil + } + return staleAges.max().map(Int.init) + } + + var needsInteractivePayloadRefresh: Bool { + let todayKey = PayloadCacheKey(period: .today, provider: .all) + let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all) + return cache[currentKey]?.isFresh != true || + cache[todayKey]?.isFresh != true || + cache[periodAllKey]?.isFresh != true || + hasStaleLoading + } + /// True if any cached payload reports at least one provider. Used to keep the /// AgentTabStrip visible across period/provider switches even when the current /// key's payload is briefly empty (e.g. immediately after a `switchTo` and @@ -95,6 +135,12 @@ final class AppStore { cache.values.contains { !$0.payload.current.providers.isEmpty } } +#if DEBUG + func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, fetchedAt: Date) { + cache[PayloadCacheKey(period: period, provider: provider)] = CachedPayload(payload: payload, fetchedAt: fetchedAt) + } +#endif + var findingsCount: Int { payload.optimize.findingCount } @@ -135,6 +181,7 @@ final class AppStore { private var inFlightKeys: Set = [] func resetLoadingState() { + payloadRefreshGeneration &+= 1 loadingCountsByKey.removeAll() loadingStartedAtByKey.removeAll() inFlightKeys.removeAll() @@ -161,6 +208,7 @@ final class AppStore { } guard !staleEntries.isEmpty else { return false } + payloadRefreshGeneration &+= 1 for (key, started) in staleEntries { NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing", Int(now.timeIntervalSince(started)), key.period.rawValue, key.provider.rawValue) @@ -196,8 +244,15 @@ final class AppStore { formatter.dateFormat = "yyyy-MM-dd" let today = formatter.string(from: Date()) if cacheDate != today { + payloadRefreshGeneration &+= 1 cache.removeAll() + loadingCountsByKey.removeAll() + loadingStartedAtByKey.removeAll() + inFlightKeys.removeAll() + attemptedKeys.removeAll() + lastErrorByKey.removeAll() cacheDate = today + NSLog("CodeBurn: reset menubar payload cache for new day %@", today) } } @@ -209,6 +264,7 @@ final class AppStore { invalidateStaleDayCache() let key = currentKey let cacheDateAtStart = cacheDate + let generationAtStart = payloadRefreshGeneration if !force, cache[key]?.isFresh == true { return } if !force, inFlightKeys.contains(key) { return } inFlightKeys.insert(key) @@ -237,6 +293,10 @@ final class AppStore { } do { let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize) + if generationAtStart != payloadRefreshGeneration { + NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch") + return + } if Task.isCancelled { // Distinguish cancellation (user switched tabs mid-fetch) from // the silent-no-result path. Without this log, a cancelled @@ -263,6 +323,7 @@ final class AppStore { do { let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false) guard !Task.isCancelled else { return } + if generationAtStart != payloadRefreshGeneration { return } if cacheDate != cacheDateAtStart { return } cache[key] = CachedPayload(payload: fallback, fetchedAt: Date()) lastSuccessByKey[key] = Date() @@ -288,8 +349,13 @@ final class AppStore { func refreshQuietly(period: Period) async { invalidateStaleDayCache() let cacheDateAtStart = cacheDate + let generationAtStart = payloadRefreshGeneration do { let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false) + if generationAtStart != payloadRefreshGeneration { + NSLog("CodeBurn: dropping quiet fetch result for \(period.rawValue) — refresh pipeline reset mid-fetch") + return + } // Same day-rollover guard as refresh(): drop yesterday's payload if // the calendar rolled over during the fetch. if cacheDate != cacheDateAtStart { return } diff --git a/mac/Sources/CodeBurnMenubar/AppVersion.swift b/mac/Sources/CodeBurnMenubar/AppVersion.swift new file mode 100644 index 0000000..c5ee14a --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/AppVersion.swift @@ -0,0 +1,43 @@ +import Foundation + +enum AppVersion { + static var bundleShortVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + } + + static var bundleBuildVersion: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + } + + static var normalizedBundleShortVersion: String { + normalize(bundleShortVersion) + } + + static var normalizedBundleBuildVersion: String { + normalize(bundleBuildVersion) + } + + static var displayBundleShortVersion: String { + display(bundleShortVersion) + } + + static func normalize(_ version: String) -> String { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("mac-v") { + return String(trimmed.dropFirst(5)) + } + if trimmed.lowercased().hasPrefix("v") { + return String(trimmed.dropFirst()) + } + return trimmed + } + + static func display(_ version: String) -> String { + let normalized = normalize(version) + guard !normalized.isEmpty else { return "v?" } + if normalized == "?" || normalized == "dev" || normalized == "dev-preview" || normalized == "—" { + return normalized + } + return "v\(normalized)" + } +} diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index a58d044..7daccb2 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -6,6 +6,8 @@ private let refreshIntervalSeconds: UInt64 = 30 private let nanosPerSecond: UInt64 = 1_000_000_000 private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond private let forceRefreshWatchdogSeconds: TimeInterval = 90 +private let refreshLoopWatchdogSeconds: TimeInterval = 90 +private let refreshRateLimitSeconds: TimeInterval = 5 private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30 private let statusItemWidth: CGFloat = NSStatusItem.variableLength private let popoverWidth: CGFloat = 360 @@ -42,6 +44,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private var forceRefreshGeneration: UInt64 = 0 private var manualRefreshTask: Task? private var manualRefreshGeneration: UInt64 = 0 + private var refreshLoopHeartbeatAt: Date = .distantPast func applicationWillFinishLaunching(_ notification: Notification) { // Set accessory policy before the app's focus chain forms. On macOS Tahoe @@ -94,30 +97,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { queue: .main ) { [weak self] _ in Task { @MainActor in - self?.forceRefreshTask?.cancel() - self?.forceRefreshTask = nil - self?.forceRefreshStartedAt = nil - self?.forceRefreshGeneration &+= 1 - self?.manualRefreshTask?.cancel() - self?.manualRefreshTask = nil - self?.manualRefreshGeneration &+= 1 - self?.store.resetLoadingState() - self?.refreshLoopTask?.cancel() - self?.refreshLoopTask = nil + self?.prepareRefreshPipelineForSleep() } } // didWakeNotification + screensDidWakeNotification can both fire on - // the same wake. forceRefresh has a 5-second rate-limit gate so the - // duplicate is squashed there. Restart the refresh loop too, since - // we cancelled it on willSleep. + // the same wake. forceRefreshTask squashes overlap; both notifications + // still bypass the short manual-click rate limit so a just-before-sleep + // refresh cannot block wake recovery. NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor in - self?.recoverRefreshPipelineAfterInterruption(resetLoading: true) + self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake") } } @@ -127,7 +121,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { queue: .main ) { [weak self] _ in Task { @MainActor in - self?.recoverRefreshPipelineAfterInterruption(resetLoading: true) + self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake") } } } @@ -139,21 +133,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { queue: .main ) { [weak self] _ in Task { @MainActor in - self?.recoverRefreshPipelineAfterInterruption(resetLoading: false) + self?.recoverRefreshPipelineAfterInterruption(resetLoading: false, reason: "launch agent") } } } - private func recoverRefreshPipelineAfterInterruption(resetLoading: Bool) { + private func prepareRefreshPipelineForSleep() { + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshStartedAt = nil + forceRefreshGeneration &+= 1 + manualRefreshTask?.cancel() + manualRefreshTask = nil + manualRefreshGeneration &+= 1 + store.resetLoadingState() + refreshLoopTask?.cancel() + refreshLoopTask = nil + refreshLoopHeartbeatAt = .distantPast + lastRefreshTime = .distantPast + } + + private func recoverRefreshPipelineAfterInterruption(resetLoading: Bool, clearCache: Bool = false, reason: String) { if resetLoading { - store.resetLoadingState() + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshStartedAt = nil + forceRefreshGeneration &+= 1 + manualRefreshTask?.cancel() + manualRefreshTask = nil + manualRefreshGeneration &+= 1 + store.resetRefreshState(clearCache: clearCache) } else { _ = store.clearStaleLoadingIfNeeded() } - if refreshLoopTask == nil { + let now = Date() + let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt) + if refreshLoopTask == nil || loopAge > refreshLoopWatchdogSeconds { + if refreshLoopTask != nil { + NSLog("CodeBurn: refresh loop stale for %ds after %@ — restarting", Int(loopAge), reason) + } + refreshLoopTask?.cancel() + refreshLoopTask = nil startRefreshLoop() } - forceRefresh() + forceRefresh(bypassRateLimit: true, forceQuota: true) } private func installLaunchAgentIfNeeded() { @@ -250,11 +273,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { return false } - private func forceRefresh() { + private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) { let now = Date() _ = clearStaleForceRefreshIfNeeded(now: now) guard forceRefreshTask == nil else { return } - guard now.timeIntervalSince(lastRefreshTime) > 5 else { return } + if !bypassRateLimit { + guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return } + } lastRefreshTime = now forceRefreshStartedAt = now forceRefreshGeneration &+= 1 @@ -262,9 +287,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { forceRefreshTask = Task { async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true) - async let today: Void = store.refreshQuietly(period: .today) - async let quotas: Bool = refreshLiveQuotaProgressIfDue() - _ = await (main, today, quotas) + async let quotas: Bool = refreshLiveQuotaProgressIfDue(force: forceQuota) + if store.selectedPeriod != .today || store.selectedProvider != .all { + await store.refreshQuietly(period: .today) + } + _ = await main refreshStatusButton() await MainActor.run { [weak self] in guard let self, self.forceRefreshGeneration == generation else { return } @@ -272,6 +299,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { self.forceRefreshStartedAt = nil self.lastRefreshTime = Date() } + _ = await quotas } } @@ -344,39 +372,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } + private func refreshPayloadForPopoverOpen() { + guard store.needsInteractivePayloadRefresh else { return } + let shouldResetPipeline = store.shouldResetInteractiveRefreshPipeline + if shouldResetPipeline, let age = store.staleInteractivePayloadAgeSeconds { + NSLog("CodeBurn: popover opened with %ds stale payload cache - resetting refresh pipeline", age) + } + recoverRefreshPipelineAfterInterruption( + resetLoading: shouldResetPipeline, + reason: "popover open" + ) + } + private func startRefreshLoop() { refreshLoopTask?.cancel() + refreshLoopHeartbeatAt = Date() + forceRefresh(bypassRateLimit: true, forceQuota: true) refreshLoopTask = Task { [weak self] in - // Provider refreshes only run when the user has explicitly connected. - // Each refresh is a no-op until its corresponding bootstrap flag is set. - if let self { - await self.refreshLiveQuotaProgressIfDue(force: true) - } while !Task.isCancelled { guard let self else { return } + self.refreshLoopHeartbeatAt = Date() let clearedStaleForceRefresh = self.clearStaleForceRefreshIfNeeded() let clearedStaleLoading = self.store.clearStaleLoadingIfNeeded() - // Skip the loop's tick if a wake / manual / distributed- - // notification refresh just ran. Without this gate, every - // wake produced two refreshes (forceRefresh from the wake - // observer plus the loop's natural tick). let sinceLast = Date().timeIntervalSince(self.lastRefreshTime) - if self.forceRefreshTask == nil && (clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= 5) { - if self.store.selectedPeriod != .today || self.store.selectedProvider != .all { - async let quiet: Void = self.store.refreshQuietly(period: .today) - async let main: Void = self.store.refresh(includeOptimize: false, force: true) - _ = await (quiet, main) - } else { - await self.store.refresh(includeOptimize: false, force: true) - } - self.lastRefreshTime = Date() - self.refreshStatusButton() + if clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= TimeInterval(refreshIntervalSeconds) { + self.forceRefresh(bypassRateLimit: true) } - // Cadence-driven live-quota refresh, anchored on LAST SUCCESS - // (not last attempt) so an intermittent failure doesn't reset - // the timer. Each provider has its own anchor so a Codex 429 - // doesn't delay a due Claude refresh. - await self.refreshLiveQuotaProgressIfDue() try? await Task.sleep(nanoseconds: refreshIntervalNanos) } } @@ -617,6 +638,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { window.collectionBehavior.insert(.canJoinAllSpaces) window.makeKeyAndOrderFront(nil) } + refreshPayloadForPopoverOpen() refreshLiveQuotaProgressForPopoverOpen() } } @@ -705,10 +727,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { alert.icon = codeburnAlertIcon() if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { alert.messageText = "Update Available" - alert.informativeText = "v\(latest) is available (you have v\(updateChecker.currentVersion)). Run:\n\ncodeburn menubar --force" + alert.informativeText = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion))). Run:\n\ncodeburn menubar --force" } else { alert.messageText = "Up to Date" - alert.informativeText = "You're on the latest version (v\(updateChecker.currentVersion))." + alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))." } alert.alertStyle = .informational alert.addButton(withTitle: "OK") diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift index ce575b6..955718f 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift @@ -16,14 +16,14 @@ final class UpdateChecker { var updateAvailable: Bool { guard let latest = latestVersion else { return false } let current = currentVersion - let normalizedLatest = latest.hasPrefix("v") ? String(latest.dropFirst()) : latest - let normalizedCurrent = current.hasPrefix("v") ? String(current.dropFirst()) : current + let normalizedLatest = AppVersion.normalize(latest) + let normalizedCurrent = AppVersion.normalize(current) guard !normalizedCurrent.isEmpty && normalizedCurrent != "dev" else { return false } return normalizedLatest.compare(normalizedCurrent, options: .numeric) == .orderedDescending } var currentVersion: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + AppVersion.normalizedBundleShortVersion } func checkIfNeeded() async { diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index fbf3dd9..6a38b1c 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -567,7 +567,7 @@ struct FooterBar: View { Spacer() - Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")") + Text(AppVersion.displayBundleShortVersion) .font(.system(size: 10, weight: .regular, design: .monospaced)) .foregroundStyle(.tertiary) diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index a4c3585..a317380 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -337,10 +337,8 @@ private struct CodexConnectionRow: View { // MARK: - About private struct AboutSettingsTab: View { - private let appVersion: String = - (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "—" - private let buildVersion: String = - (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "—" + private let appVersion: String = AppVersion.normalizedBundleShortVersion + private let buildVersion: String = AppVersion.normalizedBundleBuildVersion var body: some View { VStack(spacing: 14) { diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift new file mode 100644 index 0000000..0c7fb14 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +private func menubarPayload(cost: Double) -> MenubarPayload { + MenubarPayload( + generated: "test", + current: CurrentBlock( + label: "Today", + cost: cost, + calls: 1, + sessions: 1, + oneShotRate: nil, + inputTokens: 1, + outputTokens: 1, + cacheHitPercent: 0, + topActivities: [], + topModels: [], + providers: ["claude": cost] + ), + optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []), + history: HistoryBlock(daily: []) + ) +} + +@Suite("AppStore refresh recovery") +@MainActor +struct AppStoreRefreshRecoveryTests { + @Test("stale visible payload triggers hard recovery without clearing cache") + func stalePayloadTriggersHardRecoveryWithoutClearingCache() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 92.33), + period: .today, + provider: .all, + fetchedAt: Date().addingTimeInterval(-180) + ) + + #expect(store.todayPayload?.current.cost == 92.33) + #expect(store.needsInteractivePayloadRefresh) + #expect(store.hasStaleInteractivePayload) + #expect(store.shouldResetInteractiveRefreshPipeline) + + store.resetRefreshState(clearCache: false) + + #expect(store.todayPayload?.current.cost == 92.33) + } + + @Test("fresh visible payload does not trigger hard recovery") + func freshPayloadDoesNotTriggerHardRecovery() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 164.06), + period: .today, + provider: .all, + fetchedAt: Date() + ) + + #expect(!store.needsInteractivePayloadRefresh) + #expect(!store.hasStaleInteractivePayload) + #expect(!store.shouldResetInteractiveRefreshPipeline) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift b/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift new file mode 100644 index 0000000..898f5e0 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import CodeBurnMenubar + +@Suite("AppVersion") +struct AppVersionTests { + @Test("display avoids duplicate v prefix") + func displayAvoidsDuplicatePrefix() { + #expect(AppVersion.display("0.9.8") == "v0.9.8") + #expect(AppVersion.display("v0.9.8") == "v0.9.8") + #expect(AppVersion.display("mac-v0.9.8") == "v0.9.8") + } + + @Test("bundle metadata stores unprefixed semver") + func normalizeBundleVersion() { + #expect(AppVersion.normalize("v0.9.8") == "0.9.8") + #expect(AppVersion.normalize("mac-v0.9.8") == "0.9.8") + #expect(AppVersion.normalize("dev") == "dev") + } +} diff --git a/package.json b/package.json index 72dd6db..c24699d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ ], "scripts": { "bundle-litellm": "node scripts/bundle-litellm.mjs", - "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"require('fs').copyFileSync('src/cli.ts','dist/cli.js')\"", + "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"", "dev": "tsx src/cli.ts", "test": "vitest", "prepublishOnly": "npm run build" diff --git a/src/parser.ts b/src/parser.ts index 3bb602e..9ab75ce 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -5,6 +5,7 @@ import { calculateCost, getShortModelName } from './models.js' import { discoverAllSessions, getProvider } from './providers/index.js' import { flushCodexCache } from './codex-cache.js' import { flushAntigravityCache } from './providers/antigravity.js' +import { isSqliteBusyError } from './sqlite.js' import type { ParsedProviderCall } from './providers/types.js' import type { AssistantMessageContent, @@ -565,6 +566,19 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { } } +const warnedProviderReadFailures = new Set() + +function warnProviderReadFailureOnce(providerName: string, err: unknown): void { + const key = `${providerName}:sqlite-busy` + if (warnedProviderReadFailures.has(key)) return + warnedProviderReadFailures.add(key) + if (isSqliteBusyError(err)) { + process.stderr.write( + `codeburn: skipped ${providerName} data because its SQLite database is temporarily locked; will retry on the next refresh.\n` + ) + } +} + async function parseProviderSources( providerName: string, sources: Array<{ path: string; project: string }>, @@ -589,25 +603,33 @@ async function parseProviderSources( seenKeys, ) - for await (const call of parser.parse()) { - if (dateRange) { - if (!call.timestamp) continue - const ts = new Date(call.timestamp) - if (ts < dateRange.start || ts > dateRange.end) continue - } + try { + for await (const call of parser.parse()) { + if (dateRange) { + if (!call.timestamp) continue + const ts = new Date(call.timestamp) + if (ts < dateRange.start || ts > dateRange.end) continue + } - const turn = providerCallToTurn(call) - const classified = classifyTurn(turn) - const project = call.project ?? source.project - const key = `${providerName}:${call.sessionId}:${project}` + const turn = providerCallToTurn(call) + const classified = classifyTurn(turn) + const project = call.project ?? source.project + const key = `${providerName}:${call.sessionId}:${project}` - const existing = sessionMap.get(key) - if (existing) { - existing.turns.push(classified) - if (!existing.projectPath && call.projectPath) existing.projectPath = call.projectPath - } else { - sessionMap.set(key, { project, projectPath: call.projectPath, turns: [classified] }) + const existing = sessionMap.get(key) + if (existing) { + existing.turns.push(classified) + if (!existing.projectPath && call.projectPath) existing.projectPath = call.projectPath + } else { + sessionMap.set(key, { project, projectPath: call.projectPath, turns: [classified] }) + } } + } catch (err) { + if (isSqliteBusyError(err)) { + warnProviderReadFailureOnce(providerName, err) + continue + } + throw err } } } finally { diff --git a/src/providers/opencode.ts b/src/providers/opencode.ts index 5a2546f..6b0f8ed 100644 --- a/src/providers/opencode.ts +++ b/src/providers/opencode.ts @@ -4,7 +4,7 @@ import { homedir } from 'os' import { calculateCost, getShortModelName } from '../models.js' import { extractBashCommands } from '../bash-utils.js' -import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js' import type { Provider, SessionSource, @@ -126,7 +126,8 @@ function validateSchemaDetailed(db: SqliteDatabase): SchemaCheckResult { for (const table of required) { try { db.query<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM ${table} LIMIT 1`) - } catch { + } catch (err) { + if (isSqliteBusyError(err)) throw err missing.push(table) } } diff --git a/src/sqlite.ts b/src/sqlite.ts index 9242c63..3fb3c6a 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -16,6 +16,7 @@ export type SqliteDatabase = { type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => { prepare(sql: string): { all(...params: unknown[]): Row[] } + exec?(sql: string): void close(): void } @@ -97,12 +98,35 @@ export function getSqliteLoadError(): string { return loadError ?? 'SQLite driver not available' } +export function isSqliteBusyError(err: unknown): boolean { + const e = err as { code?: unknown; errcode?: unknown; errstr?: unknown; message?: unknown } | null + const code = typeof e?.code === 'string' ? e.code : '' + const errcode = typeof e?.errcode === 'number' ? e.errcode : null + const message = [ + typeof e?.message === 'string' ? e.message : '', + typeof e?.errstr === 'string' ? e.errstr : '', + ].join(' ') + + return ( + errcode === 5 || + errcode === 6 || + code === 'SQLITE_BUSY' || + code === 'SQLITE_LOCKED' || + /\bSQLITE_(BUSY|LOCKED)\b|database (?:is |table is )?locked/i.test(message) + ) +} + export function openDatabase(path: string): SqliteDatabase { if (!loadDriver() || DatabaseSync === null) { throw new Error(getSqliteLoadError()) } const db = new DatabaseSync(path, { readOnly: true }) + try { + db.exec?.('PRAGMA busy_timeout = 1000') + } catch { + // Best effort. Some Node sqlite builds may not expose exec on DatabaseSync. + } return { query(sql: string, params: unknown[] = []): T[] { diff --git a/tests/blob-to-text.test.ts b/tests/blob-to-text.test.ts index f54717e..aeb7ce3 100644 --- a/tests/blob-to-text.test.ts +++ b/tests/blob-to-text.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { blobToText } from '../src/sqlite.js' +import { blobToText, isSqliteBusyError } from '../src/sqlite.js' describe('blobToText', () => { it('returns empty string for null', () => { @@ -37,3 +37,17 @@ describe('blobToText', () => { expect(blobToText(new Uint8Array(0))).toBe('') }) }) + +describe('isSqliteBusyError', () => { + it('detects node:sqlite busy errors by errcode', () => { + expect(isSqliteBusyError({ code: 'ERR_SQLITE_ERROR', errcode: 5, errstr: 'database is locked' })).toBe(true) + }) + + it('detects sqlite locked messages', () => { + expect(isSqliteBusyError(new Error('SQLITE_LOCKED: database table is locked'))).toBe(true) + }) + + it('ignores unrelated sqlite errors', () => { + expect(isSqliteBusyError(new Error('no such table: session'))).toBe(false) + }) +})