From a028c607f587d5950837a4dea452e1c3b0bc4167 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Fri, 22 May 2026 10:56:38 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Add Decodex app usage estimates","authority":"manual"} --- README.md | 9 +- apps/decodex-app/README.md | 18 +- .../Sources/DecodexApp/AccountPanelView.swift | 576 ++++++++++++++---- .../Sources/DecodexApp/AccountStore.swift | 1 + .../Sources/DecodexApp/DecodexAppBridge.swift | 11 +- .../Sources/DecodexApp/Models.swift | 95 ++- .../DecodexApp/OperatorSnapshotModels.swift | 2 +- apps/decodex/src/accounts.rs | 447 +++++++++++++- apps/decodex/src/app_bridge.rs | 21 +- docs/spec/runtime.md | 2 +- 10 files changed, 1034 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index 3bbf0c94..a336c4bf 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,12 @@ global selector; project configs do not pin specific accounts. Account display-n rerolls are also global Decodex state under `[codex.account_names.offsets]` in `~/.codex/decodex/config.toml` so the operator dashboard and Decodex App show the same privacy-preserving names. Client-only presentation preferences such as theme, sorting, -and whether identities are hidden remain local to each UI. To switch the account used -by the Codex CLI itself, run `decodex account use ` or use the Decodex App -row action; this overwrites `$CODEX_HOME/auth.json` or `~/.codex/auth.json` from the +and whether identities are hidden remain local to each UI. Usage probes keep bounded +seven-day account usage estimates in +`~/.codex/decodex/account-usage-history.jsonl`; the file stores daily percentage +snapshots for local display and no token material. To switch the account used by the +Codex CLI itself, run `decodex account use ` or use the Decodex App row +action; this overwrites `$CODEX_HOME/auth.json` or `~/.codex/auth.json` from the matching `accounts.jsonl` entry. `decodex diagnose --json` writes the local agent evidence index under diff --git a/apps/decodex-app/README.md b/apps/decodex-app/README.md index c3b47d3f..adc9311b 100644 --- a/apps/decodex-app/README.md +++ b/apps/decodex-app/README.md @@ -9,12 +9,14 @@ or the full operator dashboard. ## Scope -The first Decodex App release manages the shared Codex account pool through the local -Decodex server. On launch the app connects to an existing `decodex serve` on the -default local endpoint when one is available; otherwise it starts the bundled -`decodex serve --api-only` binary and talks to that server. App-started servers do not -poll registered projects or dispatch Linear work. The helper remains available for -interactive login flows that need streamed command output: +The first Decodex App release manages the shared Codex account pool through the +bundled Rust app helper so account UI stays on the same CLI-owned files even when a +long-running local `decodex serve` is older than the app bundle. On launch the app also +connects to an existing `decodex serve` on the default local endpoint when one is +available; otherwise it starts the bundled `decodex serve --api-only` binary for +operator snapshot and WebUI routes. App-started servers do not poll registered projects +or dispatch Linear work. The helper owns account operations and interactive login flows +that need streamed command output: - list accounts without printing token material - pin future Decodex runs to one account @@ -31,7 +33,9 @@ The app and operator dashboard share account-pool state through the Rust account stored accounts come from `~/.codex/decodex/accounts.jsonl`, run routing and account display-name offsets come from `~/.codex/decodex/config.toml`, and Codex CLI auth switching writes `auth.json`. Presentation-only choices such as local privacy -visibility remain client-local. +visibility remain client-local. Usage probes update the bounded seven-day local +estimate file at `~/.codex/decodex/account-usage-history.jsonl`; it stores daily +percentage snapshots for account-pool display and does not contain token material. ## Development diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index bd5af86b..31559e16 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -11,21 +11,22 @@ enum PanelFont { .system(size: size, weight: weight, design: design) } - static let headerIcon = text(13.8, weight: .semibold) - static let headerTitle = text(14.2, weight: .semibold) - static let headerSubtitle = text(10.6, weight: .medium) - static let emptyIcon = text(16.5, weight: .medium) - static let emptyTitle = text(11.8, weight: .semibold) - static let emptyBody = text(10.4, weight: .regular) - static let notice = text(10.1, weight: .regular) - static let summaryIcon = text(10, weight: .medium) - static let summaryTitle = text(9.8, weight: .medium) - static let summaryValue = text(11.3, weight: .semibold) - static let accountName = text(12.6, weight: .semibold) - static let accountDetail = text(10.4, weight: .medium) - static let usage = text(9.6, weight: .semibold) - static let usageMeta = text(8.9, weight: .medium) - static let iconButton = text(11, weight: .semibold) + static let headerIcon = text(14.1, weight: .semibold) + static let headerTitle = text(14.8, weight: .semibold) + static let headerSubtitle = text(11.1, weight: .medium) + static let emptyIcon = text(16.8, weight: .medium) + static let emptyTitle = text(12.2, weight: .semibold) + static let emptyBody = text(10.9, weight: .regular) + static let notice = text(10.6, weight: .regular) + static let summaryIcon = text(10.4, weight: .medium) + static let metricLabel = text(10.4, weight: .medium) + static let metricValue = text(11.9, weight: .semibold) + static let accountName = text(13.1, weight: .semibold) + static let accountDetail = text(10.9, weight: .medium) + static let usageLabel = text(10.4, weight: .medium) + static let usageValue = text(10.7, weight: .semibold) + static let tertiary = text(9.7, weight: .medium) + static let iconButton = text(11.2, weight: .semibold) } enum PanelPalette { @@ -55,26 +56,32 @@ enum PanelPalette { static func codexAccent(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.86, green: 0.89, blue: 0.94) - : Color(red: 0.22, green: 0.38, blue: 0.52) + ? Color(red: 0.82, green: 0.87, blue: 0.94) + : Color(red: 0.2, green: 0.36, blue: 0.52) } static func routeAccent(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.82, green: 0.87, blue: 0.94) - : Color(red: 0.18, green: 0.34, blue: 0.52) + ? Color(red: 0.68, green: 0.8, blue: 0.96) + : Color(red: 0.13, green: 0.32, blue: 0.56) + } + + static func landingAccent(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.74, green: 0.82, blue: 0.9) + : Color(red: 0.19, green: 0.34, blue: 0.46) } - static func usageMint(_ colorScheme: ColorScheme) -> Color { + static func capacityAccent(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.78, green: 0.84, blue: 0.9) - : Color(red: 0.28, green: 0.38, blue: 0.5) + ? Color(red: 0.72, green: 0.8, blue: 0.88) + : Color(red: 0.18, green: 0.34, blue: 0.48) } static func warning(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.88, green: 0.58, blue: 0.35) - : Color(red: 0.62, green: 0.36, blue: 0.14) + ? Color(red: 0.95, green: 0.68, blue: 0.38) + : Color(red: 0.62, green: 0.4, blue: 0.12) } static func fastModeAccent(_ colorScheme: ColorScheme) -> Color { @@ -85,8 +92,8 @@ enum PanelPalette { static func destructive(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 1, green: 0.42, blue: 0.45) - : Color(red: 0.72, green: 0.13, blue: 0.18) + ? Color(red: 0.98, green: 0.4, blue: 0.45) + : Color(red: 0.68, green: 0.1, blue: 0.16) } static func progressTrack(_ colorScheme: ColorScheme) -> Color { @@ -274,6 +281,10 @@ struct AccountPanelView: View { header accountSummary + if let usageEstimate = store.accountList?.usageEstimate { + AccountPoolUsageEstimateView(estimate: usageEstimate, accounts: store.accounts) + } + if let snapshot = store.operatorSnapshot, snapshot.shouldDisplayInPanel { OperatorStatusStripView( snapshot: snapshot, @@ -298,7 +309,7 @@ struct AccountPanelView: View { accountList } } - .frame(width: 310) + .frame(width: 322) .padding(9) .modernGlassSurface( cornerRadius: 18, @@ -576,7 +587,8 @@ struct AccountPanelView: View { private var headerSubtitle: String { let count = store.accounts.count let accountLabel = "\(count) account\(count == 1 ? "" : "s")" - return hasFixedSelection ? "\(accountLabel) / routed" : "\(accountLabel) / balanced" + let routeLabel = hasFixedSelection ? "Routed" : "Balanced" + return "\(accountLabel) · \(routeLabel)" } private var emailsHidden: Bool { @@ -592,11 +604,22 @@ struct AccountPanelView: View { } private func operatorRunCount(for account: CodexAccount) -> Int? { - store.operatorSnapshot?.runningCount(for: account) + guard let count = store.operatorSnapshot?.runningCount(for: account), count > 0 else { + return nil + } + + return count } private func accountRowHeight(for account: CodexAccount) -> CGFloat { - let base: CGFloat = account.hasUsageSummary ? 98 : 46 + let base: CGFloat + if account.hasUsageWindowSummary { + base = account.hasSevenDayUsageEstimate ? 120 : 102 + } else if account.hasSevenDayUsageEstimate { + base = 66 + } else { + base = 48 + } let runSignal: CGFloat = operatorRuns(for: account).isEmpty ? 0 : 22 return base + runSignal @@ -824,9 +847,9 @@ struct AccountRunSummaryView: View { if runs.count > 2 { Text("+\(runs.count - 2)") - .font(PanelFont.summaryTitle) + .font(PanelFont.metricLabel) .foregroundStyle(PanelPalette.secondaryText(colorScheme)) - .frame(height: 18) + .frame(height: 19) .padding(.horizontal, 6) .modernGlassSurface(cornerRadius: 9, depth: .control) } @@ -847,18 +870,18 @@ struct AccountRunChipView: View { .frame(width: 10) Text(run.compactTitle) - .font(PanelFont.summaryTitle) + .font(PanelFont.metricLabel) .foregroundStyle(PanelPalette.primaryText(colorScheme).opacity(0.92)) .lineLimit(1) .truncationMode(.middle) Text(run.compactDetail) - .font(PanelFont.summaryTitle) + .font(PanelFont.metricLabel) .foregroundStyle(PanelPalette.secondaryText(colorScheme)) .lineLimit(1) .truncationMode(.tail) } - .frame(height: 18) + .frame(height: 19) .frame(maxWidth: 132, alignment: .leading) .padding(.horizontal, 6) .modernGlassSurface(cornerRadius: 9, depth: .control) @@ -888,12 +911,193 @@ struct AccountRunChipView: View { } } +struct AccountPoolUsageEstimateView: View { + let estimate: AccountUsageEstimate + let accounts: [CodexAccount] + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 0) { + AccountPoolUsageMetricView( + title: "Pool used", + value: formatUsagePercent(estimate.totalUsedOfCapacityPercent), + tint: poolUsageTint + ) + + usageDivider + + AccountPoolUsageMetricView( + title: "Day Δ", + value: dayDeltaText, + tint: dayDeltaTint + ) + + usageDivider + + AccountPoolUsageMetricView( + title: "Daily avg", + value: formatDailyUsageRate(estimate.averageDailyPoolPercent), + tint: PanelPalette.secondaryText(colorScheme) + ) + } + .frame(height: 30) + + if estimate.accountEstimateCount < estimate.accountCount { + Text("\(estimate.accountEstimateCount)/\(estimate.accountCount) accounts measured") + .font(PanelFont.tertiary) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.72)) + .lineLimit(1) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .modernGlassSurface(cornerRadius: 10, depth: .section) + .accessibilityLabel(accessibilityLabel) + } + + private var usageDivider: some View { + Rectangle() + .fill(PanelPalette.separator(colorScheme).opacity(colorScheme == .dark ? 0.72 : 0.9)) + .frame(width: 0.5, height: 20) + } + + private var accessibilityLabel: String { + "Pool usage over \(estimate.windowDays) days: \(formatUsagePercent(estimate.totalUsedOfCapacityPercent)) used, daily change \(dayDeltaText), average \(formatUsagePercent(estimate.averageDailyPoolPercent)) per day" + } + + private var dayDeltaText: String { + guard let delta = dayDeltaPercentagePoints else { + return "-" + } + + return formatPercentagePointDelta(delta) + } + + private var poolUsageTint: Color { + let used = estimate.totalUsedOfCapacityPercent + if used >= 90 { + return PanelPalette.destructive(colorScheme) + } + if used >= 75 { + return PanelPalette.warning(colorScheme) + } + + return PanelPalette.routeAccent(colorScheme) + } + + private var dayDeltaTint: Color { + guard let delta = dayDeltaPercentagePoints else { + return PanelPalette.secondaryText(colorScheme) + } + if delta > 0.05 { + if estimate.totalUsedOfCapacityPercent >= 90 { + return PanelPalette.destructive(colorScheme) + } + if estimate.totalUsedOfCapacityPercent >= 75 { + return PanelPalette.warning(colorScheme) + } + + return PanelPalette.capacityAccent(colorScheme) + } + if delta < -0.05 { + return PanelPalette.secondaryText(colorScheme) + } + + return PanelPalette.secondaryText(colorScheme) + } + + private var dayDeltaPercentagePoints: Double? { + let measuredAccounts = accounts.filter { account in + account.sevenDayUsedPercent != nil + } + guard !measuredAccounts.isEmpty, estimate.totalCapacityPercent > 0 else { + return nil + } + + let latestDate = measuredAccounts + .flatMap(\.recentUsageRecords) + .map(\.date) + .max() + guard let latestDate else { + return estimate.totalUsedOfCapacityPercent + } + guard let previousDate = previousUsageDate(before: latestDate) else { + return estimate.totalUsedOfCapacityPercent + } + + let previousUsedPercent = measuredAccounts.reduce(0) { total, account in + total + (usageRecord(for: account, on: previousDate)?.usedPercent ?? 0) + } + let previousPoolPercent = + (Double(previousUsedPercent) / Double(estimate.totalCapacityPercent)) * 100 + + return estimate.totalUsedOfCapacityPercent - previousPoolPercent + } + + private func usageRecord( + for account: CodexAccount, + on date: String + ) -> AccountUsageRecord? { + account.recentUsageRecords + .filter { record in record.date == date } + .max { left, right in + left.checkedAtUnixEpoch < right.checkedAtUnixEpoch + } + } + + private func previousUsageDate(before value: String) -> String? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + let calendar = Calendar(identifier: .gregorian) + + guard let date = formatter.date(from: value), + let previousDate = calendar.date(byAdding: .day, value: -1, to: date) + else { + return nil + } + + return formatter.string(from: previousDate) + } +} + +struct AccountPoolUsageMetricView: View { + let title: String + let value: String + let tint: Color + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(PanelFont.metricLabel) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .lineLimit(1) + + Text(value) + .font(PanelFont.metricValue) + .foregroundStyle(tint.opacity(colorScheme == .dark ? 0.94 : 0.78)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.72) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 5) + } +} + struct AccountUsageSummaryView: View { let account: CodexAccount @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(spacing: 5) { + if account.hasSevenDayUsageEstimate { + AccountSevenDayUsageLineView(account: account) + } + if account.hasPrimaryUsageData { AccountUsageMeterView( label: account.windowLabel(seconds: account.primaryWindowSeconds), @@ -918,6 +1122,76 @@ struct AccountUsageSummaryView: View { } } +struct AccountSevenDayUsageLineView: View { + let account: CodexAccount + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 5) { + Image(systemName: "calendar") + .font(PanelFont.summaryIcon) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) + .frame(width: 10) + + Text("7d used") + .font(PanelFont.usageLabel) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .lineLimit(1) + + Text(usedText) + .font(PanelFont.usageValue) + .foregroundStyle(PanelPalette.primaryText(colorScheme).opacity(colorScheme == .dark ? 0.92 : 0.86)) + .monospacedDigit() + .lineLimit(1) + + if let recordDate { + Text(recordDate) + .font(PanelFont.tertiary) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.72)) + .monospacedDigit() + .lineLimit(1) + } + + Spacer(minLength: 4) + + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text("avg") + .font(PanelFont.usageLabel) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) + .lineLimit(1) + + Text(dailyAverageText) + .font(PanelFont.usageValue) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .monospacedDigit() + .lineLimit(1) + } + } + .frame(height: 16) + .accessibilityLabel("Seven day used \(usedText), daily average \(dailyAverageText)") + } + + private var usedText: String { + guard let used = account.sevenDayUsedPercent else { + return "-" + } + + return "\(used)%" + } + + private var dailyAverageText: String { + guard let average = account.sevenDayDailyAveragePercent else { + return "-" + } + + return formatDailyUsageRate(average) + } + + private var recordDate: String? { + account.recentUsageRecords.last.map { compactUsageDate($0.date) } + } +} + struct AccountUsageMeterView: View { let label: String let remainingPercent: Int? @@ -929,31 +1203,33 @@ struct AccountUsageMeterView: View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 5) { Text(label) - .frame(width: 42, alignment: .leading) + .font(PanelFont.usageLabel) + .frame(width: 28, alignment: .leading) .foregroundStyle(PanelPalette.secondaryText(colorScheme)) Text(remainingText) - .frame(width: 40, alignment: .leading) + .font(PanelFont.usageValue) + .frame(width: 62, alignment: .leading) .foregroundStyle(valueColor) .monospacedDigit() Spacer(minLength: 2) Text(resetDisplay.short) - .font(PanelFont.usageMeta) + .font(PanelFont.usageValue) .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(colorScheme == .dark ? 0.82 : 0.9)) .monospacedDigit() .lineLimit(1) if !resetDisplay.date.isEmpty { Text(resetDisplay.date) - .font(PanelFont.usageMeta) + .font(PanelFont.tertiary) .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(colorScheme == .dark ? 0.68 : 0.78)) .lineLimit(1) .truncationMode(.middle) } } - .frame(height: 12) + .frame(height: 14) GeometryReader { proxy in ZStack(alignment: .leading) { @@ -985,9 +1261,8 @@ struct AccountUsageMeterView: View { } .frame(height: 3.2) } - .font(PanelFont.usage) .lineLimit(1) - .frame(height: 20) + .frame(height: 22) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityLabel("\(label) remaining \(remainingText), \(resetDisplay.accessibility)") } @@ -997,7 +1272,7 @@ struct AccountUsageMeterView: View { return "-" } - return "\(remainingPercent)%" + return "\(remainingPercent)% left" } private var progress: CGFloat { @@ -1019,7 +1294,7 @@ struct AccountUsageMeterView: View { private var color: Color { switch tone { case .codexActive: return PanelPalette.codexAccent(colorScheme) - case .ready: return PanelPalette.usageMint(colorScheme) + case .ready: return PanelPalette.capacityAccent(colorScheme) case .selected: return PanelPalette.routeAccent(colorScheme) case .warning: return PanelPalette.warning(colorScheme) case .danger: return PanelPalette.destructive(colorScheme) @@ -1032,7 +1307,7 @@ struct AccountUsageMeterView: View { case .warning, .danger: return color.opacity(colorScheme == .dark ? 0.95 : 0.78) default: - return color.opacity(colorScheme == .dark ? 0.92 : 0.72) + return PanelPalette.primaryText(colorScheme).opacity(colorScheme == .dark ? 0.9 : 0.84) } } @@ -1138,12 +1413,11 @@ private struct UsageResetDisplay { private static func formatResetDate(_ date: Date) -> String { let formatter = DateFormatter() - formatter.locale = .autoupdatingCurrent - let calendar = Calendar.autoupdatingCurrent - let template = calendar.component(.year, from: date) == calendar.component(.year, from: Date()) - ? "MMM d jm" - : "MMM d y jm" - formatter.setLocalizedDateFormatFromTemplate(template) + formatter.locale = Locale(identifier: "en_US_POSIX") + let calendar = Calendar(identifier: .gregorian) + formatter.dateFormat = calendar.component(.year, from: date) == calendar.component(.year, from: Date()) + ? "MMM d HH:mm" + : "MMM d yyyy HH:mm" return formatter.string(from: date) } } @@ -1177,42 +1451,18 @@ struct OperatorStatusStripView: View { var body: some View { VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 0) { - OperatorFlowMetricView( - title: "Intake", - value: snapshot.queuedCount, - unit: "Issues", - tint: PanelPalette.secondaryText(colorScheme) - ) - - flowDivider - - OperatorFlowMetricView( - title: "Running", - value: snapshot.activeRunCount, - unit: "Lanes", - tint: PanelPalette.routeAccent(colorScheme) - ) - - flowDivider - - OperatorFlowMetricView( - title: "Review", - value: snapshot.reviewCount, - unit: "PRs", - tint: PanelPalette.codexAccent(colorScheme) - ) - - flowDivider + if !metrics.isEmpty { + HStack(spacing: 0) { + ForEach(Array(metrics.enumerated()), id: \.element.id) { index, metric in + if index > 0 { + flowDivider + } - OperatorFlowMetricView( - title: "Landing", - value: snapshot.landingCount, - unit: "PRs", - tint: PanelPalette.fastModeAccent(colorScheme) - ) + OperatorFlowMetricView(metric: metric) + } + } + .frame(height: 32) } - .frame(height: 30) if let warning = snapshot.warningSummary { HStack(spacing: 5) { @@ -1222,7 +1472,7 @@ struct OperatorStatusStripView: View { .frame(width: 10) Text(warning) - .font(PanelFont.summaryTitle) + .font(PanelFont.metricLabel) .foregroundStyle(PanelPalette.secondaryText(colorScheme)) .lineLimit(1) .truncationMode(.tail) @@ -1230,11 +1480,11 @@ struct OperatorStatusStripView: View { Spacer(minLength: 4) Text(refreshMeta) - .font(PanelFont.summaryTitle) + .font(PanelFont.tertiary) .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.68)) .monospacedDigit() } - .frame(height: 14) + .frame(height: 16) .help("Operator snapshot refreshes every \(refreshIntervalSeconds) seconds.") } } @@ -1247,7 +1497,42 @@ struct OperatorStatusStripView: View { private var flowDivider: some View { Rectangle() .fill(PanelPalette.separator(colorScheme).opacity(colorScheme == .dark ? 0.72 : 0.9)) - .frame(width: 0.5, height: 20) + .frame(width: 0.5, height: 21) + } + + private var metrics: [OperatorFlowMetric] { + [ + OperatorFlowMetric( + title: "Intake", + value: snapshot.queuedCount, + unitSingular: "issue", + unitPlural: "issues", + tint: PanelPalette.secondaryText(colorScheme) + ), + OperatorFlowMetric( + title: "Running", + value: snapshot.activeRunCount, + unitSingular: "lane", + unitPlural: "lanes", + tint: PanelPalette.routeAccent(colorScheme) + ), + OperatorFlowMetric( + title: "Review", + value: snapshot.reviewCount, + unitSingular: "PR", + unitPlural: "PRs", + tint: PanelPalette.codexAccent(colorScheme) + ), + OperatorFlowMetric( + title: "Landing", + value: snapshot.landingCount, + unitSingular: "PR", + unitPlural: "PRs", + tint: PanelPalette.landingAccent(colorScheme) + ), + ].filter { metric in + metric.value > 0 + } } private var refreshMeta: String { @@ -1264,31 +1549,38 @@ struct OperatorStatusStripView: View { } } -struct OperatorFlowMetricView: View { +struct OperatorFlowMetric: Identifiable { let title: String let value: Int - let unit: String + let unitSingular: String + let unitPlural: String let tint: Color + + var id: String { + title + } + + var unit: String { + value == 1 ? unitSingular : unitPlural + } +} + +struct OperatorFlowMetricView: View { + let metric: OperatorFlowMetric @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(PanelFont.summaryTitle) + Text(metric.title) + .font(PanelFont.metricLabel) .foregroundStyle(PanelPalette.secondaryText(colorScheme)) .lineLimit(1) - HStack(alignment: .firstTextBaseline, spacing: 3) { - Text("\(value)") - .font(PanelFont.summaryValue) - .foregroundStyle(value > 0 ? tint : PanelPalette.primaryText(colorScheme)) - .monospacedDigit() - - Text(unit) - .font(PanelFont.summaryTitle) - .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) - .lineLimit(1) - } + Text("\(metric.value) \(metric.unit)") + .font(PanelFont.metricValue) + .foregroundStyle(metric.tint) + .monospacedDigit() + .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 5) @@ -1310,13 +1602,13 @@ struct SummaryTileView: View { .frame(width: 11) Text(title) - .font(PanelFont.summaryTitle) + .font(PanelFont.metricLabel) .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) Text(value) - .font(PanelFont.summaryValue) + .font(PanelFont.metricValue) .foregroundStyle(PanelPalette.primaryText(colorScheme)) .lineLimit(1) .truncationMode(.middle) @@ -1578,6 +1870,64 @@ private enum AccountDisplay { } } +private func formatUsagePercent(_ value: Double) -> String { + guard value.isFinite else { + return "-" + } + + let rounded = value.rounded() + if abs(value - rounded) < 0.05 { + return "\(Int(rounded))%" + } + + return String(format: "%.1f%%", value) +} + +private func formatDailyUsageRate(_ value: Double) -> String { + let percent = formatUsagePercent(value) + guard percent != "-" else { + return "-" + } + + return "\(percent)/d" +} + +private func formatPercentagePointDelta(_ value: Double) -> String { + guard value.isFinite else { + return "-" + } + + let absValue = abs(value) + let sign = value > 0.05 ? "+" : (value < -0.05 ? "-" : "") + let rounded = absValue.rounded() + if abs(absValue - rounded) < 0.05 { + return "\(sign)\(Int(rounded))pp" + } + + return String(format: "%@%.1fpp", sign, absValue) +} + +private func compactUsageDate(_ value: String) -> String { + let parts = value.split(separator: "-") + guard parts.count == 3, let month = Int(parts[1]), let day = Int(parts[2]) else { + return value + } + + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.year = 2000 + components.month = month + components.day = day + guard let date = components.date else { + return value + } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "MMM d" + return formatter.string(from: date) +} + enum GlassSurfaceDepth { case panel case section @@ -1702,7 +2052,7 @@ private extension CodexAccount { case .codexActive: return PanelPalette.codexAccent(colorScheme) case .ready: - return PanelPalette.usageMint(colorScheme) + return PanelPalette.secondaryText(colorScheme) case .selected: return PanelPalette.routeAccent(colorScheme) case .warning: @@ -1723,9 +2073,21 @@ private extension CodexAccount { } var hasUsageSummary: Bool { + hasUsageWindowSummary || hasSevenDayUsageEstimate + } + + var hasUsageWindowSummary: Bool { hasPrimaryUsageData || hasSecondaryUsageData } + var hasSevenDayUsageEstimate: Bool { + sevenDayUsedPercent != nil || sevenDayDailyAveragePercent != nil + } + + var recentUsageRecords: [AccountUsageRecord] { + usageRecords ?? [] + } + var compactHealthLabel: String? { if isUsageLimited { return "Limited" diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift index 2a553630..5f91995b 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -298,6 +298,7 @@ private extension AccountListResponse { accounts: accounts.map { account in account.withCodexActive(account.matchesSelector(identity.selector)) }, + usageEstimate: usageEstimate, usageProbeError: usageProbeError ) } diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift index 98e85bd9..21a3f72d 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift @@ -295,7 +295,16 @@ struct DecodexAppBridge: Sendable { private extension AppBridgeRequest { var requiresHelper: Bool { switch operation { - case "account_login", "codex_fast_mode_status", "codex_fast_mode_set": + case + "account_clear", + "account_import", + "account_list", + "account_login", + "account_logout", + "account_select", + "account_use", + "codex_fast_mode_status", + "codex_fast_mode_set": return true default: return false diff --git a/apps/decodex-app/Sources/DecodexApp/Models.swift b/apps/decodex-app/Sources/DecodexApp/Models.swift index b3083643..d4137963 100644 --- a/apps/decodex-app/Sources/DecodexApp/Models.swift +++ b/apps/decodex-app/Sources/DecodexApp/Models.swift @@ -7,6 +7,7 @@ struct AccountListResponse: Decodable { let codexAuth: CodexAuthIdentity? let control: AccountControl let accounts: [CodexAccount] + let usageEstimate: AccountUsageEstimate? let usageProbeError: String? enum CodingKeys: String, CodingKey { @@ -16,6 +17,7 @@ struct AccountListResponse: Decodable { case codexAuth = "codex_auth" case control case accounts + case usageEstimate = "usage_estimate" case usageProbeError = "usage_probe_error" } } @@ -66,6 +68,44 @@ struct CodexFastModeResponse: Decodable, Equatable { } } +struct AccountUsageEstimate: Decodable, Equatable { + let windowDays: Int + let accountCount: Int + let accountEstimateCount: Int + let totalCapacityPercent: Int + let totalUsedPercent: Int + let totalUsedOfCapacityPercent: Double + let averageDailyUsedPercent: Double + let averageDailyPoolPercent: Double + + enum CodingKeys: String, CodingKey { + case windowDays = "window_days" + case accountCount = "account_count" + case accountEstimateCount = "account_estimate_count" + case totalCapacityPercent = "total_capacity_percent" + case totalUsedPercent = "total_used_percent" + case totalUsedOfCapacityPercent = "total_used_of_capacity_percent" + case averageDailyUsedPercent = "average_daily_used_percent" + case averageDailyPoolPercent = "average_daily_pool_percent" + } +} + +struct AccountUsageRecord: Decodable, Identifiable, Equatable { + let date: String + let usedPercent: Int + let checkedAtUnixEpoch: Int + + var id: String { + "\(date)-\(checkedAtUnixEpoch)" + } + + enum CodingKeys: String, CodingKey { + case date + case usedPercent = "used_percent" + case checkedAtUnixEpoch = "checked_at_unix_epoch" + } +} + struct CodexAccount: Decodable, Identifiable, Equatable { let accountFingerprint: String let email: String? @@ -95,6 +135,9 @@ struct CodexAccount: Decodable, Identifiable, Equatable { let creditsUnlimited: Bool? let creditsBalance: String? let rateLimitReachedType: String? + let sevenDayUsedPercent: Int? + let sevenDayDailyAveragePercent: Double? + let usageRecords: [AccountUsageRecord]? var id: String { email ?? accountFingerprint @@ -190,22 +233,7 @@ struct CodexAccount: Decodable, Identifiable, Equatable { } func windowLabel(seconds: Int?) -> String { - switch seconds { - case 18_000: return "5 hrs" - case 604_800: return "7 days" - case let value?: - let hours = value / 3_600 - if hours > 0 && value % 3_600 == 0 { - return hours == 1 ? "1 hr" : "\(hours) hrs" - } - let days = value / 86_400 - if days > 0 && value % 86_400 == 0 { - return days == 1 ? "1 day" : "\(days) days" - } - return "window" - case nil: - return "window" - } + UsageWindowLabel.make(seconds: seconds) } func usageTone(remainingPercent: Int?) -> AccountTone { @@ -255,7 +283,10 @@ struct CodexAccount: Decodable, Identifiable, Equatable { creditsHasCredits: creditsHasCredits, creditsUnlimited: creditsUnlimited, creditsBalance: creditsBalance, - rateLimitReachedType: rateLimitReachedType + rateLimitReachedType: rateLimitReachedType, + sevenDayUsedPercent: sevenDayUsedPercent, + sevenDayDailyAveragePercent: sevenDayDailyAveragePercent, + usageRecords: usageRecords ) } @@ -288,6 +319,9 @@ struct CodexAccount: Decodable, Identifiable, Equatable { case creditsUnlimited = "credits_unlimited" case creditsBalance = "credits_balance" case rateLimitReachedType = "rate_limit_reached_type" + case sevenDayUsedPercent = "seven_day_used_percent" + case sevenDayDailyAveragePercent = "seven_day_daily_average_percent" + case usageRecords = "usage_records" } } @@ -299,3 +333,30 @@ enum AccountTone { case danger case neutral } + +enum UsageWindowLabel { + static func make(seconds: Int?) -> String { + guard let seconds, seconds > 0 else { + return "-" + } + + if seconds == 18_000 { + return "5h" + } + if seconds == 604_800 { + return "7d" + } + if seconds % 86_400 == 0 { + return days(seconds / 86_400) + } + if seconds % 3_600 == 0 { + return "\(seconds / 3_600)h" + } + + return "-" + } + + static func days(_ value: Int) -> String { + "\(value)d" + } +} diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift index fd7b32d2..06b3560b 100644 --- a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift +++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift @@ -54,7 +54,7 @@ struct OperatorSnapshotResponse: Decodable, Sendable { } var shouldDisplayInPanel: Bool { - !isAPIOnlySnapshot + hasVisibleSignal && !isAPIOnlySnapshot } var warningSummary: String? { diff --git a/apps/decodex/src/accounts.rs b/apps/decodex/src/accounts.rs index a516f940..69b16947 100644 --- a/apps/decodex/src/accounts.rs +++ b/apps/decodex/src/accounts.rs @@ -20,6 +20,8 @@ use crate::{ state::CodexAccountActivitySummary, }; +const USAGE_ESTIMATE_WINDOW_DAYS: i64 = 7; +const USAGE_ESTIMATE_WINDOW_SECONDS: i64 = USAGE_ESTIMATE_WINDOW_DAYS * 24 * 60 * 60; const ACCOUNT_RANDOM_NAMES: &[&str] = &[ "Alex", "Avery", "Bailey", "Blake", "Casey", "Charlie", "Clara", "Dana", "Drew", "Eden", "Elliot", "Emery", "Evan", "Finley", "Harper", "Hayden", "Iris", "Jamie", "Jordan", "Kai", @@ -83,10 +85,6 @@ impl AccountStore { self.response_from_records(&records) } - fn list_with_usage(&self) -> Result { - self.list_with_cached_usage(false) - } - fn list_with_cached_usage(&self, force_refresh: bool) -> Result { let mut response = self.list()?; @@ -308,6 +306,7 @@ impl AccountStore { codex_auth: codex_auth.as_ref().map(AccountIdentity::summary), control, accounts, + usage_estimate: None, usage_probe_error: None, }) } @@ -464,6 +463,7 @@ pub(crate) struct AccountListResponse { pub(crate) codex_auth: Option, pub(crate) control: AccountControlSummary, pub(crate) accounts: Vec, + pub(crate) usage_estimate: Option, pub(crate) usage_probe_error: Option, } impl AccountListResponse { @@ -475,7 +475,13 @@ impl AccountListResponse { match CodexAccountPool::from_accounts_path(accounts_path) .and_then(|pool| pool.account_activity_summaries_cached(force_refresh)) { - Ok(summaries) => self.apply_usage_summaries(&summaries), + Ok(summaries) => { + self.apply_usage_summaries(&summaries); + + if let Err(error) = self.refresh_usage_records(accounts_path) { + self.usage_probe_error = Some(error.to_string()); + } + }, Err(error) => self.usage_probe_error = Some(error.to_string()), } } @@ -486,6 +492,34 @@ impl AccountListResponse { account.apply_usage_summary(summary); } } + + self.refresh_usage_estimate(); + } + + fn refresh_usage_records(&mut self, accounts_path: &Path) -> Result<()> { + let history_path = usage_history_path(accounts_path)?; + let mut history = AccountUsageHistory::read(&history_path)?; + + history + .merge_current_records(self.accounts.iter().filter_map(AccountSummary::usage_record)); + history.write(&history_path)?; + history.apply_to_accounts(&mut self.accounts); + + Ok(()) + } + + fn refresh_usage_estimate(&mut self) { + let account_count = self.accounts.len(); + let account_estimate_count = + self.accounts.iter().filter(|account| account.seven_day_used_percent.is_some()).count(); + let total_used_percent = + self.accounts.iter().filter_map(|account| account.seven_day_used_percent).sum::(); + + self.usage_estimate = AccountUsageEstimateSummary::new( + account_count, + account_estimate_count, + total_used_percent, + ); } } @@ -508,6 +542,54 @@ pub(crate) struct AccountControlSummary { pub(crate) account_selector: Option, } +#[derive(Clone, Serialize)] +pub(crate) struct AccountUsageEstimateSummary { + pub(crate) window_days: i64, + pub(crate) account_count: usize, + pub(crate) account_estimate_count: usize, + pub(crate) total_capacity_percent: i64, + pub(crate) total_used_percent: i64, + pub(crate) total_used_of_capacity_percent: f64, + pub(crate) average_daily_used_percent: f64, + pub(crate) average_daily_pool_percent: f64, +} +impl AccountUsageEstimateSummary { + fn new( + account_count: usize, + account_estimate_count: usize, + total_used_percent: i64, + ) -> Option { + if account_count == 0 || account_estimate_count == 0 { + return None; + } + + let total_capacity_percent = + i64::try_from(account_count).unwrap_or(i64::MAX / 100).saturating_mul(100); + let total_used_of_capacity_percent = + percent_ratio(total_used_percent, total_capacity_percent); + + Some(Self { + window_days: USAGE_ESTIMATE_WINDOW_DAYS, + account_count, + account_estimate_count, + total_capacity_percent, + total_used_percent, + total_used_of_capacity_percent, + average_daily_used_percent: total_used_percent as f64 + / USAGE_ESTIMATE_WINDOW_DAYS as f64, + average_daily_pool_percent: total_used_of_capacity_percent + / USAGE_ESTIMATE_WINDOW_DAYS as f64, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct AccountUsageDailySummary { + pub(crate) date: String, + pub(crate) used_percent: i64, + pub(crate) checked_at_unix_epoch: i64, +} + #[derive(Serialize)] pub(crate) struct AccountSummary { pub(crate) account_fingerprint: String, @@ -538,6 +620,9 @@ pub(crate) struct AccountSummary { pub(crate) credits_unlimited: Option, pub(crate) credits_balance: Option, pub(crate) rate_limit_reached_type: Option, + pub(crate) seven_day_used_percent: Option, + pub(crate) seven_day_daily_average_percent: Option, + pub(crate) usage_records: Vec, } impl AccountSummary { fn apply_usage_summary(&mut self, summary: &CodexAccountActivitySummary) { @@ -562,6 +647,197 @@ impl AccountSummary { } self.note.clone_from(&summary.note); + self.apply_usage_estimate(); + } + + fn apply_usage_estimate(&mut self) { + let Some(basis) = SevenDayUsageBasis::from_account(self) else { + self.seven_day_used_percent = None; + self.seven_day_daily_average_percent = None; + + return; + }; + + self.seven_day_used_percent = Some(basis.used_percent); + self.seven_day_daily_average_percent = + Some(basis.used_percent as f64 / USAGE_ESTIMATE_WINDOW_DAYS as f64); + } + + fn usage_record(&self) -> Option { + let basis = SevenDayUsageBasis::from_account(self)?; + let checked_at_unix_epoch = self.checked_at_unix_epoch?; + + Some(AccountUsageHistoryRecord { + date: usage_record_date(checked_at_unix_epoch)?, + account_fingerprint: self.account_fingerprint.clone(), + email: self.email.clone(), + used_percent: basis.used_percent, + window_seconds: basis.window_seconds, + checked_at_unix_epoch, + resets_at_unix_epoch: basis.resets_at_unix_epoch, + }) + } +} + +#[derive(Clone, Copy)] +struct SevenDayUsageBasis { + used_percent: i64, + window_seconds: Option, + resets_at_unix_epoch: Option, +} +impl SevenDayUsageBasis { + fn from_account(account: &AccountSummary) -> Option { + let secondary = Self::from_window( + account.secondary_remaining_percent, + account.secondary_window_seconds, + account.secondary_resets_at_unix_epoch, + ); + + if let Some(basis) = secondary + && accepts_secondary_usage_window(basis.window_seconds) + { + return Some(basis); + } + + Self::from_window( + account.primary_remaining_percent, + account.primary_window_seconds, + account.primary_resets_at_unix_epoch, + ) + .filter(|basis| basis.window_seconds.is_some_and(is_seven_day_usage_window)) + } + + fn from_window( + remaining_percent: Option, + window_seconds: Option, + resets_at_unix_epoch: Option, + ) -> Option { + Some(Self { + used_percent: used_percent_from_remaining(remaining_percent?), + window_seconds, + resets_at_unix_epoch, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +struct AccountUsageHistoryRecord { + date: String, + account_fingerprint: String, + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, + used_percent: i64, + #[serde(skip_serializing_if = "Option::is_none")] + window_seconds: Option, + checked_at_unix_epoch: i64, + #[serde(skip_serializing_if = "Option::is_none")] + resets_at_unix_epoch: Option, +} +impl AccountUsageHistoryRecord { + fn daily_summary(&self) -> AccountUsageDailySummary { + AccountUsageDailySummary { + date: self.date.clone(), + used_percent: self.used_percent, + checked_at_unix_epoch: self.checked_at_unix_epoch, + } + } + + fn is_recent(&self, now_unix_epoch: i64) -> bool { + now_unix_epoch.saturating_sub(self.checked_at_unix_epoch) <= USAGE_ESTIMATE_WINDOW_SECONDS + } + + fn matches_account(&self, account: &AccountSummary) -> bool { + self.account_fingerprint == account.account_fingerprint + || self + .email + .as_deref() + .zip(account.email.as_deref()) + .is_some_and(|(left, right)| left == right) + } + + fn same_daily_slot(&self, other: &Self) -> bool { + self.date == other.date + && (self.account_fingerprint == other.account_fingerprint + || self + .email + .as_deref() + .zip(other.email.as_deref()) + .is_some_and(|(left, right)| left == right)) + } +} + +#[derive(Default)] +struct AccountUsageHistory { + records: Vec, +} +impl AccountUsageHistory { + fn read(path: &Path) -> Result { + let input = match fs::read_to_string(path) { + Ok(input) => input, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Self::default()), + Err(error) => { + eyre::bail!("Failed to read account usage history `{}`: {error}", path.display()); + }, + }; + + Ok(Self { records: parse_usage_history_records(&input, path)? }) + } + + fn merge_current_records( + &mut self, + current_records: impl Iterator, + ) { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let current_records = current_records.collect::>(); + + self.records.retain(|record| { + record.is_recent(now) + && !current_records.iter().any(|current| current.same_daily_slot(record)) + }); + self.records.extend(current_records); + self.records.sort_by(|left, right| { + left.date + .cmp(&right.date) + .then_with(|| left.account_fingerprint.cmp(&right.account_fingerprint)) + }); + } + + fn write(&self, path: &Path) -> Result<()> { + let parent = path.parent().ok_or_else(|| { + eyre::eyre!("Account usage history path `{}` must have a parent.", path.display()) + })?; + let file_name = path.file_name().and_then(|name| name.to_str()).ok_or_else(|| { + eyre::eyre!("Account usage history path must end in a valid file name.") + })?; + let temp_path = parent.join(format!(".{file_name}.tmp-{}", process::id())); + let mut body = String::new(); + + for record in &self.records { + body.push_str(&serde_json::to_string(record)?); + body.push('\n'); + } + + fs::create_dir_all(parent)?; + fs::write(&temp_path, body)?; + + secure_account_file(&temp_path)?; + + fs::rename(temp_path, path)?; + + secure_account_file(path)?; + + Ok(()) + } + + fn apply_to_accounts(&self, accounts: &mut [AccountSummary]) { + for account in accounts { + account.usage_records = self + .records + .iter() + .filter(|record| record.matches_account(account)) + .map(AccountUsageHistoryRecord::daily_summary) + .collect(); + } } } @@ -788,6 +1064,9 @@ impl AccountPoolRecord { credits_unlimited: None, credits_balance: None, rate_limit_reached_type: None, + seven_day_used_percent: None, + seven_day_daily_average_percent: None, + usage_records: Vec::new(), } } @@ -904,10 +1183,6 @@ pub(crate) fn account_list() -> Result { AccountStore::global()?.list() } -pub(crate) fn account_list_with_usage() -> Result { - AccountStore::global()?.list_with_usage() -} - pub(crate) fn account_list_with_cached_usage(force_refresh: bool) -> Result { AccountStore::global()?.list_with_cached_usage(force_refresh) } @@ -1119,6 +1394,31 @@ fn parse_account_records(input: &str, path: &Path) -> Result Result> { + let mut records = Vec::new(); + + for (line_index, line) in input.lines().enumerate() { + let line_number = line_index + 1; + let trimmed = line.trim(); + + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let record = + serde_json::from_str::(trimmed).map_err(|error| { + eyre::eyre!( + "Decodex account usage history `{}` line {line_number} is invalid: {error}", + path.display() + ) + })?; + + records.push(record); + } + + Ok(records) +} + fn print_list_response(response: &AccountListResponse, json: bool) -> Result<()> { if json { println!("{}", serde_json::to_string_pretty(response)?); @@ -1324,6 +1624,45 @@ fn number_as_i64(value: &serde_json::Value) -> Option { .or_else(|| value.as_f64().map(|number| number.round() as i64)) } +fn usage_history_path(accounts_path: &Path) -> Result { + let parent = accounts_path.parent().ok_or_else(|| { + eyre::eyre!( + "Decodex accounts path `{}` must have a parent directory.", + accounts_path.display() + ) + })?; + + Ok(parent.join("account-usage-history.jsonl")) +} + +fn usage_record_date(unix_epoch: i64) -> Option { + OffsetDateTime::from_unix_timestamp(unix_epoch) + .ok() + .map(|timestamp| timestamp.date().to_string()) +} + +fn accepts_secondary_usage_window(window_seconds: Option) -> bool { + window_seconds.is_none_or(is_seven_day_usage_window) +} + +fn is_seven_day_usage_window(window_seconds: i64) -> bool { + window_seconds + .checked_sub(USAGE_ESTIMATE_WINDOW_SECONDS) + .is_some_and(|delta| delta.abs() <= 3_600) +} + +fn used_percent_from_remaining(remaining_percent: i64) -> i64 { + 100_i64.saturating_sub(remaining_percent).clamp(0, 100) +} + +fn percent_ratio(numerator: i64, denominator: i64) -> f64 { + if denominator <= 0 { + return 0.0; + } + + (numerator as f64 / denominator as f64) * 100.0 +} + fn random_name_seed_for(account_fingerprint: &str, email: Option) -> String { if !account_fingerprint.trim().is_empty() { return account_fingerprint.to_owned(); @@ -1605,6 +1944,68 @@ mod tests { assert_eq!(response.accounts[0].secondary_window_seconds, Some(604_800)); assert_eq!(response.accounts[0].secondary_remaining_percent, Some(91)); assert_eq!(response.accounts[0].credits_balance.as_deref(), Some("9.99")); + assert_eq!(response.accounts[0].seven_day_used_percent, Some(9)); + + assert_close(response.accounts[0].seven_day_daily_average_percent, 9.0 / 7.0); + } + + #[test] + fn usage_records_and_pool_estimate_use_seven_day_window() { + let temp_dir = TempDir::new().expect("temp dir should create"); + let store = AccountStore::new( + temp_dir.path().join("accounts.jsonl"), + temp_dir.path().join("config.toml"), + ); + + store + .save_records(&[ + account_record( + "copy@example.com", + "acct_123456", + "header.eyJleHAiOjQxMDI0NDQ4MDB9.sig", + "refresh-secret", + ), + account_record( + "other@example.com", + "acct_654321", + "header.eyJleHAiOjQxMDI0NDQ4MDB9.sig", + "refresh-secret-2", + ), + ]) + .expect("records should save"); + + let summaries = [ + usage_summary("copy@example.com", "...123456", 40), + usage_summary("other@example.com", "...654321", 70), + ]; + let mut response = store.list().expect("account list should load"); + + response.apply_usage_summaries(&summaries); + response.refresh_usage_records(&store.accounts_path).expect("usage history should refresh"); + + let estimate = response.usage_estimate.as_ref().expect("usage estimate should exist"); + let history_path = super::usage_history_path(&store.accounts_path) + .expect("usage history path should resolve"); + let history = fs::read_to_string(history_path).expect("usage history should read"); + let record_date = + super::usage_record_date(1_800_000_000).expect("usage record date should format"); + + assert_eq!(estimate.window_days, 7); + assert_eq!(estimate.account_count, 2); + assert_eq!(estimate.account_estimate_count, 2); + assert_eq!(estimate.total_capacity_percent, 200); + assert_eq!(estimate.total_used_percent, 90); + + assert_close(Some(estimate.total_used_of_capacity_percent), 45.0); + assert_close(Some(estimate.average_daily_used_percent), 90.0 / 7.0); + assert_close(Some(estimate.average_daily_pool_percent), 45.0 / 7.0); + + assert_eq!(response.accounts[0].usage_records.len(), 1); + assert_eq!(response.accounts[0].usage_records[0].date, record_date); + assert_eq!(response.accounts[0].usage_records[0].used_percent, 60); + assert_eq!(history.lines().count(), 2); + assert!(history.contains(r#""used_percent":60"#)); + assert!(history.contains(r#""used_percent":30"#)); } fn account_record( @@ -1631,4 +2032,32 @@ mod tests { last_refresh: None, } } + + fn usage_summary( + email: &str, + account_fingerprint: &str, + secondary_remaining_percent: i64, + ) -> CodexAccountActivitySummary { + CodexAccountActivitySummary { + account_fingerprint: String::from(account_fingerprint), + email: Some(String::from(email)), + plan_type: Some(String::from("pro")), + status: String::from("available"), + refresh_status: String::from("not_needed"), + checked_at_unix_epoch: Some(1_800_000_000), + secondary_window_seconds: Some(604_800), + secondary_remaining_percent: Some(secondary_remaining_percent), + secondary_resets_at_unix_epoch: Some(1_800_604_800), + ..CodexAccountActivitySummary::default() + } + } + + fn assert_close(value: Option, expected: f64) { + let value = value.expect("value should exist"); + + assert!( + (value - expected).abs() < 0.001, + "expected {value} to be within 0.001 of {expected}" + ); + } } diff --git a/apps/decodex/src/app_bridge.rs b/apps/decodex/src/app_bridge.rs index 5cdaadbd..0eb2b4ec 100644 --- a/apps/decodex/src/app_bridge.rs +++ b/apps/decodex/src/app_bridge.rs @@ -21,6 +21,8 @@ enum AppBridgeRequest { List { #[serde(default)] include_usage: bool, + #[serde(default)] + force_refresh: bool, }, #[serde(rename = "account_select")] Select { @@ -99,9 +101,9 @@ pub fn run() -> Result<()> { fn handle_request(request: AppBridgeRequest) -> Result<()> { match request { - AppBridgeRequest::List { include_usage } => + AppBridgeRequest::List { include_usage, force_refresh } => if include_usage { - emit_result(&accounts::account_list_with_usage()?) + emit_result(&accounts::account_list_with_cached_usage(force_refresh)?) } else { emit_result(&accounts::account_list()?) }, @@ -188,6 +190,21 @@ mod tests { assert!(matches!(request, AppBridgeRequest::Use { .. })); } + #[test] + fn parses_account_list_bridge_request_with_force_refresh() { + let request = serde_json::from_value::(serde_json::json!({ + "operation": "account_list", + "include_usage": true, + "force_refresh": true + })) + .expect("bridge request should parse"); + + assert!(matches!( + request, + AppBridgeRequest::List { include_usage: true, force_refresh: true } + )); + } + #[test] fn parses_fast_mode_set_bridge_request() { let request = serde_json::from_value::(serde_json::json!({ diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index a74fba46..b6e60f00 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -296,7 +296,7 @@ and idempotency fields are defined by ## Local operational state -The local runtime store is the global Decodex SQLite database for one local installation. It lives at `~/.codex/decodex/runtime.sqlite3`, not inside any registered project checkout or worktree. Every row that belongs to a repo is scoped by `project_id`. Decodex logs live beside that database under `~/.codex/decodex/logs/`, the optional shared Codex account pool lives at `~/.codex/decodex/accounts.jsonl`, global operator config lives at `~/.codex/decodex/config.toml`, and agent-readable derived evidence lives under `~/.codex/decodex/agent-evidence//`; vendor-qualified app-data directories and per-project runtime databases are not part of the runtime contract. Global operator config owns account-pool routing and shared account display-name offsets. UI-only preferences such as theme, table sorting, and local privacy visibility are not runtime state. +The local runtime store is the global Decodex SQLite database for one local installation. It lives at `~/.codex/decodex/runtime.sqlite3`, not inside any registered project checkout or worktree. Every row that belongs to a repo is scoped by `project_id`. Decodex logs live beside that database under `~/.codex/decodex/logs/`, the optional shared Codex account pool lives at `~/.codex/decodex/accounts.jsonl`, global operator config lives at `~/.codex/decodex/config.toml`, bounded local account usage estimates live at `~/.codex/decodex/account-usage-history.jsonl`, and agent-readable derived evidence lives under `~/.codex/decodex/agent-evidence//`; vendor-qualified app-data directories and per-project runtime databases are not part of the runtime contract. Global operator config owns account-pool routing and shared account display-name offsets. Account usage history owns local seven-day display estimates only; it does not contain token material and does not decide scheduling. UI-only preferences such as theme, table sorting, and local privacy visibility are not runtime state. Project contracts live outside registered repositories under `~/.codex/decodex/projects//`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `.toml` are not part of the contract. `project.toml` must set `[paths].repo_root` so the project contract is explicit. Project registration stores the centralized `config_path`, target `repo_root`, `worktree_root`, and workflow path in the global runtime database. Commands that start inside a registered checkout or lane worktree resolve the project through that registry; they do not discover or trust worktree-local config files. `decodex serve` loads enabled registered projects from the global runtime database. It must not scan `.codex` history, repo-local config files, or currently open worktrees to infer additional projects.