From cfcf3f051899c895e6c8e58e768b52cda8eb7dad Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 21 May 2026 14:53:17 +0800 Subject: [PATCH 1/2] {"schema":"decodex/commit/1","summary":"Add Decodex app operator metrics","authority":"manual"} --- .../Sources/DecodexApp/AccountPanelView.swift | 272 ++++++++++++- .../Sources/DecodexApp/AccountStore.swift | 54 +++ .../Sources/DecodexApp/DecodexApp.swift | 1 + .../Sources/DecodexApp/DecodexAppBridge.swift | 2 + .../DecodexApp/DecodexServerBridge.swift | 8 + .../DecodexApp/OperatorSnapshotModels.swift | 373 ++++++++++++++++++ apps/decodex/src/orchestrator.rs | 1 + .../decodex/src/orchestrator/operator_http.rs | 42 ++ .../tests/operator/status/dashboard.rs | 19 + 9 files changed, 769 insertions(+), 3 deletions(-) create mode 100644 apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 33e03d58..d4d59f44 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -274,6 +274,14 @@ struct AccountPanelView: View { header accountSummary + if let snapshot = store.operatorSnapshot, snapshot.shouldDisplayInPanel { + OperatorStatusStripView( + snapshot: snapshot, + updatedAt: store.operatorSnapshotUpdatedAt, + refreshIntervalSeconds: AccountStore.operatorSnapshotRefreshIntervalSeconds + ) + } + if let notice = store.notice { NoticeView(text: notice) } @@ -318,9 +326,11 @@ struct AccountPanelView: View { .font(PanelFont.headerSubtitle) .foregroundStyle(PanelPalette.secondaryText(colorScheme)) .lineLimit(1) + .minimumScaleFactor(0.9) } + .layoutPriority(1) - Spacer() + Spacer(minLength: 4) HStack(spacing: 5) { PanelIconButtonView( @@ -347,6 +357,18 @@ struct AccountPanelView: View { help: store.fastModeEnabled ? "Turn fast mode off" : "Turn fast mode on" ) + PanelIconButtonView( + symbol: "safari", + tint: PanelPalette.actionBlue(colorScheme), + isActive: false, + action: { + Task { + await store.openWebUI() + } + }, + help: "Open Decodex WebUI" + ) + if hasFixedSelection { PanelIconButtonView( symbol: "shuffle", @@ -458,8 +480,12 @@ struct AccountPanelView: View { private var accountRows: some View { VStack(spacing: 0) { ForEach(Array(store.accounts.enumerated()), id: \.element.id) { index, account in + let runs = operatorRuns(for: account) + AccountRowView( account: account, + runs: runs, + runCount: operatorRunCount(for: account), emailsHidden: emailsHidden, showsDivider: index < store.accounts.count - 1, isLogoutArmed: armedLogoutAccountID == account.id, @@ -537,7 +563,7 @@ struct AccountPanelView: View { private var accountListHeight: CGFloat { let rows = store.accounts.reduce(CGFloat(0)) { total, account in - total + (account.hasUsageSummary ? 98 : 46) + total + accountRowHeight(for: account) } let spacing = CGFloat(max(store.accounts.count - 1, 0)) * 5 + 2 @@ -550,7 +576,7 @@ struct AccountPanelView: View { private var headerSubtitle: String { let count = store.accounts.count let accountLabel = "\(count) account\(count == 1 ? "" : "s")" - return hasFixedSelection ? "\(accountLabel) / run route set" : "\(accountLabel) / balanced runs" + return hasFixedSelection ? "\(accountLabel) / routed" : "\(accountLabel) / balanced" } private var emailsHidden: Bool { @@ -561,6 +587,21 @@ struct AccountPanelView: View { account.panelDisplayName(emailsHidden: emailsHidden) } + private func operatorRuns(for account: CodexAccount) -> [OperatorRunStatus] { + store.operatorSnapshot?.activeRuns(for: account) ?? [] + } + + private func operatorRunCount(for account: CodexAccount) -> Int? { + store.operatorSnapshot?.runningCount(for: account) + } + + private func accountRowHeight(for account: CodexAccount) -> CGFloat { + let base: CGFloat = account.hasUsageSummary ? 98 : 46 + let runSignal: CGFloat = operatorRuns(for: account).isEmpty ? 0 : 22 + + return base + runSignal + } + private func account(matching selector: String) -> CodexAccount? { store.accounts.first { account in account.matchesSelector(selector) @@ -608,6 +649,8 @@ struct AccountPanelView: View { struct AccountRowView: View { let account: CodexAccount + let runs: [OperatorRunStatus] + let runCount: Int? let emailsHidden: Bool let showsDivider: Bool let isLogoutArmed: Bool @@ -653,6 +696,23 @@ struct AccountRowView: View { .lineLimit(1) .fixedSize(horizontal: true, vertical: false) } + + if let runCount { + Text("ยท") + .font(PanelFont.accountDetail) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.62)) + .fixedSize(horizontal: true, vertical: false) + + Text(runCount == 1 ? "1 running" : "\(runCount) running") + .font(PanelFont.accountDetail) + .foregroundStyle( + runCount > 0 + ? PanelPalette.routeAccent(colorScheme) + : PanelPalette.secondaryText(colorScheme).opacity(0.84) + ) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } } .frame(maxWidth: .infinity, alignment: .leading) @@ -707,6 +767,10 @@ struct AccountRowView: View { } } + if !runs.isEmpty { + AccountRunSummaryView(runs: runs) + } + if account.hasUsageSummary { AccountUsageSummaryView(account: account) } @@ -748,6 +812,82 @@ struct AccountRowView: View { } } +struct AccountRunSummaryView: View { + let runs: [OperatorRunStatus] + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 5) { + ForEach(Array(runs.prefix(2))) { run in + AccountRunChipView(run: run) + } + + if runs.count > 2 { + Text("+\(runs.count - 2)") + .font(PanelFont.summaryTitle) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .frame(height: 18) + .padding(.horizontal, 6) + .modernGlassSurface(cornerRadius: 9, depth: .control) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct AccountRunChipView: View { + let run: OperatorRunStatus + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 4) { + Image(systemName: symbol) + .font(PanelFont.summaryIcon) + .foregroundStyle(tint.opacity(colorScheme == .dark ? 0.9 : 0.78)) + .frame(width: 10) + + Text(run.compactTitle) + .font(PanelFont.summaryTitle) + .foregroundStyle(PanelPalette.primaryText(colorScheme).opacity(0.92)) + .lineLimit(1) + .truncationMode(.middle) + + Text(run.compactDetail) + .font(PanelFont.summaryTitle) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(height: 18) + .frame(maxWidth: 132, alignment: .leading) + .padding(.horizontal, 6) + .modernGlassSurface(cornerRadius: 9, depth: .control) + .layoutPriority(1) + } + + private var symbol: String { + if run.hasAttentionTone { + return "exclamationmark.triangle.fill" + } + if run.isWaiting { + return "clock" + } + + return "play.fill" + } + + private var tint: Color { + if run.hasAttentionTone { + return PanelPalette.warning(colorScheme) + } + if run.isWaiting { + return PanelPalette.secondaryText(colorScheme) + } + + return PanelPalette.routeAccent(colorScheme) + } +} + struct AccountUsageSummaryView: View { let account: CodexAccount @Environment(\.colorScheme) private var colorScheme @@ -1029,6 +1169,132 @@ struct NoticeView: View { } } +struct OperatorStatusStripView: View { + let snapshot: OperatorSnapshotResponse + let updatedAt: Date? + let refreshIntervalSeconds: Int + @Environment(\.colorScheme) private var colorScheme + + 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 + + OperatorFlowMetricView( + title: "Landing", + value: snapshot.landingCount, + unit: "PRs", + tint: PanelPalette.fastModeAccent(colorScheme) + ) + } + .frame(height: 30) + + if let warning = snapshot.warningSummary { + HStack(spacing: 5) { + Image(systemName: "exclamationmark.circle") + .font(PanelFont.summaryIcon) + .foregroundStyle(PanelPalette.warning(colorScheme).opacity(0.82)) + .frame(width: 10) + + Text(warning) + .font(PanelFont.summaryTitle) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: 4) + + Text(refreshMeta) + .font(PanelFont.summaryTitle) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.68)) + .monospacedDigit() + } + .frame(height: 14) + .help("Operator snapshot refreshes every \(refreshIntervalSeconds) seconds.") + } + } + .padding(.horizontal, 6) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .modernGlassSurface(cornerRadius: 10, depth: .section) + } + + private var flowDivider: some View { + Rectangle() + .fill(PanelPalette.separator(colorScheme).opacity(colorScheme == .dark ? 0.72 : 0.9)) + .frame(width: 0.5, height: 20) + } + + private var refreshMeta: String { + guard let updatedAt else { + return "\(refreshIntervalSeconds)s refresh" + } + + let age = max(0, Int(Date().timeIntervalSince(updatedAt).rounded())) + if age < 2 { + return "\(refreshIntervalSeconds)s refresh" + } + + return "\(age)s ago" + } +} + +struct OperatorFlowMetricView: View { + let title: String + let value: Int + let unit: String + let tint: Color + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(PanelFont.summaryTitle) + .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) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 5) + } +} + struct SummaryTileView: View { let title: String let value: String diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift index 7f696349..2a553630 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -1,9 +1,12 @@ +import AppKit import Foundation @MainActor final class AccountStore: ObservableObject { @Published private(set) var accountList: AccountListResponse? @Published private(set) var fastMode: CodexFastModeResponse? + @Published private(set) var operatorSnapshot: OperatorSnapshotResponse? + @Published private(set) var operatorSnapshotUpdatedAt: Date? @Published private(set) var isRefreshing = false @Published private(set) var isLoggingIn = false @Published private(set) var isSettingFastMode = false @@ -12,9 +15,12 @@ final class AccountStore: ObservableObject { private let bridge = DecodexAppBridge() private var automaticRefreshTask: Task? + private var automaticOperatorRefreshTask: Task? + static let operatorSnapshotRefreshIntervalSeconds = 10 deinit { automaticRefreshTask?.cancel() + automaticOperatorRefreshTask?.cancel() } var isInitialLoading: Bool { @@ -81,7 +87,9 @@ final class AccountStore: ObservableObject { ) notice = nil await refreshFastMode() + await refreshOperatorSnapshot() } catch { + operatorSnapshot = nil notice = error.localizedDescription } } @@ -94,6 +102,30 @@ final class AccountStore: ObservableObject { await refresh() } + func refreshOperatorSnapshot() async { + do { + operatorSnapshot = try await bridge.runJSON( + .operatorSnapshot, + as: OperatorSnapshotResponse.self + ) + operatorSnapshotUpdatedAt = Date() + } catch { + operatorSnapshot = nil + operatorSnapshotUpdatedAt = nil + } + } + + func openWebUI() async { + do { + let url = try await DecodexServerBridge.shared.dashboardURL() + + NSWorkspace.shared.open(url) + notice = nil + } catch { + notice = error.localizedDescription + } + } + func resetLoginSession() { guard !isLoggingIn else { return @@ -119,6 +151,28 @@ final class AccountStore: ObservableObject { await self?.refresh(force: true) } } + + startAutomaticOperatorRefresh() + } + + private func startAutomaticOperatorRefresh() { + guard automaticOperatorRefreshTask == nil else { + return + } + + automaticOperatorRefreshTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep( + nanoseconds: UInt64(Self.operatorSnapshotRefreshIntervalSeconds) * 1_000_000_000 + ) + } catch { + return + } + + await self?.refreshOperatorSnapshot() + } + } } func useInCodex(_ account: CodexAccount) async { diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift index dd42a9f1..5eb69748 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift @@ -61,6 +61,7 @@ struct DecodexApp: App { let content = AccountPanelView(store: store, loginWindowState: loginWindowState) .task { await store.refreshIfNeeded() + await store.refreshOperatorSnapshot() } if #available(macOS 15.0, *) { diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift index 7c18f4a5..98e85bd9 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift @@ -73,6 +73,8 @@ struct AppBridgeRequest: Encodable, Sendable { AppBridgeRequest(operation: "codex_fast_mode_set", enabled: enabled) } + static let operatorSnapshot = AppBridgeRequest(operation: "operator_snapshot") + private init( operation: String, selector: String? = nil, diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift index b7f95de6..ed026af7 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift @@ -23,6 +23,12 @@ actor DecodexServerBridge { private var liveCheckedAt: Date? private var startedProcess: Process? + func dashboardURL() async throws -> URL { + let baseURL = try await ensureServer() + + return baseURL.appendingPathComponent("dashboard") + } + 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") @@ -266,6 +272,8 @@ extension AppBridgeRequest { return try jsonPost("api/accounts/import") case "account_use": return try jsonPost("api/accounts/use") + case "operator_snapshot": + return ServerRoute(method: "GET", path: "api/operator-snapshot", body: nil) default: return nil } diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift new file mode 100644 index 00000000..fd7b32d2 --- /dev/null +++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift @@ -0,0 +1,373 @@ +import Foundation + +struct OperatorSnapshotResponse: Decodable, Sendable { + let warnings: [String] + let projects: [OperatorProjectStatus] + let activeRuns: [OperatorRunStatus] + let queuedCandidates: [OperatorQueuedIssueStatus] + let postReviewLanes: [OperatorPostReviewLaneStatus] + + var activeRunCount: Int { + max( + activeRuns.count, + projects.reduce(0) { $0 + $1.activeRunCount } + ) + } + + var queuedCount: Int { + max( + queuedCandidates.filter { !$0.isClosed }.count, + projects.reduce(0) { $0 + $1.queuedCandidateCount } + ) + } + + var reviewCount: Int { + max( + postReviewLanes.count, + projects.reduce(0) { $0 + $1.postReviewLaneCount } + ) + } + + var landingCount: Int { + postReviewLanes.filter { $0.isReadyToLand }.count + } + + var waitingCount: Int { + projects.reduce(0) { $0 + $1.waitingLaneCount } + } + + var attentionCount: Int { + projects.reduce(0) { $0 + $1.attentionCount } + } + + var cleanupCount: Int { + projects.reduce(0) { $0 + $1.cleanupBlockedCount + $1.cleanupPendingCount } + } + + var hasVisibleSignal: Bool { + activeRunCount > 0 + || queuedCount > 0 + || waitingCount > 0 + || attentionCount > 0 + || cleanupCount > 0 + || !warnings.isEmpty + } + + var shouldDisplayInPanel: Bool { + !isAPIOnlySnapshot + } + + var warningSummary: String? { + let labels = warnings.compactMap(Self.warningLabel).filter { !$0.isEmpty } + guard let first = labels.first else { + return nil + } + if labels.count == 1 { + return first + } + + return "\(first) +\(labels.count - 1)" + } + + func activeRuns(for account: CodexAccount) -> [OperatorRunStatus] { + activeRuns.filter { $0.isAssigned(to: account) } + } + + func runningCount(for account: CodexAccount) -> Int { + activeRuns(for: account).count + } + + private var isAPIOnlySnapshot: Bool { + warnings.contains("automation_disabled") + && projects.allSatisfy { $0.connectorState == "api_only" } + } + + private static func warningLabel(_ value: String) -> String? { + switch value { + case "automation_disabled": + return nil + case "control_plane_tick_context_failed": + return "Control-plane context unavailable" + case "operator_snapshot_build_failed": + return "Snapshot build failed" + case "control_plane_tick_failed": + return "Control-plane tick failed" + case "tracker_rate_limited": + return "Tracker sync paused" + case "codex_accounts_unavailable": + return "Accounts unavailable" + case "worktree_hygiene_unavailable": + return "Worktree hygiene unavailable" + default: + return readable(value) + } + } + + enum CodingKeys: String, CodingKey { + case warnings + case projects + case activeRuns = "active_runs" + case queuedCandidates = "queued_candidates" + case postReviewLanes = "post_review_lanes" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + warnings = try container.decodeIfPresent([String].self, forKey: .warnings) ?? [] + projects = try container.decodeIfPresent([OperatorProjectStatus].self, forKey: .projects) ?? [] + activeRuns = try container.decodeIfPresent([OperatorRunStatus].self, forKey: .activeRuns) ?? [] + queuedCandidates = try container.decodeIfPresent( + [OperatorQueuedIssueStatus].self, + forKey: .queuedCandidates + ) ?? [] + postReviewLanes = try container.decodeIfPresent( + [OperatorPostReviewLaneStatus].self, + forKey: .postReviewLanes + ) ?? [] + } +} + +struct OperatorProjectStatus: Decodable, Sendable { + let connectorState: String? + let activeRunCount: Int + let queuedCandidateCount: Int + let postReviewLaneCount: Int + let waitingLaneCount: Int + let attentionCount: Int + let cleanupBlockedCount: Int + let cleanupPendingCount: Int + + enum CodingKeys: String, CodingKey { + case connectorState = "connector_state" + case activeRunCount = "active_run_count" + case queuedCandidateCount = "queued_candidate_count" + case postReviewLaneCount = "post_review_lane_count" + case waitingLaneCount = "waiting_lane_count" + case attentionCount = "attention_count" + case cleanupBlockedCount = "cleanup_blocked_count" + case cleanupPendingCount = "cleanup_pending_count" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + connectorState = try container.decodeIfPresent(String.self, forKey: .connectorState) + activeRunCount = try container.decodeIfPresent(Int.self, forKey: .activeRunCount) ?? 0 + queuedCandidateCount = try container.decodeIfPresent(Int.self, forKey: .queuedCandidateCount) ?? 0 + postReviewLaneCount = try container.decodeIfPresent(Int.self, forKey: .postReviewLaneCount) ?? 0 + waitingLaneCount = try container.decodeIfPresent(Int.self, forKey: .waitingLaneCount) ?? 0 + attentionCount = try container.decodeIfPresent(Int.self, forKey: .attentionCount) ?? 0 + cleanupBlockedCount = try container.decodeIfPresent(Int.self, forKey: .cleanupBlockedCount) ?? 0 + cleanupPendingCount = try container.decodeIfPresent(Int.self, forKey: .cleanupPendingCount) ?? 0 + } +} + +struct OperatorQueuedIssueStatus: Decodable, Sendable { + let classification: String? + + var isClosed: Bool { + classification == "closed" + } + + enum CodingKeys: String, CodingKey { + case classification + } +} + +struct OperatorPostReviewLaneStatus: Decodable, Sendable { + let classification: String? + + var isReadyToLand: Bool { + classification == "ready_to_land" + } + + enum CodingKeys: String, CodingKey { + case classification + } +} + +struct OperatorRunStatus: Decodable, Identifiable, Sendable { + let runID: String + let issueIdentifier: String? + let title: String? + let status: String? + let attemptStatus: String? + let phase: String? + let waitReason: String? + let currentOperation: String? + let threadStatus: String? + let idleForSeconds: Int? + let suspectedStall: Bool + let childAgentActivity: OperatorChildAgentActivity? + let account: OperatorRunAccountSummary? + let accounts: [OperatorRunAccountSummary] + + var id: String { + runID + } + + var compactTitle: String { + if let issueIdentifier = trimmed(issueIdentifier), !issueIdentifier.isEmpty { + return issueIdentifier + } + if let title = trimmed(title), !title.isEmpty { + return title + } + + return "Run" + } + + var compactDetail: String { + if let currentDetail = trimmed(childAgentActivity?.currentDetail), !currentDetail.isEmpty { + return currentDetail + } + if let currentBucket = trimmed(childAgentActivity?.currentBucket), !currentBucket.isEmpty { + return readable(currentBucket) + } + if let waitReason = trimmed(waitReason), !waitReason.isEmpty { + return readable(waitReason) + } + if let operation = trimmed(currentOperation), !operation.isEmpty, operation != "idle" { + return readable(operation) + } + if let phase = trimmed(phase), !phase.isEmpty { + return readable(phase) + } + if let threadStatus = trimmed(threadStatus), !threadStatus.isEmpty { + return readable(threadStatus) + } + if let status = trimmed(status), !status.isEmpty { + return readable(status) + } + + return "Active" + } + + var hasAttentionTone: Bool { + suspectedStall + || attemptStatus == "waiting_for_review" + || status == "manual_attention" + || status == "blocked" + } + + var isWaiting: Bool { + waitReason != nil + || attemptStatus?.contains("waiting") == true + || phase?.contains("waiting") == true + } + + func isAssigned(to account: CodexAccount) -> Bool { + let runAccounts = ([self.account].compactMap { $0 }) + accounts + + return runAccounts.contains { $0.matches(account) } + } + + enum CodingKeys: String, CodingKey { + case runID = "run_id" + case issueIdentifier = "issue_identifier" + case title + case status + case attemptStatus = "attempt_status" + case phase + case waitReason = "wait_reason" + case currentOperation = "current_operation" + case threadStatus = "thread_status" + case idleForSeconds = "idle_for_seconds" + case suspectedStall = "suspected_stall" + case childAgentActivity = "child_agent_activity" + case account + case accounts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + runID = try container.decodeIfPresent(String.self, forKey: .runID) ?? UUID().uuidString + issueIdentifier = try container.decodeIfPresent(String.self, forKey: .issueIdentifier) + title = try container.decodeIfPresent(String.self, forKey: .title) + status = try container.decodeIfPresent(String.self, forKey: .status) + attemptStatus = try container.decodeIfPresent(String.self, forKey: .attemptStatus) + phase = try container.decodeIfPresent(String.self, forKey: .phase) + waitReason = try container.decodeIfPresent(String.self, forKey: .waitReason) + currentOperation = try container.decodeIfPresent(String.self, forKey: .currentOperation) + threadStatus = try container.decodeIfPresent(String.self, forKey: .threadStatus) + idleForSeconds = try container.decodeIfPresent(Int.self, forKey: .idleForSeconds) + suspectedStall = try container.decodeIfPresent(Bool.self, forKey: .suspectedStall) ?? false + childAgentActivity = try container.decodeIfPresent( + OperatorChildAgentActivity.self, + forKey: .childAgentActivity + ) + account = try container.decodeIfPresent(OperatorRunAccountSummary.self, forKey: .account) + accounts = try container.decodeIfPresent([OperatorRunAccountSummary].self, forKey: .accounts) ?? [] + } +} + +struct OperatorChildAgentActivity: Decodable, Sendable { + let currentBucket: String? + let currentDetail: String? + let toolCallCount: Int + + enum CodingKeys: String, CodingKey { + case currentBucket = "current_bucket" + case currentDetail = "current_detail" + case toolCallCount = "tool_call_count" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + currentBucket = try container.decodeIfPresent(String.self, forKey: .currentBucket) + currentDetail = try container.decodeIfPresent(String.self, forKey: .currentDetail) + toolCallCount = try container.decodeIfPresent(Int.self, forKey: .toolCallCount) ?? 0 + } +} + +struct OperatorRunAccountSummary: Decodable, Sendable { + let accountFingerprint: String + let email: String? + + func matches(_ account: CodexAccount) -> Bool { + if !accountFingerprint.isEmpty, accountFingerprint == account.accountFingerprint { + return true + } + if let email, let accountEmail = account.email { + return email.caseInsensitiveCompare(accountEmail) == .orderedSame + } + + return false + } + + enum CodingKeys: String, CodingKey { + case accountFingerprint = "account_fingerprint" + case email + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + accountFingerprint = try container.decodeIfPresent(String.self, forKey: .accountFingerprint) ?? "" + email = try container.decodeIfPresent(String.self, forKey: .email) + } +} + +private func trimmed(_ value: String?) -> String? { + value?.trimmingCharacters(in: .whitespacesAndNewlines) +} + +private func readable(_ value: String) -> String { + let words = value + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { word in + let text = String(word) + guard let first = text.first else { + return text + } + + return first.uppercased() + String(text.dropFirst()) + } + + return words.joined(separator: " ") +} diff --git a/apps/decodex/src/orchestrator.rs b/apps/decodex/src/orchestrator.rs index 2fd7ea1b..41280372 100644 --- a/apps/decodex/src/orchestrator.rs +++ b/apps/decodex/src/orchestrator.rs @@ -80,6 +80,7 @@ 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_APP_SNAPSHOT_ENDPOINT_PATH: &str = "/api/operator-snapshot"; 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/operator_http.rs b/apps/decodex/src/orchestrator/operator_http.rs index 905bda50..2b8fc233 100644 --- a/apps/decodex/src/orchestrator/operator_http.rs +++ b/apps/decodex/src/orchestrator/operator_http.rs @@ -32,6 +32,7 @@ enum OperatorRequestRoute { DashboardLogoTouchPng, DashboardWs, Live, + AppSnapshot, AccountList { force_refresh: bool }, AccountSelect, AccountClear, @@ -230,6 +231,13 @@ fn handle_operator_state_endpoint_connection( return Ok(()); } + if route == OperatorRequestRoute::AppSnapshot { + let response = build_operator_app_snapshot_http_response(snapshot); + + stream.write_all(&response)?; + + return Ok(()); + } if route == OperatorRequestRoute::DashboardWs { handle_operator_dashboard_websocket_connection( stream, @@ -277,6 +285,35 @@ fn snapshot_json_with_live_account_control(snapshot_json: &[u8]) -> Vec { } } +fn build_operator_app_snapshot_http_response(snapshot: &Arc>) -> Vec { + let snapshot = match snapshot.lock() { + Ok(snapshot) => snapshot, + Err(error) => { + return http_response_bytes( + "500 Internal Server Error", + "text/plain; charset=utf-8", + format!("operator snapshot lock poisoned: {error}").as_bytes(), + ); + }, + }; + let Some(snapshot_json) = snapshot.snapshot_json.as_deref() else { + return http_response_bytes_with_headers( + "200 OK", + "application/json", + &[("Cache-Control", String::from("no-store"))], + b"{}", + ); + }; + + let body = snapshot_json_with_live_account_control(snapshot_json); + let mut headers = vec![("Cache-Control", String::from("no-store"))]; + if let Some(published_at) = snapshot.last_publish_unix_epoch { + headers.push(("X-Decodex-Snapshot-Unix-Epoch", published_at.to_string())); + } + + http_response_bytes_with_headers("200 OK", "application/json", &headers, &body) +} + fn handle_operator_dashboard_websocket_connection( mut stream: TcpStream, request: &[u8], @@ -1547,6 +1584,9 @@ fn build_operator_state_http_response_for_route(route: OperatorRequestRoute) -> http_response_bytes("200 OK", "image/png", OPERATOR_DASHBOARD_LOGO_TOUCH_PNG) }, OperatorRequestRoute::DashboardWs => websocket_upgrade_required_response(), + OperatorRequestRoute::AppSnapshot => { + http_response_bytes("200 OK", "application/json", b"{}") + }, OperatorRequestRoute::Live => { http_response_bytes("200 OK", "text/plain; charset=utf-8", b"ok") }, @@ -1604,6 +1644,7 @@ fn parse_operator_state_request_route( ("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_APP_SNAPSHOT_ENDPOINT_PATH) => Ok(OperatorRequestRoute::AppSnapshot), ("GET", OPERATOR_ACCOUNTS_ENDPOINT_PATH) => Ok(OperatorRequestRoute::AccountList { force_refresh: operator_query_has_flag(query, "refresh"), }), @@ -1617,6 +1658,7 @@ fn parse_operator_state_request_route( | OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH | OPERATOR_DASHBOARD_WS_ENDPOINT_PATH | OPERATOR_LIVE_ENDPOINT_PATH + | OPERATOR_APP_SNAPSHOT_ENDPOINT_PATH | OPERATOR_ACCOUNTS_ENDPOINT_PATH | "/api/accounts/select" | "/api/accounts/clear" diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index cbabd9f1..21cd4411 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -12,6 +12,25 @@ fn dashboard_response() -> String { .expect("dashboard response should be utf-8") } +#[test] +fn operator_app_snapshot_endpoint_returns_json() { + let response = String::from_utf8( + orchestrator::build_operator_state_http_response( + format!( + "GET {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + orchestrator::OPERATOR_APP_SNAPSHOT_ENDPOINT_PATH + ) + .as_bytes(), + ) + .expect("app snapshot response should build"), + ) + .expect("app snapshot response should be utf-8"); + + assert!(response.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(response.contains("Content-Type: application/json\r\n")); + assert!(response.ends_with("\r\n\r\n{}")); +} + #[test] fn operator_dashboard_background_wash_stays_viewport_fixed() { let response = dashboard_response(); From 5e57cf6a7facb720ad84f6e79270df0fdf01533d Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 21 May 2026 14:56:59 +0800 Subject: [PATCH 2/2] {"schema":"decodex/commit/1","summary":"Fix operator metrics Rust style","authority":"manual"} --- apps/decodex/src/orchestrator/operator_http.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/decodex/src/orchestrator/operator_http.rs b/apps/decodex/src/orchestrator/operator_http.rs index 2b8fc233..a41e0896 100644 --- a/apps/decodex/src/orchestrator/operator_http.rs +++ b/apps/decodex/src/orchestrator/operator_http.rs @@ -304,9 +304,9 @@ fn build_operator_app_snapshot_http_response(snapshot: &Arc