Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 24 additions & 49 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -691,23 +691,10 @@ jobs:
needs: changes
if: github.event_name == 'workflow_dispatch'
runs-on: macos-latest
# The unsharded control for the sharded Linux lane: the only place the whole
# suite runs in one pool, so it is the place that catches what sharding
# hides. The flakes it keeps surfacing are timing, not logic, and the fix
# is the tests, not a fourth lane.
#
# Sized to measured work, not to a guess. At 30 this lane never once finished:
# every run was cancelled slightly past halfway and the cancellations were read
# as runner capacity for months (#4905). An authorized one-off measurement let it
# complete for the first time and it took 50m39s wall, Bun reporting 3034.18s over
# 26526 tests in 1343 files. 75 leaves roughly 24 minutes of headroom on that
# number, which is the growth room the suite needs without letting a genuine hang
# sit for an hour before anyone sees it.
#
# This bound is not a fix for anything the run reports. That first complete run
# surfaced four tests that exceed their own timeouts under shared-process pressure,
# tracked separately in #4997; raising this budget is what made them observable and
# must not be mistaken for resolving them.
# Unsharded full-membership control, one worker in sequential fresh batches.
# Long-lived Bun isolate pools repeatedly wedged while synchronously reaping
# child processes. Batching bounds that lifetime without retrying failures or
# excluding tests. This no longer claims one whole-suite process as evidence.
timeout-minutes: 75
steps:
- name: Checkout
Expand Down Expand Up @@ -744,37 +731,21 @@ jobs:
cd gui
bun run build

# This is the lane that exists to see what sharding hides, so it is the last place a
# repeated attempt belongs. One execution, whatever the outcome; the shared classifier
# decides which message is printed, never whether the leg fails.
- name: Test
- name: Setup bounded batch utilities
run: |
# GitHub Actions starts bash `run:` blocks with `-e`. Disable
# errexit so a Bun crash reaches PIPESTATUS and the classifier below,
# instead of aborting the step before either can be read.
set +e
set -uo pipefail
# One shared classifier for every lane; see scripts/ci/bun-crash-signatures.sh.
source scripts/ci/bun-crash-signatures.sh
suite_log="$(mktemp -t ocx-macos-suite.XXXXXX)"
# --timeout: Bun's default 5s per-test ceiling is the recurring flake
# class on this loaded shared runner (real retry windows + server
# round-trips exceed 5s under contention; a 10s-floor in-test
# watchdog fired at 10.16s there). 60s keeps hangs bounded (the 30m
# job timeout is the outer backstop) while removing the timing
# flakes — assertions are untouched. Pairs with the 30s CI floor in
# tests/helpers/ci-watchdog.ts.
bun test --isolate --timeout 60000 tests 2>&1 | tee "$suite_log"
suite_status="${PIPESTATUS[0]}"
if [ "$suite_status" -eq 0 ]; then
exit 0
fi
if is_bun_runtime_crash "$suite_status" "$suite_log"; then
echo "::error::Bun runtime crash in the macOS control suite (exit ${suite_status}); a crash is process death, not a test result, and it fails this leg on the first occurrence."
else
echo "::error::macOS control suite failed (exit ${suite_status})."
fi
exit "$suite_status"
brew list coreutils >/dev/null 2>&1 || brew install coreutils
echo "$(brew --prefix coreutils)/libexec/gnubin" >> "$GITHUB_PATH"

- name: Test in unsharded fresh-process batches
env:
TEST_SHARD: 1/1
BUN_TEST_FILE_SCOPE: all
BUN_TEST_BATCH_SIZE: '12'
BUN_TEST_PARALLEL: '1'
BUN_TEST_BATCH_TIMEOUT_SECONDS: '300'
OCX_TEST_NO_QUEUE: '1'
OCX_TEST_FULL_SUITE: '1'
run: bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD"

- name: CLI help smoke
run: bun run src/cli/index.ts help
Expand Down Expand Up @@ -1180,7 +1151,9 @@ jobs:
toolchain: stable

- name: Test MenuBarCore
run: bun run test:macos
run: |
bun run test:macos
swift run --package-path app NativeTrayTests

- name: Build dashboard
run: bun run build:gui
Expand All @@ -1200,7 +1173,7 @@ jobs:
# build has no business holding the release key. Updater artifacts are therefore off
# here and the signing path stays in release.yml, which already reads the secret and
# refuses to publish a manifest when it is absent.
run: bunx tauri build --ci --bundles app --config '{"bundle":{"createUpdaterArtifacts":false}}'
run: bunx tauri build --ci --bundles app --config '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"signingIdentity":"-"}}}'

- name: Verify WidgetKit appex and desktop app
run: |
Expand All @@ -1214,6 +1187,8 @@ jobs:
test -x "$app/Contents/MacOS/$executable"
test -x "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget"
test -x "$app/Contents/MacOS/ocx"
# Verify actual entitlements and execute the signed Bun sidecar as well as the seal.
bash desktop/scripts/verify-macos-runtime.sh "$app"
codesign -dv "$app/Contents/PlugIns/OpenCodexWidget.appex"
# The widget is only offered in the gallery when its bundle is actually linked in, and
# nothing else here would notice its absence: the appex builds, signs and registers
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,7 @@ jobs:
# PREREQUISITE: configure the Trusted Publisher for this repo + workflow on npmjs.com — possible
# only AFTER the package's first version exists (do the first publish locally, see the runbook).
- name: Preflight release metadata
id: metadata
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ inputs.version }}
Expand Down Expand Up @@ -792,6 +793,12 @@ jobs:
fi
if npm view "${pkg_name}@${RELEASE_VERSION}" version >/dev/null 2>&1; then
if [ "$RESUME" = "true" ]; then
resume_git_head="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" gitHead --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000)" || {
echo "::error::Cannot verify the existing npm package source; resume refused"
exit 1
}
bun scripts/verify-release-resume.ts "$GITHUB_SHA" "$resume_git_head"
echo "resume_sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
echo "::notice::${pkg_name}@${RELEASE_VERSION} is acknowledged on npm; resuming after the recorded partial publication"
elif [ "$dry_run" = "true" ]; then
echo "::notice::${pkg_name}@${RELEASE_VERSION} already exists on npm; dry-run only"
Expand Down Expand Up @@ -846,10 +853,15 @@ jobs:
NPM_DIST_TAG: ${{ inputs.tag }}
RESUME: ${{ inputs.resume-after-npm-publish }}
RELEASE_VERSION: ${{ inputs.version }}
VERIFIED_RESUME_SHA: ${{ steps.metadata.outputs.resume_sha }}
run: |
set -euo pipefail
pkg_name="$(node -p "require('./package.json').name")"
if [ "$RESUME" = "true" ]; then
if [ -z "$VERIFIED_RESUME_SHA" ] || [ "$VERIFIED_RESUME_SHA" != "$GITHUB_SHA" ]; then
echo "::error::Resume has no matching registry source verification; publication remains unacknowledged"
exit 1
fi
# npm publication was acknowledged by the earlier run and confirmed by the
# preflight above; completing the GitHub side must never republish.
echo "::notice::RESUME — npm publish skipped; publication already acknowledged"
Expand Down
4 changes: 4 additions & 0 deletions app/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ let package = Package(
name: "OpenCodexWidget",
platforms: [.macOS(.v14)],
products: [
.library(name: "NativeTray", type: .static, targets: ["NativeTray"]),
.executable(name: "NativeTrayTests", targets: ["NativeTrayTests"]),
.executable(name: "OpenCodexWidget", targets: ["OpenCodexWidget"]),
.executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]),
],
targets: [
.target(name: "NativeTray", path: "Sources/NativeTray"),
.executableTarget(name: "NativeTrayTests", dependencies: ["NativeTray"], path: "Sources/NativeTrayTests"),
.target(name: "MenuBarCore", path: "Sources/MenuBarCore"),
.executableTarget(
name: "OpenCodexWidget",
Expand Down
48 changes: 48 additions & 0 deletions app/Sources/MenuBarCore/CompanionUsage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation

public extension UsageReport {
/// Unknown folded attribution cannot be safely redistributed after a display filter.
func filteredSummary(_ settings: CompanionSettings) -> UsageSummary? {
if settings.models == nil && settings.hiddenProviders.isEmpty { return summary }
guard summary != nil else { return nil }
let emptySelection = settings.models?.isEmpty == true
let rows: [UsageModelRow]
if emptySelection { rows = [] }
else {
guard let models, models.allSatisfy({ row in
guard let provider = row.provider, let model = row.model else { return false }
return !provider.isEmpty && !model.isEmpty && !(provider == "other" && model == "other")
}) else { return nil }
let hidden = Set(settings.hiddenProviders)
let selected = settings.models.map(Set.init)
rows = models.filter { row in
!hidden.contains(row.provider!) && (selected == nil
|| selected!.contains("\(row.provider!)/\(row.model!)") || selected!.contains(row.model!))
}
}
func sum(_ key: KeyPath<UsageModelRow, Int?>) -> Int? {
guard !rows.isEmpty else { return nil }
var total = 0
for row in rows {
guard let value = row[keyPath: key], value >= 0 else { return nil }
let next = total.addingReportingOverflow(value)
guard !next.overflow else { return nil }
total = next.partialValue
}
return total
}
var cost: Double? = rows.isEmpty ? nil : 0
for row in rows {
guard let value = row.estimatedCostUsd, value.isFinite, value >= 0, let previous = cost,
(previous + value).isFinite else { cost = nil; break }
cost = previous + value
}
let requests = sum(\.requests), measured = sum(\.measuredRequests)
let coverage = requests.flatMap { count in
measured.flatMap { count > 0 && $0 <= count ? Double($0) / Double(count) : nil }
}
return UsageSummary(requests: requests, measuredRequests: measured, estimatedRequests: sum(\.estimatedRequests),
totalTokens: sum(\.totalTokens), inputTokens: sum(\.inputTokens), outputTokens: sum(\.outputTokens),
estimatedCostUsd: cost, coverageRatio: coverage)
}
}
3 changes: 2 additions & 1 deletion app/Sources/MenuBarCore/MenuBarTitle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ public enum MenuBarTitle {
today: UsageReport?,
quotas: [NormalizedQuota]
) -> String? {
let summary = today?.summary
let summary = today?.filteredSummary(settings)
let quotas = quotas.filter { !settings.hiddenProviders.contains($0.provider) }
let values: [String: String] = [
"requests": Format.count(summary?.requests),
"totalTokens": Format.tokens(summary?.totalTokens),
Expand Down
4 changes: 3 additions & 1 deletion app/Sources/MenuBarCore/ProxyClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ public actor ProxyClient {
if let models = settings.models, !models.isEmpty {
query.append(URLQueryItem(name: "models", value: models.joined(separator: ",")))
}
return try await get("api/usage/timeline", query: query)
query.append(contentsOf: settings.hiddenProviders.map { URLQueryItem(name: "hiddenProvider", value: $0) })
let timeline: UsageTimeline = try await get("api/usage/timeline", query: query)
return timeline.projected(settings)
}

public func quotas() async throws -> [QuotaReport] {
Expand Down
4 changes: 4 additions & 0 deletions app/Sources/MenuBarCore/ProxyModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ public struct UsageModelRow: Decodable, Equatable, Sendable {
public let provider: String?
public let model: String?
public let requests: Int?
public let measuredRequests: Int?
public let estimatedRequests: Int?
public let inputTokens: Int?
public let outputTokens: Int?
public let totalTokens: Int?
public let estimatedCostUsd: Double?
}
Expand Down
2 changes: 1 addition & 1 deletion app/Sources/MenuBarCore/ProxySnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ public struct ProxySnapshot: Equatable, Sendable {

/// One normalized row per provider for the compact quota list.
public var quotaRows: [NormalizedQuota] {
quotas.map { $0.normalized() }
quotas.filter { !settings.hiddenProviders.contains($0.provider) }.map { $0.normalized() }
}

public var visibleProviders: [ProviderSummary] {
Expand Down
69 changes: 69 additions & 0 deletions app/Sources/MenuBarCore/UsageTimeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ public struct TimelineSeries: Decodable, Equatable, Sendable {
public let points: [Double]
}

public struct TimelineAppliedFilters: Decodable, Equatable, Sendable {
public let models: [String]?
public let hiddenProviders: [String]
private enum CodingKeys: String, CodingKey { case models, hiddenProviders }
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
guard values.contains(.models) else {
throw DecodingError.keyNotFound(CodingKeys.models, .init(codingPath: decoder.codingPath, debugDescription: "Missing model filter"))
}
models = try values.decodeIfPresent([String].self, forKey: .models)
hiddenProviders = try values.decode([String].self, forKey: .hiddenProviders)
guard (models?.count ?? 0) <= 100, hiddenProviders.count <= 100 else {
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Filter bound exceeded"))
}
}
}

public struct UsageTimeline: Decodable, Equatable, Sendable {
public let start: Double
public let end: Double
Expand All @@ -21,6 +38,7 @@ public struct UsageTimeline: Decodable, Equatable, Sendable {
public let availableModels: [String]
public let missingMeasurements: Int
public let truncated: Bool?
public let appliedFilters: TimelineAppliedFilters?

public var maxPoint: Double {
series.flatMap(\.points).max() ?? 0
Expand All @@ -37,3 +55,54 @@ public struct UsageTimeline: Decodable, Equatable, Sendable {
series.allSatisfy { $0.total == 0 }
}
}

public extension UsageTimeline {
private enum CodingKeys: String, CodingKey {
case start, end, bucketSeconds, buckets, metric, aggregation, grouping
case series, availableModels, missingMeasurements, truncated, appliedFilters
}

init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
start = try values.decode(Double.self, forKey: .start)
end = try values.decode(Double.self, forKey: .end)
bucketSeconds = try values.decode(Int.self, forKey: .bucketSeconds)
buckets = try values.decode(Int.self, forKey: .buckets)
metric = try values.decode(String.self, forKey: .metric)
aggregation = try values.decode(String.self, forKey: .aggregation)
grouping = try values.decode(String.self, forKey: .grouping)
series = try values.decode([TimelineSeries].self, forKey: .series)
availableModels = try values.decode([String].self, forKey: .availableModels)
missingMeasurements = try values.decode(Int.self, forKey: .missingMeasurements)
truncated = try values.decodeIfPresent(Bool.self, forKey: .truncated)
// Optional metadata cannot discard valid chart data. An unusable receipt
// takes the same conservative projection path as an older server.
appliedFilters = try? values.decode(TimelineAppliedFilters.self, forKey: .appliedFilters)
}

func projected(_ settings: CompanionSettings) -> UsageTimeline {
let identity: (String) -> Data = { Data($0.utf8) }
let hidden = Set(settings.hiddenProviders.map(identity))
let models = settings.models.map { Set($0.map(identity)) }
let emptySelection = models?.isEmpty == true
let active = !hidden.isEmpty || models != nil
let matches = appliedFilters.map { receipt in
Set(receipt.hiddenProviders.map(identity)) == hidden
&& receipt.models.map { Set($0.map(identity)) } == models
} ?? false
let visible = emptySelection ? [] : series.filter { row in
if row.id == "other", row.provider.isEmpty { return !active || matches }
return !hidden.contains(identity(row.provider))
&& (models == nil || models!.contains(identity("\(row.provider)/\(row.model)")) || models!.contains(identity(row.model)))
}
let available = availableModels.filter { id in
guard let slash = id.firstIndex(of: "/") else { return true }
return !hidden.contains(identity(String(id[..<slash])))
}
let uncertain = !emptySelection && (active || appliedFilters != nil) && !matches
return UsageTimeline(start: start, end: end, bucketSeconds: bucketSeconds, buckets: buckets,
metric: metric, aggregation: aggregation, grouping: grouping, series: visible,
availableModels: available, missingMeasurements: missingMeasurements,
truncated: uncertain ? true : truncated, appliedFilters: appliedFilters)
}
}
21 changes: 13 additions & 8 deletions app/Sources/MenuBarCore/WidgetSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,14 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
public let bucketSeconds: Int
public let style: String
public let series: [Series]
public let incomplete: Bool?

public init(start: Double, bucketSeconds: Int, style: String, series: [Series]) {
public init(start: Double, bucketSeconds: Int, style: String, series: [Series], incomplete: Bool? = nil) {
self.start = start
self.bucketSeconds = bucketSeconds
self.style = style
self.series = series
self.incomplete = incomplete
}

public struct Series: Codable, Equatable, Sendable {
Expand Down Expand Up @@ -95,18 +97,13 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
case .degraded: state = "degraded"
}
let report = snapshot.today ?? snapshot.usage
let today = report?.summary.map {
let today = report?.filteredSummary(snapshot.settings).map {
Today(requests: $0.requests, totalTokens: $0.totalTokens, estimatedCostUsd: $0.estimatedCostUsd)
}
let quotas = snapshot.quotaRows.map {
Quota(providerLabel: $0.providerLabel, windowLabel: $0.windowLabel, percent: $0.percent, resetAt: $0.resetAt?.timeIntervalSince1970)
}
let chart = snapshot.timeline.map {
Chart(
start: $0.start, bucketSeconds: $0.bucketSeconds, style: snapshot.settings.chartStyle.rawValue,
series: Array($0.series.prefix(6)).map { Chart.Series(id: $0.id, points: $0.points) }
)
}
let chart = snapshot.timeline?.projected(snapshot.settings).mapChart(style: snapshot.settings.chartStyle.rawValue)
return WidgetSnapshot(
schemaVersion: 1, generatedAt: now.timeIntervalSince1970,
state: state, stateTitle: snapshot.state.title, detail: snapshot.state.detail,
Expand Down Expand Up @@ -188,3 +185,11 @@ private extension WidgetSnapshot {
)
}
}

private extension UsageTimeline {
func mapChart(style: String) -> WidgetSnapshot.Chart {
WidgetSnapshot.Chart(start: start, bucketSeconds: bucketSeconds, style: style,
series: Array(series.prefix(6)).map { .init(id: $0.id, points: $0.points) },
incomplete: truncated == true || missingMeasurements > 0 ? true : nil)
}
}
Loading
Loading