diff --git a/Makefile.toml b/Makefile.toml index be93566a..414c8f9f 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -240,11 +240,13 @@ APP_PATH="$STAGE_DIR/Decodex App.app" test -d "$APP_PATH" test -x "$APP_PATH/Contents/MacOS/DecodexApp" test -x "$APP_PATH/Contents/Helpers/decodex-app-helper" +test -x "$APP_PATH/Contents/Helpers/decodex" test -f "$APP_PATH/Contents/Info.plist" test -f "$APP_PATH/Contents/Resources/AppIcon.icns" test -f "$APP_PATH/Contents/Resources/StatusBarIcon.png" codesign --verify --deep --strict "$APP_PATH" codesign --verify --strict "$APP_PATH/Contents/Helpers/decodex-app-helper" +codesign --verify --strict "$APP_PATH/Contents/Helpers/decodex" codesign -dv --verbose=4 "$APP_PATH" 2>&1 | grep -q '^TeamIdentifier=' codesign -dv --verbose=4 "$APP_PATH" 2>&1 | grep -q 'flags=.*runtime' plutil -extract CFBundleName raw "$APP_PATH/Contents/Info.plist" | grep -qx 'Decodex App' diff --git a/apps/decodex-app/README.md b/apps/decodex-app/README.md index 22e09d56..c20afad6 100644 --- a/apps/decodex-app/README.md +++ b/apps/decodex-app/README.md @@ -9,8 +9,12 @@ or the full operator dashboard. ## Scope -The first Decodex App release only manages the shared Codex account pool through the -bundled `decodex-app-helper`, which links the Rust account service directly: +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: - list accounts without printing token material - pin future Decodex runs to one account @@ -44,13 +48,17 @@ apps/decodex-app/script/build_and_run.sh stage cargo make test-decodex-app-stage ``` -The staging script builds both the Swift app and the Rust `decodex-app-helper`, then -copies the helper into `Contents/Helpers/`. Direct SwiftPM launches are development-only; -when needed, point them at a workspace-built helper: +The staging script builds the Swift app, the Rust `decodex` server binary, and +`decodex-app-helper`, then copies both Rust executables into `Contents/Helpers/`. +Direct SwiftPM launches are development-only; when needed, point them at workspace-built +executables: ```sh cargo build -p decodex --bin decodex-app-helper -DECODEX_APP_HELPER="$(pwd)/target/debug/decodex-app-helper" swift run --package-path apps/decodex-app DecodexApp +cargo build -p decodex --bin decodex +DECODEX_APP_DECODEX="$(pwd)/target/debug/decodex" \ +DECODEX_APP_HELPER="$(pwd)/target/debug/decodex-app-helper" \ +swift run --package-path apps/decodex-app DecodexApp ``` The staging script follows the local Rsnap-style signing path: it writes @@ -73,4 +81,4 @@ scripts/assets/render_decodex_app_icons.swift ``` The staging script copies `app-icon.icns`, the template status item image, and the -signed `decodex-app-helper` into the app bundle. +signed `decodex` / `decodex-app-helper` executables into the app bundle. diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 476186a8..72d1eedc 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -54,7 +54,13 @@ struct AccountPanelView: View { NoticeView(text: notice) } - if store.accounts.isEmpty { + if let usageProbeError = store.accountList?.usageProbeError { + NoticeView(text: "Usage probe: \(usageProbeError)") + } + + if store.isInitialLoading { + loadingState + } else if store.accounts.isEmpty { emptyState } else { accountList @@ -99,7 +105,7 @@ struct AccountPanelView: View { Button { Task { - await store.refresh() + await store.refresh(force: true) } } label: { Image(systemName: store.isRefreshing ? "arrow.triangle.2.circlepath.circle" : "arrow.clockwise") @@ -148,6 +154,19 @@ struct AccountPanelView: View { .modernGlassSurface(cornerRadius: 14) } + private var loadingState: some View { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Loading accounts") + .font(.subheadline.weight(.semibold)) + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .modernGlassSurface(cornerRadius: 14) + } + private var accountList: some View { ScrollView { LazyVStack(spacing: 6) { @@ -252,8 +271,13 @@ struct AccountPanelView: View { } private var accountListHeight: CGFloat { - min( - CGFloat(store.accounts.count) * 50 + CGFloat(max(store.accounts.count - 1, 0)) * 6 + 2, + let rows = store.accounts.reduce(CGFloat(0)) { total, account in + total + (account.hasUsageWindowData ? 72 : 50) + } + let spacing = CGFloat(max(store.accounts.count - 1, 0)) * 6 + 2 + + return min( + rows + spacing, 286 ) } @@ -296,11 +320,35 @@ struct AccountRowView: View { .lineLimit(1) .truncationMode(.middle) Text("·") + if let planLabel = account.planLabel { + Text(planLabel) + .lineLimit(1) + Text("·") + } Text(account.statusLabel) .lineLimit(1) } .font(.caption) .foregroundStyle(.secondary) + + if account.hasUsageWindowData { + HStack(spacing: 6) { + AccountUsageBadgeView( + label: account.windowLabel(seconds: account.primaryWindowSeconds), + remainingPercent: account.primaryRemainingPercent, + tone: account.usageTone( + remainingPercent: account.primaryRemainingPercent + ) + ) + AccountUsageBadgeView( + label: account.windowLabel(seconds: account.secondaryWindowSeconds), + remainingPercent: account.secondaryRemainingPercent, + tone: account.usageTone( + remainingPercent: account.secondaryRemainingPercent + ) + ) + } + } } Spacer() @@ -359,6 +407,44 @@ struct AccountRowView: View { } } +struct AccountUsageBadgeView: View { + let label: String + let remainingPercent: Int? + let tone: AccountTone + + var body: some View { + HStack(spacing: 4) { + Text(label) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Text(remainingText) + .font(.caption2.monospacedDigit().weight(.semibold)) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(color.opacity(0.16), in: Capsule()) + } + + private var remainingText: String { + guard let remainingPercent else { + return "n/a" + } + + return "\(remainingPercent)%" + } + + private var color: Color { + switch tone { + case .codexActive: return .yellow + case .ready: return .green + case .selected: return .accentColor + case .warning: return .yellow + case .danger: return .red + case .neutral: return .secondary + } + } +} + struct NoticeView: View { let text: String diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift index bee75f90..63f985be 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -10,6 +10,10 @@ final class AccountStore: ObservableObject { private let bridge = DecodexAppBridge() + var isInitialLoading: Bool { + accountList == nil && isRefreshing + } + var accounts: [CodexAccount] { accountList?.accounts ?? [] } @@ -42,20 +46,35 @@ final class AccountStore: ObservableObject { return "person.2.circle" } - func refresh() async { + func refresh(force: Bool = false) async { + guard !isRefreshing else { + return + } + isRefreshing = true defer { isRefreshing = false } do { - accountList = try await bridge.runJSON(.accountList, as: AccountListResponse.self) + accountList = try await bridge.runJSON( + .accountList(forceRefresh: force), + as: AccountListResponse.self + ) notice = nil } catch { notice = error.localizedDescription } } + func refreshIfNeeded() async { + guard accountList == nil else { + return + } + + await refresh() + } + func useInCodex(_ account: CodexAccount) async { do { _ = try await bridge.runJSON( diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift index 0a6c7ddc..c5647159 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift @@ -23,13 +23,23 @@ private enum AppAssets { @main struct DecodexApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @StateObject private var store = AccountStore() + @StateObject private var store: AccountStore + + @MainActor + init() { + let accountStore = AccountStore() + + _store = StateObject(wrappedValue: accountStore) + Task { + await accountStore.refreshIfNeeded() + } + } var body: some Scene { MenuBarExtra { AccountPanelView(store: store) .task { - await store.refresh() + await store.refreshIfNeeded() } } label: { Label { diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift index b22be9b5..60610535 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift @@ -13,9 +13,9 @@ enum DecodexAppBridgeError: LocalizedError { case .launchFailed(let message): return message case .commandFailed(let code, let message): - return "Decodex App helper exited with status \(code): \(message)" + return "Decodex App bridge command failed with status \(code): \(message)" case .invalidResponse(let message): - return "Invalid Decodex App helper response: \(message)" + return "Invalid Decodex App bridge response: \(message)" } } } @@ -26,6 +26,8 @@ struct AppBridgeRequest: Encodable, Sendable { let authJsonPath: String? let codexBin: String? let keepTempHome: Bool? + let includeUsage: Bool? + let forceRefresh: Bool? enum CodingKeys: String, CodingKey { case operation @@ -33,25 +35,33 @@ struct AppBridgeRequest: Encodable, Sendable { case authJsonPath = "auth_json_path" case codexBin = "codex_bin" case keepTempHome = "keep_temp_home" + case includeUsage = "include_usage" } - static let accountList = AppBridgeRequest(operation: "account_list") - static let accountClear = AppBridgeRequest(operation: "account_clear") + static func accountList(forceRefresh: Bool = false) -> AppBridgeRequest { + AppBridgeRequest( + operation: "account_list", + includeUsage: true, + forceRefresh: forceRefresh + ) + } + + static let accountClear = AppBridgeRequest(operation: "account_clear", includeUsage: true) static func accountUse(selector: String) -> AppBridgeRequest { AppBridgeRequest(operation: "account_use", selector: selector) } static func accountSelect(selector: String) -> AppBridgeRequest { - AppBridgeRequest(operation: "account_select", selector: selector) + AppBridgeRequest(operation: "account_select", selector: selector, includeUsage: true) } static func accountLogout(selector: String) -> AppBridgeRequest { - AppBridgeRequest(operation: "account_logout", selector: selector) + AppBridgeRequest(operation: "account_logout", selector: selector, includeUsage: true) } static func accountLogin() -> AppBridgeRequest { - AppBridgeRequest(operation: "account_login") + AppBridgeRequest(operation: "account_login", includeUsage: true) } private init( @@ -59,13 +69,17 @@ struct AppBridgeRequest: Encodable, Sendable { selector: String? = nil, authJsonPath: String? = nil, codexBin: String? = nil, - keepTempHome: Bool? = nil + keepTempHome: Bool? = nil, + includeUsage: Bool? = nil, + forceRefresh: Bool? = nil ) { self.operation = operation self.selector = selector self.authJsonPath = authJsonPath self.codexBin = codexBin self.keepTempHome = keepTempHome + self.includeUsage = includeUsage + self.forceRefresh = forceRefresh } } @@ -175,6 +189,15 @@ struct DecodexAppBridge: Sendable { as type: T.Type, onOutput: (@MainActor @Sendable (String) -> Void)? ) async throws -> T { + if onOutput == nil, try request.serverRoute() != nil { + return try await DecodexServerBridge.shared.run(request, as: type) + } + guard request.operation == "account_login" else { + throw DecodexAppBridgeError.invalidResponse( + "operation \(request.operation) must be served by Decodex server" + ) + } + let helperURL = try helperExecutableURL() let requestData = try JSONEncoder().encode(request) diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift new file mode 100644 index 00000000..d8420290 --- /dev/null +++ b/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift @@ -0,0 +1,193 @@ +import Foundation + +struct ServerRoute { + let method: String + let path: String + let body: Data? +} + +actor DecodexServerBridge { + static let shared = DecodexServerBridge() + + private let defaultBaseURL = URL(string: "http://127.0.0.1:8912")! + private let defaultListenAddress = "127.0.0.1:8912" + private var serverBaseURL: URL? + private var startedProcess: Process? + + func run(_ request: AppBridgeRequest, as type: T.Type) async throws -> T { + guard let route = try request.serverRoute() else { + throw DecodexAppBridgeError.invalidResponse("request is not supported by Decodex server") + } + + let baseURL = try await ensureServer() + let url = routeURL(baseURL: baseURL, route: route) + var urlRequest = URLRequest(url: url) + + urlRequest.httpMethod = route.method + urlRequest.timeoutInterval = 15 + if let body = route.body { + urlRequest.httpBody = body + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + guard let httpResponse = response as? HTTPURLResponse else { + throw DecodexAppBridgeError.invalidResponse("Decodex server returned a non-HTTP response") + } + if httpResponse.statusCode == 404, route.path.hasPrefix("api/accounts") { + throw DecodexAppBridgeError.invalidResponse( + "Decodex server at \(baseURL.absoluteString) does not support /api/accounts. Restart decodex serve with the bundled app version." + ) + } + guard 200..<300 ~= httpResponse.statusCode else { + let message = String(decoding: data, as: UTF8.self) + throw DecodexAppBridgeError.commandFailed( + Int32(httpResponse.statusCode), + message.isEmpty ? "Decodex server request failed" : message + ) + } + + return try JSONDecoder().decode(type, from: data) + } + + private func ensureServer() async throws -> URL { + if let serverBaseURL, await isLive(serverBaseURL) { + return serverBaseURL + } + + if let configured = configuredServerURL(), await isLive(configured) { + serverBaseURL = configured + + return configured + } + + if await isLive(defaultBaseURL) { + serverBaseURL = defaultBaseURL + + return defaultBaseURL + } + + try startBundledServer() + + for _ in 0..<40 { + if await isLive(defaultBaseURL) { + serverBaseURL = defaultBaseURL + + return defaultBaseURL + } + + try await Task.sleep(nanoseconds: 100_000_000) + } + + throw DecodexAppBridgeError.launchFailed("Decodex server did not become ready on \(defaultListenAddress)") + } + + private func configuredServerURL() -> URL? { + let value = ProcessInfo.processInfo.environment["DECODEX_APP_SERVER_URL"] ?? "" + + return value.isEmpty ? nil : URL(string: value) + } + + private func isLive(_ baseURL: URL) async -> Bool { + let url = baseURL.appendingPathComponent("livez") + var request = URLRequest(url: url) + + request.timeoutInterval = 0.75 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + let body = String(decoding: data, as: UTF8.self) + + return (response as? HTTPURLResponse)?.statusCode == 200 && body == "ok" + } catch { + return false + } + } + + private func startBundledServer() throws { + if let startedProcess, startedProcess.isRunning { + return + } + + let process = Process() + let nullDevice = FileHandle(forWritingAtPath: "/dev/null") + + process.executableURL = try decodexExecutableURL() + process.arguments = [ + "serve", + "--api-only", + "--listen-address", defaultListenAddress, + "--interval", "30s", + ] + process.standardOutput = nullDevice + process.standardError = nullDevice + + do { + try process.run() + } catch { + throw DecodexAppBridgeError.launchFailed(error.localizedDescription) + } + + startedProcess = process + } + + private func decodexExecutableURL() throws -> URL { + if let override = ProcessInfo.processInfo.environment["DECODEX_APP_DECODEX"], !override.isEmpty { + let overrideURL = URL(fileURLWithPath: override) + if FileManager.default.isExecutableFile(atPath: overrideURL.path) { + return overrideURL + } + } + + let bundledURL = Bundle.main.bundleURL + .appendingPathComponent("Contents") + .appendingPathComponent("Helpers") + .appendingPathComponent("decodex") + if FileManager.default.isExecutableFile(atPath: bundledURL.path) { + return bundledURL + } + + throw DecodexAppBridgeError.helperMissing( + "Bundled decodex server is missing. Rebuild the app bundle with apps/decodex-app/script/build_and_run.sh." + ) + } + + private func routeURL(baseURL: URL, route: ServerRoute) -> URL { + let parts = route.path.split(separator: "?", maxSplits: 1).map(String.init) + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + + components.path = "/" + parts[0] + if parts.count == 2 { + components.query = parts[1] + } + + return components.url! + } +} + +extension AppBridgeRequest { + func serverRoute() throws -> ServerRoute? { + switch operation { + case "account_list": + let suffix = forceRefresh == true ? "?refresh=1" : "" + + return ServerRoute(method: "GET", path: "api/accounts\(suffix)", body: nil) + case "account_select": + return try jsonPost("api/accounts/select") + case "account_clear": + return try jsonPost("api/accounts/clear") + case "account_logout": + return try jsonPost("api/accounts/logout") + case "account_import": + return try jsonPost("api/accounts/import") + case "account_use": + return try jsonPost("api/accounts/use") + default: + return nil + } + } + + private func jsonPost(_ path: String) throws -> ServerRoute { + ServerRoute(method: "POST", path: path, body: try JSONEncoder().encode(self)) + } +} diff --git a/apps/decodex-app/Sources/DecodexApp/Models.swift b/apps/decodex-app/Sources/DecodexApp/Models.swift index e20eb0d6..d6c0c844 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 usageProbeError: String? enum CodingKeys: String, CodingKey { case accountsPath = "accounts_path" @@ -15,6 +16,7 @@ struct AccountListResponse: Decodable { case codexAuth = "codex_auth" case control case accounts + case usageProbeError = "usage_probe_error" } } @@ -67,6 +69,19 @@ struct CodexAccount: Decodable, Identifiable, Equatable { let lastSelectedAtUnixEpoch: Int? let cooldownUntilUnixEpoch: Int? let note: String? + let planType: String? + let refreshStatus: String? + let checkedAtUnixEpoch: Int? + let primaryWindowSeconds: Int? + let primaryRemainingPercent: Int? + let primaryResetsAtUnixEpoch: Int? + let secondaryWindowSeconds: Int? + let secondaryRemainingPercent: Int? + let secondaryResetsAtUnixEpoch: Int? + let creditsHasCredits: Bool? + let creditsUnlimited: Bool? + let creditsBalance: String? + let rateLimitReachedType: String? var id: String { email ?? accountFingerprint @@ -77,6 +92,9 @@ struct CodexAccount: Decodable, Identifiable, Equatable { } var statusLabel: String { + if isUsageLimited { + return "Limited" + } if codexActive { return "Codex active" } @@ -86,6 +104,8 @@ struct CodexAccount: Decodable, Identifiable, Equatable { switch status { case "available": return "Ready" + case "usage_limited": return "Limited" + case "probe_failed": return "Usage unknown" case "expired": return "Refresh needed" case "disabled": return "Disabled" case "cooldown": return "Cooling" @@ -95,6 +115,12 @@ struct CodexAccount: Decodable, Identifiable, Equatable { } var statusTone: AccountTone { + if isUsageLimited { + return .danger + } + if status == "probe_failed" { + return .warning + } if codexActive { return .codexActive } @@ -109,6 +135,59 @@ struct CodexAccount: Decodable, Identifiable, Equatable { } } + var planLabel: String? { + guard let planType, !planType.isEmpty else { + return nil + } + + return planType.replacingOccurrences(of: "_", with: " ").capitalized + } + + var hasUsageWindowData: Bool { + primaryRemainingPercent != nil || secondaryRemainingPercent != nil + } + + var isUsageLimited: Bool { + if let reached = rateLimitReachedType, !reached.isEmpty { + return true + } + return status.contains("limit") + || primaryRemainingPercent == 0 + || secondaryRemainingPercent == 0 + } + + func windowLabel(seconds: Int?) -> String { + switch seconds { + case 18_000: return "5h" + case 604_800: return "7d" + case let value?: + let hours = value / 3_600 + if hours > 0 && value % 3_600 == 0 { + return "\(hours)h" + } + let days = value / 86_400 + if days > 0 && value % 86_400 == 0 { + return "\(days)d" + } + return "window" + case nil: + return "window" + } + } + + func usageTone(remainingPercent: Int?) -> AccountTone { + guard let remainingPercent else { + return .neutral + } + if remainingPercent <= 10 { + return .danger + } + if remainingPercent <= 25 { + return .warning + } + return .ready + } + enum CodingKeys: String, CodingKey { case accountFingerprint = "account_fingerprint" case email @@ -122,6 +201,19 @@ struct CodexAccount: Decodable, Identifiable, Equatable { case lastSelectedAtUnixEpoch = "last_selected_at_unix_epoch" case cooldownUntilUnixEpoch = "cooldown_until_unix_epoch" case note + case planType = "plan_type" + case refreshStatus = "refresh_status" + case checkedAtUnixEpoch = "checked_at_unix_epoch" + case primaryWindowSeconds = "primary_window_seconds" + case primaryRemainingPercent = "primary_remaining_percent" + case primaryResetsAtUnixEpoch = "primary_resets_at_unix_epoch" + case secondaryWindowSeconds = "secondary_window_seconds" + case secondaryRemainingPercent = "secondary_remaining_percent" + case secondaryResetsAtUnixEpoch = "secondary_resets_at_unix_epoch" + case creditsHasCredits = "credits_has_credits" + case creditsUnlimited = "credits_unlimited" + case creditsBalance = "credits_balance" + case rateLimitReachedType = "rate_limit_reached_type" } } diff --git a/apps/decodex-app/Sources/DecodexApp/SettingsView.swift b/apps/decodex-app/Sources/DecodexApp/SettingsView.swift index 2ee22f14..496f7b3a 100644 --- a/apps/decodex-app/Sources/DecodexApp/SettingsView.swift +++ b/apps/decodex-app/Sources/DecodexApp/SettingsView.swift @@ -15,9 +15,16 @@ struct SettingsView: View { Text(ProcessInfo.processInfo.environment["DECODEX_APP_HELPER"] ?? "Bundled decodex-app-helper") .textSelection(.enabled) } + + Section("Server") { + Text(ProcessInfo.processInfo.environment["DECODEX_APP_SERVER_URL"] ?? "http://127.0.0.1:8912") + .textSelection(.enabled) + Text(ProcessInfo.processInfo.environment["DECODEX_APP_DECODEX"] ?? "Bundled decodex") + .textSelection(.enabled) + } } .formStyle(.grouped) - .frame(width: 520, height: 250) + .frame(width: 520, height: 320) .padding() .task { await store.refresh() diff --git a/apps/decodex-app/script/build_and_run.sh b/apps/decodex-app/script/build_and_run.sh index a878e5d4..8b2c4752 100755 --- a/apps/decodex-app/script/build_and_run.sh +++ b/apps/decodex-app/script/build_and_run.sh @@ -5,6 +5,7 @@ MODE="${1:-run}" PRODUCT_NAME="Decodex App" EXECUTABLE_NAME="DecodexApp" HELPER_NAME="decodex-app-helper" +SERVER_NAME="decodex" BUNDLE_ID="space.decodex.app" MIN_SYSTEM_VERSION="14.0" DEFAULT_SIGN_IDENTITY="x@acg.box" @@ -21,6 +22,7 @@ APP_HELPERS="$APP_CONTENTS/Helpers" APP_RESOURCES="$APP_CONTENTS/Resources" APP_BINARY="$APP_MACOS/$EXECUTABLE_NAME" APP_HELPER_BINARY="$APP_HELPERS/$HELPER_NAME" +APP_SERVER_BINARY="$APP_HELPERS/$SERVER_NAME" INFO_PLIST="$APP_CONTENTS/Info.plist" APP_ICON_SOURCE="$WORKTREE_ROOT/assets/app-icon/generated/app-icon.icns" APP_ICON_NAME="AppIcon.icns" @@ -32,6 +34,7 @@ RUST_TARGET_DIR="" BUILD_ROOT="" BUILD_BINARY="" HELPER_BINARY="" +SERVER_BINARY="" RESOLVED_SIGN_IDENTITY="" SWIFT_CONFIGURATION="${DECODEX_APP_SWIFT_CONFIGURATION:-debug}" @@ -135,6 +138,11 @@ sign_staged_app_bundle() { --options runtime \ --sign "$RESOLVED_SIGN_IDENTITY" \ "$APP_HELPER_BINARY" + codesign \ + --force \ + --options runtime \ + --sign "$RESOLVED_SIGN_IDENTITY" \ + "$APP_SERVER_BINARY" entitlements_file="$BUILD_ROOT/$EXECUTABLE_NAME-entitlement.plist" if [[ -f "$entitlements_file" ]]; then @@ -158,23 +166,28 @@ sign_staged_app_bundle() { stage_app_bundle() { BUILD_ROOT="$(swift build --package-path "$ROOT_DIR" "${SWIFT_BUILD_FLAGS[@]}" --show-bin-path)" BUILD_BINARY="$BUILD_ROOT/$EXECUTABLE_NAME" - RUST_TARGET_DIR="$COMMON_ROOT/target" + RUST_TARGET_DIR="$WORKTREE_ROOT/target" swift build --package-path "$ROOT_DIR" "${SWIFT_BUILD_FLAGS[@]}" --product "$EXECUTABLE_NAME" CARGO_TARGET_DIR="$RUST_TARGET_DIR" cargo build -p decodex --bin "$HELPER_NAME" "${RUST_BUILD_FLAGS[@]}" + CARGO_TARGET_DIR="$RUST_TARGET_DIR" cargo build -p decodex --bin "$SERVER_NAME" "${RUST_BUILD_FLAGS[@]}" if [[ "$SWIFT_CONFIGURATION" == "release" ]]; then HELPER_BINARY="$RUST_TARGET_DIR/release/$HELPER_NAME" + SERVER_BINARY="$RUST_TARGET_DIR/release/$SERVER_NAME" else HELPER_BINARY="$RUST_TARGET_DIR/debug/$HELPER_NAME" + SERVER_BINARY="$RUST_TARGET_DIR/debug/$SERVER_NAME" fi rm -rf "$APP_BUNDLE" mkdir -p "$APP_MACOS" "$APP_HELPERS" "$APP_RESOURCES" cp "$BUILD_BINARY" "$APP_BINARY" cp "$HELPER_BINARY" "$APP_HELPER_BINARY" + cp "$SERVER_BINARY" "$APP_SERVER_BINARY" chmod +x "$APP_BINARY" chmod +x "$APP_HELPER_BINARY" + chmod +x "$APP_SERVER_BINARY" if [[ -f "$APP_ICON_SOURCE" ]]; then cp "$APP_ICON_SOURCE" "$APP_RESOURCES/$APP_ICON_NAME" fi diff --git a/apps/decodex/src/accounts.rs b/apps/decodex/src/accounts.rs index 4bb61362..bdcbe361 100644 --- a/apps/decodex/src/accounts.rs +++ b/apps/decodex/src/accounts.rs @@ -13,8 +13,10 @@ use serde::{Deserialize, Serialize}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ + agent::CodexAccountPool, prelude::{Result, eyre}, runtime, + state::CodexAccountActivitySummary, }; pub(crate) struct AccountLoginRequest { @@ -72,6 +74,18 @@ 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()?; + + response.hydrate_usage_from_path(&self.accounts_path, force_refresh); + + Ok(response) + } + fn select(&self, selector: &str) -> Result { let selector = selector.trim(); @@ -259,6 +273,7 @@ impl AccountStore { codex_auth: codex_auth.as_ref().map(AccountIdentity::summary), control, accounts, + usage_probe_error: None, }) } @@ -375,6 +390,29 @@ pub(crate) struct AccountListResponse { pub(crate) codex_auth: Option, pub(crate) control: AccountControlSummary, pub(crate) accounts: Vec, + pub(crate) usage_probe_error: Option, +} +impl AccountListResponse { + fn hydrate_usage_from_path(&mut self, accounts_path: &Path, force_refresh: bool) { + if self.accounts.is_empty() { + return; + } + + match CodexAccountPool::from_accounts_path(accounts_path) + .and_then(|pool| pool.account_activity_summaries_cached(force_refresh)) + { + Ok(summaries) => self.apply_usage_summaries(&summaries), + Err(error) => self.usage_probe_error = Some(error.to_string()), + } + } + + fn apply_usage_summaries(&mut self, summaries: &[CodexAccountActivitySummary]) { + for account in &mut self.accounts { + if let Some(summary) = matching_usage_summary(account, summaries) { + account.apply_usage_summary(summary); + } + } + } } #[derive(Serialize)] @@ -410,6 +448,44 @@ pub(crate) struct AccountSummary { pub(crate) last_selected_at_unix_epoch: Option, pub(crate) cooldown_until_unix_epoch: Option, pub(crate) note: Option, + pub(crate) plan_type: Option, + pub(crate) refresh_status: Option, + pub(crate) checked_at_unix_epoch: Option, + pub(crate) primary_window_seconds: Option, + pub(crate) primary_remaining_percent: Option, + pub(crate) primary_resets_at_unix_epoch: Option, + pub(crate) secondary_window_seconds: Option, + pub(crate) secondary_remaining_percent: Option, + pub(crate) secondary_resets_at_unix_epoch: Option, + pub(crate) credits_has_credits: Option, + pub(crate) credits_unlimited: Option, + pub(crate) credits_balance: Option, + pub(crate) rate_limit_reached_type: Option, +} +impl AccountSummary { + fn apply_usage_summary(&mut self, summary: &CodexAccountActivitySummary) { + self.status = summary.status.clone(); + self.plan_type = summary.plan_type.clone(); + self.refresh_status = Some(summary.refresh_status.clone()); + self.checked_at_unix_epoch = summary.checked_at_unix_epoch; + self.primary_window_seconds = summary.primary_window_seconds; + self.primary_remaining_percent = summary.primary_remaining_percent; + self.primary_resets_at_unix_epoch = summary.primary_resets_at_unix_epoch; + self.secondary_window_seconds = summary.secondary_window_seconds; + self.secondary_remaining_percent = summary.secondary_remaining_percent; + self.secondary_resets_at_unix_epoch = summary.secondary_resets_at_unix_epoch; + self.credits_has_credits = summary.credits_has_credits; + self.credits_unlimited = summary.credits_unlimited; + + self.credits_balance.clone_from(&summary.credits_balance); + self.rate_limit_reached_type.clone_from(&summary.rate_limit_reached_type); + + if summary.cooldown_until_unix_epoch.is_some() { + self.cooldown_until_unix_epoch = summary.cooldown_until_unix_epoch; + } + + self.note.clone_from(&summary.note); + } } #[derive(Clone)] @@ -615,6 +691,19 @@ impl AccountPoolRecord { last_selected_at_unix_epoch: self.last_selected_at_unix_epoch, cooldown_until_unix_epoch: self.cooldown_until_unix_epoch, note: Some(String::from("local account pool")), + plan_type: None, + refresh_status: None, + checked_at_unix_epoch: None, + primary_window_seconds: None, + primary_remaining_percent: None, + primary_resets_at_unix_epoch: None, + secondary_window_seconds: None, + secondary_remaining_percent: None, + secondary_resets_at_unix_epoch: None, + credits_has_credits: None, + credits_unlimited: None, + credits_balance: None, + rate_limit_reached_type: None, } } } @@ -720,6 +809,22 @@ 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) +} + +pub(crate) fn hydrate_account_list_usage(mut response: AccountListResponse) -> AccountListResponse { + let accounts_path = PathBuf::from(&response.accounts_path); + + response.hydrate_usage_from_path(&accounts_path, false); + + response +} + pub(crate) fn account_select(selector: &str) -> Result { AccountStore::global()?.select(selector) } @@ -762,6 +867,20 @@ pub(crate) fn account_login( import_result } +fn matching_usage_summary<'a>( + account: &AccountSummary, + summaries: &'a [CodexAccountActivitySummary], +) -> Option<&'a CodexAccountActivitySummary> { + summaries.iter().find(|summary| { + account + .email + .as_deref() + .zip(summary.email.as_deref()) + .is_some_and(|(account_email, summary_email)| account_email == summary_email) + || account.account_fingerprint == summary.account_fingerprint + }) +} + fn run_codex_device_login( codex_bin: &str, temp_home: &Path, @@ -1124,7 +1243,10 @@ mod tests { use tempfile::TempDir; - use crate::accounts::{AccountPoolRecord, AccountStore, AuthDotJson, CodexTokenData}; + use crate::{ + accounts::{AccountPoolRecord, AccountStore, AuthDotJson, CodexTokenData}, + state::CodexAccountActivitySummary, + }; #[test] fn imports_auth_json_without_printing_tokens() { @@ -1262,6 +1384,53 @@ mod tests { assert!(response.accounts[1].codex_active); } + #[test] + fn list_response_merges_usage_snapshot() { + 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", + )]) + .expect("records should save"); + + let mut response = store.list().expect("account list should load"); + + response.apply_usage_summaries(&[CodexAccountActivitySummary { + account_fingerprint: String::from("...123456"), + email: Some(String::from("copy@example.com")), + 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), + primary_window_seconds: Some(18_000), + primary_remaining_percent: Some(72), + primary_resets_at_unix_epoch: Some(1_800_018_000), + secondary_window_seconds: Some(604_800), + secondary_remaining_percent: Some(91), + secondary_resets_at_unix_epoch: Some(1_800_604_800), + credits_has_credits: Some(true), + credits_unlimited: Some(false), + credits_balance: Some(String::from("9.99")), + rate_limit_reached_type: None, + ..CodexAccountActivitySummary::default() + }]); + + assert_eq!(response.accounts[0].plan_type.as_deref(), Some("pro")); + assert_eq!(response.accounts[0].primary_window_seconds, Some(18_000)); + assert_eq!(response.accounts[0].primary_remaining_percent, Some(72)); + 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")); + } + fn account_record( email: &str, account_id: &str, diff --git a/apps/decodex/src/agent/codex_accounts.rs b/apps/decodex/src/agent/codex_accounts.rs index 65d2424d..80593775 100644 --- a/apps/decodex/src/agent/codex_accounts.rs +++ b/apps/decodex/src/agent/codex_accounts.rs @@ -5,7 +5,7 @@ use std::{ fs::{self, File, OpenOptions}, path::{Path, PathBuf}, process, - sync::Mutex, + sync::{Mutex, OnceLock}, time::Duration, }; @@ -24,6 +24,9 @@ const CODEX_USER_AGENT: &str = "codex-cli"; const CHATGPT_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const HTTP_TIMEOUT: Duration = Duration::from_secs(10); const TOKEN_REFRESH_INTERVAL_SECONDS: i64 = 8 * 24 * 60 * 60; +const ACCOUNT_ACTIVITY_CACHE_TTL_SECONDS: i64 = 60; + +static ACCOUNT_ACTIVITY_CACHE: OnceLock>> = OnceLock::new(); pub(crate) trait CodexAccountProvider { fn select_account(&self) -> crate::prelude::Result; @@ -53,6 +56,10 @@ impl CodexAccountPool { ) } + pub(crate) fn from_accounts_path(path: impl AsRef) -> crate::prelude::Result { + Self::new_with_fixed_account(path, DEFAULT_USAGE_ENDPOINT, DEFAULT_REFRESH_ENDPOINT, None) + } + fn new_with_fixed_account( path: impl AsRef, usage_endpoint: impl Into, @@ -91,6 +98,50 @@ impl CodexAccountPool { self.probe_account_activity_summaries(&mut records) } + pub(crate) fn account_activity_summaries_cached( + &self, + force_refresh: bool, + ) -> crate::prelude::Result> { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let cache_key = self.cache_key(); + let cache = ACCOUNT_ACTIVITY_CACHE.get_or_init(|| Mutex::new(None)); + + if !force_refresh { + let cached = cache + .lock() + .map_err(|error| eyre::eyre!("Codex account usage cache is poisoned: {error}"))?; + + if let Some(entry) = cached.as_ref() + && entry.key == cache_key + && now.saturating_sub(entry.checked_at_unix_epoch) + < ACCOUNT_ACTIVITY_CACHE_TTL_SECONDS + { + return Ok(entry.summaries.clone()); + } + } + + let summaries = self.account_activity_summaries()?; + let mut cached = cache + .lock() + .map_err(|error| eyre::eyre!("Codex account usage cache is poisoned: {error}"))?; + + *cached = Some(AccountActivityCacheEntry { + key: cache_key, + checked_at_unix_epoch: now, + summaries: summaries.clone(), + }); + + Ok(summaries) + } + + fn cache_key(&self) -> AccountActivityCacheKey { + AccountActivityCacheKey { + path: self.path.clone(), + usage_endpoint: self.usage_endpoint.clone(), + refresh_endpoint: self.refresh_endpoint.clone(), + } + } + fn lock_records(&self) -> crate::prelude::Result { let parent = self.path.parent().ok_or_else(|| { eyre::eyre!( @@ -663,6 +714,20 @@ impl CodexAccountLogin { } } +#[derive(Clone, Eq, PartialEq)] +struct AccountActivityCacheKey { + path: PathBuf, + usage_endpoint: String, + refresh_endpoint: String, +} + +#[derive(Clone)] +struct AccountActivityCacheEntry { + key: AccountActivityCacheKey, + checked_at_unix_epoch: i64, + summaries: Vec, +} + struct AccountPoolFileLock { _file: File, } diff --git a/apps/decodex/src/app_bridge.rs b/apps/decodex/src/app_bridge.rs index 8170feaa..4b583b57 100644 --- a/apps/decodex/src/app_bridge.rs +++ b/apps/decodex/src/app_bridge.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ - accounts::{self, AccountLoginRequest, AccountUseRequest}, + accounts::{self, AccountListResponse, AccountLoginRequest, AccountUseRequest}, prelude::{Result, eyre}, }; @@ -17,15 +17,33 @@ use crate::{ #[serde(tag = "operation", rename_all = "snake_case")] enum AppBridgeRequest { #[serde(rename = "account_list")] - List, + List { + #[serde(default)] + include_usage: bool, + }, #[serde(rename = "account_select")] - Select { selector: String }, + Select { + selector: String, + #[serde(default)] + include_usage: bool, + }, #[serde(rename = "account_clear")] - Clear, + Clear { + #[serde(default)] + include_usage: bool, + }, #[serde(rename = "account_logout")] - Logout { selector: String }, + Logout { + selector: String, + #[serde(default)] + include_usage: bool, + }, #[serde(rename = "account_import")] - Import { auth_json_path: String }, + Import { + auth_json_path: String, + #[serde(default)] + include_usage: bool, + }, #[serde(rename = "account_use")] Use { selector: String, auth_json_path: Option }, #[serde(rename = "account_login")] @@ -34,6 +52,8 @@ enum AppBridgeRequest { codex_bin: String, #[serde(default)] keep_temp_home: bool, + #[serde(default)] + include_usage: bool, }, } @@ -74,14 +94,22 @@ pub fn run() -> Result<()> { fn handle_request(request: AppBridgeRequest) -> Result<()> { match request { - AppBridgeRequest::List => emit_result(&accounts::account_list()?), - AppBridgeRequest::Select { selector } => emit_result(&accounts::account_select(&selector)?), - AppBridgeRequest::Clear => emit_result(&accounts::account_clear()?), - AppBridgeRequest::Logout { selector } => emit_result(&accounts::account_logout(&selector)?), - AppBridgeRequest::Import { auth_json_path } => { + AppBridgeRequest::List { include_usage } => + if include_usage { + emit_result(&accounts::account_list_with_usage()?) + } else { + emit_result(&accounts::account_list()?) + }, + AppBridgeRequest::Select { selector, include_usage } => + emit_account_list_result(accounts::account_select(&selector)?, include_usage), + AppBridgeRequest::Clear { include_usage } => + emit_account_list_result(accounts::account_clear()?, include_usage), + AppBridgeRequest::Logout { selector, include_usage } => + emit_account_list_result(accounts::account_logout(&selector)?, include_usage), + AppBridgeRequest::Import { auth_json_path, include_usage } => { let auth_json_path = PathBuf::from(auth_json_path); - emit_result(&accounts::account_import(&auth_json_path)?) + emit_account_list_result(accounts::account_import(&auth_json_path)?, include_usage) }, AppBridgeRequest::Use { selector, auth_json_path } => emit_result(&accounts::account_use(&AccountUseRequest { @@ -89,7 +117,7 @@ fn handle_request(request: AppBridgeRequest) -> Result<()> { auth_json_path: auth_json_path.map(Into::into), json: true, })?), - AppBridgeRequest::Login { codex_bin, keep_temp_home } => { + AppBridgeRequest::Login { codex_bin, keep_temp_home, include_usage } => { let response = accounts::account_login( &AccountLoginRequest { codex_bin, keep_temp_home }, |chunk| { @@ -99,11 +127,18 @@ fn handle_request(request: AppBridgeRequest) -> Result<()> { }, )?; - emit_result(&response) + emit_account_list_result(response, include_usage) }, } } +fn emit_account_list_result(response: AccountListResponse, include_usage: bool) -> Result<()> { + let response = + if include_usage { accounts::hydrate_account_list_usage(response) } else { response }; + + emit_result(&response) +} + fn emit_result(payload: &T) -> Result<()> where T: Serialize, diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index 5e186fef..546b723a 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -380,6 +380,9 @@ struct ServeCommand { /// Operator UI listen address. #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8912")] listen_address: String, + /// Serve only local operator HTTP/API endpoints without polling or dispatching projects. + #[arg(long)] + api_only: bool, } impl ServeCommand { fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { @@ -387,6 +390,7 @@ impl ServeCommand { config_path, poll_interval: self.interval, listen_address: &self.listen_address, + api_only: self.api_only, }) } } @@ -863,11 +867,20 @@ mod tests { assert_eq!(cli.config, Some(PathBuf::from("./project.toml"))); assert!(matches!( cli.command, - Command::Serve(ServeCommand { interval, listen_address }) - if interval == Duration::from_secs(30) && listen_address == "127.0.0.1:9000" + Command::Serve(ServeCommand { interval, listen_address, api_only }) + if interval == Duration::from_secs(30) + && listen_address == "127.0.0.1:9000" + && !api_only )); } + #[test] + fn parses_serve_api_only() { + let cli = Cli::parse_from(["decodex", "serve", "--api-only"]); + + assert!(matches!(cli.command, Command::Serve(ServeCommand { api_only: true, .. }))); + } + #[test] fn parses_project_add() { let cli = Cli::parse_from(["decodex", "project", "add", "./project.toml"]); diff --git a/apps/decodex/src/orchestrator.rs b/apps/decodex/src/orchestrator.rs index 42599f8a..2fd7ea1b 100644 --- a/apps/decodex/src/orchestrator.rs +++ b/apps/decodex/src/orchestrator.rs @@ -79,6 +79,7 @@ const OPERATOR_DASHBOARD_ENDPOINT_PATH: &str = "/"; const OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH: &str = "/dashboard"; const OPERATOR_DASHBOARD_WS_ENDPOINT_PATH: &str = "/dashboard/control"; const OPERATOR_LIVE_ENDPOINT_PATH: &str = "/livez"; +const OPERATOR_ACCOUNTS_ENDPOINT_PATH: &str = "/api/accounts"; const OPERATOR_STATE_MAX_REQUEST_BYTES: usize = 8_192; const OPERATOR_DASHBOARD_WS_CLIENT_MESSAGE_MAX_BYTES: usize = 64 * 1_024; const OPERATOR_STATE_HEADER_TERMINATOR: &[u8] = b"\r\n\r\n"; diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index a33e7a7d..7754df3c 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -105,6 +105,11 @@ pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { if request.poll_interval.is_zero() { eyre::bail!("serve interval must be greater than zero."); } + if request.api_only && request.config_path.is_some() { + eyre::bail!( + "serve --api-only does not accept --config because it must not register or poll projects." + ); + } validate_daemon_runtime()?; @@ -132,6 +137,7 @@ pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { listen_address = %operator_state_endpoint.listen_address(), path = OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH, ws_path = OPERATOR_DASHBOARD_WS_ENDPOINT_PATH, + api_only = request.api_only, runtime_db_path = %runtime_db_path.display(), global_config_path = %global_config_path.display(), project_config_dir = %project_config_dir.display(), @@ -140,7 +146,11 @@ pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { loop { let tick_started_at = Instant::now(); - let snapshot = run_control_plane_tick(&state_store, &mut project_runtimes)?; + let snapshot = if request.api_only { + run_control_plane_api_only_tick(&state_store)? + } else { + run_control_plane_tick(&state_store, &mut project_runtimes)? + }; if let Err(error) = operator_state_endpoint.publish_snapshot(&snapshot) { let _ = error; @@ -376,6 +386,25 @@ fn run_control_plane_tick( })) } +fn run_control_plane_api_only_tick(state_store: &StateStore) -> Result { + let registered_projects = state_store.list_projects()?; + let mut snapshot = empty_control_plane_snapshot(DEFAULT_OPERATOR_DASHBOARD_RUN_LIMIT); + + if !registered_projects.iter().any(ProjectRegistration::enabled) { + add_operator_snapshot_warning(&mut snapshot, "no_enabled_projects"); + } + + add_operator_snapshot_warning(&mut snapshot, "automation_disabled"); + + snapshot.projects = registered_projects + .iter() + .map(operator_project_status_from_api_only_registration) + .collect(); + snapshot.account_control = global_codex_account_control_status(); + + Ok(snapshot) +} + fn collect_control_plane_snapshot( registered_projects: Vec, mut run_enabled_project_tick: F, @@ -779,3 +808,27 @@ fn operator_project_status_from_registration( warning_count, } } + +fn operator_project_status_from_api_only_registration( + project: &ProjectRegistration, +) -> OperatorProjectStatus { + OperatorProjectStatus { + project_id: project.service_id().to_owned(), + config_path: project.config_path().display().to_string(), + repo_root: project.repo_root().display().to_string(), + enabled: project.enabled(), + active_run_count: 0, + queued_candidate_count: 0, + post_review_lane_count: 0, + retained_worktree_count: 0, + waiting_lane_count: 0, + attention_count: 0, + connector_state: if project.enabled() { + String::from("api_only") + } else { + String::from("disabled") + }, + last_activity_at: None, + warning_count: usize::from(project.enabled()), + } +} diff --git a/apps/decodex/src/orchestrator/operator_http.rs b/apps/decodex/src/orchestrator/operator_http.rs index 335e1149..075c9a3b 100644 --- a/apps/decodex/src/orchestrator/operator_http.rs +++ b/apps/decodex/src/orchestrator/operator_http.rs @@ -3,6 +3,9 @@ use base64::Engine as _; use sha1::{Digest as _, Sha1}; use libc::SIGTERM; +use crate::accounts; +use crate::accounts::AccountUseRequest; + #[cfg(test)] type DashboardRunInterrupterForTest = fn(u32) -> Result<()>; @@ -29,6 +32,12 @@ enum OperatorRequestRoute { DashboardLogoTouchPng, DashboardWs, Live, + AccountList { force_refresh: bool }, + AccountSelect, + AccountClear, + AccountLogout, + AccountImport, + AccountUse, } enum DashboardClientFrame { @@ -114,6 +123,12 @@ struct DashboardClientMessage { account_selector: Option, } +#[derive(Deserialize)] +struct OperatorAccountRequest { + selector: Option, + auth_json_path: Option, +} + struct DashboardControlAck<'a> { request_id: Option<&'a str>, action: &'a str, @@ -206,6 +221,13 @@ fn handle_operator_state_endpoint_connection( }, }; + if operator_request_route_is_account_api(&route) { + let response = build_operator_account_http_response(route, &request); + + stream.write_all(&response)?; + + return Ok(()); + } if route == OperatorRequestRoute::DashboardWs { handle_operator_dashboard_websocket_connection( stream, @@ -1238,12 +1260,18 @@ fn read_operator_state_request_headers(stream: &mut TcpStream) -> Result let mut request = Vec::with_capacity(1_024); loop { - if request + if let Some(header_end) = request .windows(OPERATOR_STATE_HEADER_TERMINATOR.len()) - .any(|window| window == OPERATOR_STATE_HEADER_TERMINATOR) + .position(|window| window == OPERATOR_STATE_HEADER_TERMINATOR) { - return Ok(request); + let body_offset = header_end + OPERATOR_STATE_HEADER_TERMINATOR.len(); + let content_length = operator_http_content_length(&request[..body_offset])?; + + if request.len() >= body_offset + content_length { + return Ok(request); + } } + if request.len() >= OPERATOR_STATE_MAX_REQUEST_BYTES { eyre::bail!("Operator state endpoint request headers exceeded the size limit."); } @@ -1261,6 +1289,24 @@ fn read_operator_state_request_headers(stream: &mut TcpStream) -> Result } } +fn operator_http_content_length(headers: &[u8]) -> Result { + let headers = String::from_utf8_lossy(headers); + + for line in headers.lines().skip(1) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + + if name.trim().eq_ignore_ascii_case("Content-Length") { + return value.trim().parse::().map_err(|error| { + eyre::eyre!("Operator HTTP request Content-Length was invalid: {error}") + }); + } + } + + Ok(0) +} + #[cfg(test)] fn build_operator_state_http_response(request: &[u8]) -> Result> { let route = match parse_operator_state_request_route(request) { @@ -1268,9 +1314,129 @@ fn build_operator_state_http_response(request: &[u8]) -> Result> { Err(response) => return Ok(response), }; + if operator_request_route_is_account_api(&route) { + return Ok(build_operator_account_http_response(route, request)); + } + Ok(build_operator_state_http_response_for_route(route)) } +fn operator_request_route_is_account_api(route: &OperatorRequestRoute) -> bool { + matches!( + route, + OperatorRequestRoute::AccountList { .. } + | OperatorRequestRoute::AccountSelect + | OperatorRequestRoute::AccountClear + | OperatorRequestRoute::AccountLogout + | OperatorRequestRoute::AccountImport + | OperatorRequestRoute::AccountUse + ) +} + +fn build_operator_account_http_response(route: OperatorRequestRoute, request: &[u8]) -> Vec { + match operator_account_http_response_body(route, request) { + Ok(body) => http_response_bytes("200 OK", "application/json", &body), + Err(error) => { + let body = serde_json::to_vec(&json!({ "error": error.to_string() })) + .unwrap_or_else(|_| br#"{"error":"account request failed"}"#.to_vec()); + + http_response_bytes("400 Bad Request", "application/json", &body) + }, + } +} + +fn operator_account_http_response_body( + route: OperatorRequestRoute, + request: &[u8], +) -> Result> { + match route { + OperatorRequestRoute::AccountList { force_refresh } => + serde_json::to_vec(&accounts::account_list_with_cached_usage(force_refresh)?) + .map_err(Into::into), + OperatorRequestRoute::AccountSelect => { + let selector = operator_account_request_selector(request)?; + let response = accounts::hydrate_account_list_usage( + accounts::account_select(&selector)?, + ); + + serde_json::to_vec(&response).map_err(Into::into) + }, + OperatorRequestRoute::AccountClear => { + let response = + accounts::hydrate_account_list_usage(accounts::account_clear()?); + + serde_json::to_vec(&response).map_err(Into::into) + }, + OperatorRequestRoute::AccountLogout => { + let selector = operator_account_request_selector(request)?; + let response = accounts::hydrate_account_list_usage( + accounts::account_logout(&selector)?, + ); + + serde_json::to_vec(&response).map_err(Into::into) + }, + OperatorRequestRoute::AccountImport => { + let body = operator_account_request_body(request)?; + let auth_json_path = body + .auth_json_path + .as_deref() + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| eyre::eyre!("Account import requires auth_json_path."))?; + let response = accounts::hydrate_account_list_usage( + accounts::account_import(Path::new(auth_json_path))?, + ); + + serde_json::to_vec(&response).map_err(Into::into) + }, + OperatorRequestRoute::AccountUse => { + let body = operator_account_request_body(request)?; + let selector = body + .selector + .as_deref() + .filter(|selector| !selector.trim().is_empty()) + .ok_or_else(|| eyre::eyre!("Account use requires selector."))?; + let auth_json_path = body.auth_json_path.as_deref().map(PathBuf::from); + let response = accounts::account_use(&AccountUseRequest { + selector: selector.to_owned(), + auth_json_path, + json: true, + })?; + + serde_json::to_vec(&response).map_err(Into::into) + }, + _ => eyre::bail!("Unsupported account API route."), + } +} + +fn operator_account_request_selector(request: &[u8]) -> Result { + let body = operator_account_request_body(request)?; + + body.selector + .filter(|selector| !selector.trim().is_empty()) + .ok_or_else(|| eyre::eyre!("Account request requires selector.")) +} + +fn operator_account_request_body(request: &[u8]) -> Result { + let body = operator_http_request_body(request)?; + + if body.is_empty() { + return Ok(OperatorAccountRequest { selector: None, auth_json_path: None }); + } + + serde_json::from_slice(body) + .map_err(|error| eyre::eyre!("Account request body was not valid JSON: {error}")) +} + +fn operator_http_request_body(request: &[u8]) -> Result<&[u8]> { + let body_offset = request + .windows(OPERATOR_STATE_HEADER_TERMINATOR.len()) + .position(|window| window == OPERATOR_STATE_HEADER_TERMINATOR) + .map(|index| index + OPERATOR_STATE_HEADER_TERMINATOR.len()) + .ok_or_else(|| eyre::eyre!("Operator HTTP request omitted header terminator."))?; + + Ok(&request[body_offset..]) +} + fn build_operator_state_http_response_for_route(route: OperatorRequestRoute) -> Vec { match route { OperatorRequestRoute::Dashboard => { @@ -1289,6 +1455,13 @@ fn build_operator_state_http_response_for_route(route: OperatorRequestRoute) -> OperatorRequestRoute::Live => { http_response_bytes("200 OK", "text/plain; charset=utf-8", b"ok") }, + OperatorRequestRoute::AccountList { .. } + | OperatorRequestRoute::AccountSelect + | OperatorRequestRoute::AccountClear + | OperatorRequestRoute::AccountLogout + | OperatorRequestRoute::AccountImport + | OperatorRequestRoute::AccountUse => + http_response_bytes("405 Method Not Allowed", "text/plain; charset=utf-8", b"method not allowed"), } } @@ -1319,38 +1492,56 @@ fn parse_operator_state_request_route( b"missing path", )); }; - - if method != "GET" { - return Err(http_response_bytes( - "405 Method Not Allowed", - "text/plain; charset=utf-8", - b"method not allowed", - )); - } - let path_without_query = path .split_once('?') .map_or(path, |(path_without_query, _)| path_without_query); + let query = path.split_once('?').map(|(_, query)| query).unwrap_or_default(); let normalized_path = path_without_query .split_once('#') .map_or(path_without_query, |(path_without_fragment, _)| path_without_fragment); - match normalized_path { - OPERATOR_DASHBOARD_ENDPOINT_PATH | OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH => + match (method, normalized_path) { + ("GET", OPERATOR_DASHBOARD_ENDPOINT_PATH | OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH) => Ok(OperatorRequestRoute::Dashboard), - "/assets/icon.png" => Ok(OperatorRequestRoute::DashboardIconPng), - "/assets/logo.ico" => Ok(OperatorRequestRoute::DashboardLogoIco), - "/assets/logo-touch.png" => Ok(OperatorRequestRoute::DashboardLogoTouchPng), - OPERATOR_DASHBOARD_WS_ENDPOINT_PATH => Ok(OperatorRequestRoute::DashboardWs), - OPERATOR_LIVE_ENDPOINT_PATH => Ok(OperatorRequestRoute::Live), - _ => Err(http_response_bytes( - "404 Not Found", + ("GET", "/assets/icon.png") => Ok(OperatorRequestRoute::DashboardIconPng), + ("GET", "/assets/logo.ico") => Ok(OperatorRequestRoute::DashboardLogoIco), + ("GET", "/assets/logo-touch.png") => Ok(OperatorRequestRoute::DashboardLogoTouchPng), + ("GET", OPERATOR_DASHBOARD_WS_ENDPOINT_PATH) => Ok(OperatorRequestRoute::DashboardWs), + ("GET", OPERATOR_LIVE_ENDPOINT_PATH) => Ok(OperatorRequestRoute::Live), + ("GET", OPERATOR_ACCOUNTS_ENDPOINT_PATH) => Ok(OperatorRequestRoute::AccountList { + force_refresh: operator_query_has_flag(query, "refresh"), + }), + ("POST", "/api/accounts/select") => Ok(OperatorRequestRoute::AccountSelect), + ("POST", "/api/accounts/clear") => Ok(OperatorRequestRoute::AccountClear), + ("POST", "/api/accounts/logout") => Ok(OperatorRequestRoute::AccountLogout), + ("POST", "/api/accounts/import") => Ok(OperatorRequestRoute::AccountImport), + ("POST", "/api/accounts/use") => Ok(OperatorRequestRoute::AccountUse), + (_, OPERATOR_DASHBOARD_ENDPOINT_PATH + | OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH + | OPERATOR_DASHBOARD_WS_ENDPOINT_PATH + | OPERATOR_LIVE_ENDPOINT_PATH + | OPERATOR_ACCOUNTS_ENDPOINT_PATH + | "/api/accounts/select" + | "/api/accounts/clear" + | "/api/accounts/logout" + | "/api/accounts/import" + | "/api/accounts/use") => Err(http_response_bytes( + "405 Method Not Allowed", "text/plain; charset=utf-8", - b"not found", + b"method not allowed", )), + _ => Err(http_response_bytes("404 Not Found", "text/plain; charset=utf-8", b"not found")), } } +fn operator_query_has_flag(query: &str, name: &str) -> bool { + query.split('&').any(|part| { + let key = part.split_once('=').map_or(part, |(key, _)| key); + + key == name + }) +} + fn http_response_bytes(status_line: &str, content_type: &str, body: &[u8]) -> Vec { http_response_bytes_with_headers(status_line, content_type, &[], body) } diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index 81ac144b..030d4a58 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -844,7 +844,7 @@ fn codex_account_activity_summaries( }; match CodexAccountPool::from_config(accounts_config) - .and_then(|pool| pool.account_activity_summaries()) + .and_then(|pool| pool.account_activity_summaries_cached(false)) { Ok(accounts) => accounts, Err(error) => { diff --git a/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs b/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs index a0ae94c6..81a91162 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs @@ -32,6 +32,39 @@ fn control_plane_snapshot_lists_disabled_registered_projects() { assert!(project_runtimes.is_empty(), "disabled projects should not be ticked"); } +#[test] +fn control_plane_api_only_snapshot_does_not_tick_enabled_projects() { + let (temp_dir, config, _workflow) = temp_project_layout(); + let _home_guard = + TestEnvVarGuard::set("HOME", temp_dir.path().to_str().expect("home should be utf-8")); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let registration = ProjectRegistration::from_config( + config.service_id(), + &service_config_path(config.repo_root()), + &config, + true, + "test-fingerprint", + ); + + state_store.upsert_project(®istration).expect("project should register"); + + let snapshot = orchestrator::run_control_plane_api_only_tick(&state_store) + .expect("api-only snapshot should build"); + let project = snapshot.projects.first().expect("enabled project should be listed"); + + assert_eq!(snapshot.projects.len(), 1); + assert_eq!(project.project_id, "pubfi"); + assert!(project.enabled); + assert_eq!(project.connector_state, "api_only"); + assert_eq!(project.active_run_count, 0); + assert_eq!(project.queued_candidate_count, 0); + assert_eq!(project.warning_count, 1); + assert!(snapshot.active_runs.is_empty()); + assert!(snapshot.queued_candidates.is_empty()); + assert!(snapshot.warnings.contains(&String::from("automation_disabled"))); + assert!(!snapshot.warnings.contains(&String::from("no_enabled_projects"))); +} + #[test] fn control_plane_snapshot_aggregates_top_level_lanes_for_all_registered_projects() { let (active_temp_dir, active_config, _active_workflow) = temp_project_layout(); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/http.rs b/apps/decodex/src/orchestrator/tests/operator/status/http.rs index 9376af3a..83e1c959 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/http.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/http.rs @@ -1229,6 +1229,35 @@ fn operator_state_endpoint_serves_only_liveness_probe() { assert!(live_response.ends_with("ok")); } +#[test] +fn operator_state_endpoint_serves_account_api_snapshot() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let _home_guard = + TestEnvVarGuard::set("HOME", temp_dir.path().to_str().expect("temp path should be UTF-8")); + let response = String::from_utf8( + orchestrator::build_operator_state_http_response( + b"GET /api/accounts?refresh=1 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ) + .expect("account response should build"), + ) + .expect("account response should be utf-8"); + + assert!(response.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(response.contains("Content-Type: application/json")); + + let body = response + .split_once("\r\n\r\n") + .map(|(_, body)| body) + .expect("account response should include body"); + let data: Value = serde_json::from_str(body).expect("account response should be json"); + + assert_eq!(data["accounts"], serde_json::json!([])); + assert_eq!(data["usage_probe_error"], Value::Null); + assert!(data["accounts_path"].as_str().is_some_and(|path| { + path.ends_with(".codex/decodex/accounts.jsonl") + })); +} + #[test] fn operator_state_endpoint_rejects_removed_http_snapshot_routes() { for removed_path in ["/state", "/readyz"] { diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index d8d4a0e8..f749b7a0 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -32,6 +32,7 @@ pub(crate) struct ServeRequest<'a> { pub(crate) config_path: Option<&'a Path>, pub(crate) poll_interval: Duration, pub(crate) listen_address: &'a str, + pub(crate) api_only: bool, } /// Agent-readable runtime diagnosis request.