mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
Merge pull request #315 from getagentseal/fix/menubar-version-prefix
Some checks failed
CI / semgrep (push) Has been cancelled
Some checks failed
CI / semgrep (push) Has been cancelled
Harden menubar refresh recovery
This commit is contained in:
commit
2ca92a97cf
14 changed files with 356 additions and 82 deletions
|
|
@ -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" <<PLIST
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${VERSION}</string>
|
||||
<string>${BUNDLE_VERSION}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${VERSION}</string>
|
||||
<string>${BUNDLE_VERSION}</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>${MIN_MACOS}</string>
|
||||
<key>LSUIElement</key>
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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<Void, Never>?
|
||||
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<PayloadCacheKey> = []
|
||||
|
||||
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 }
|
||||
|
|
|
|||
43
mac/Sources/CodeBurnMenubar/AppVersion.swift
Normal file
43
mac/Sources/CodeBurnMenubar/AppVersion.swift
Normal file
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Void, Never>?
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
19
mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift
Normal file
19
mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T extends Row = Row>(sql: string, params: unknown[] = []): T[] {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue