diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 6f04376..25584f3 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -283,10 +283,7 @@ struct AccountPanelView: View { AccountTelemetryMatrixView( aggregate: accountProfileAggregate, usageEstimate: store.accountList?.usageEstimate, - accounts: store.accounts, - snapshot: displayableOperatorSnapshot, - updatedAt: store.operatorSnapshotUpdatedAt, - currentTime: currentTime + accounts: store.accounts ) } @@ -651,18 +648,9 @@ struct AccountPanelView: View { AccountProfileAggregate.make(accounts: store.accounts) } - private var displayableOperatorSnapshot: OperatorSnapshotResponse? { - guard let snapshot = store.operatorSnapshot, snapshot.shouldDisplayInPanel else { - return nil - } - - return snapshot - } - private var telemetryMatrixIsVisible: Bool { accountProfileAggregate != nil || store.accountList?.usageEstimate != nil - || displayableOperatorSnapshot != nil } private var telemetryMatrixHeight: CGFloat { @@ -677,10 +665,6 @@ struct AccountPanelView: View { : AccountPanelLayout.telemetryPoolHeight ) } - if let snapshot = displayableOperatorSnapshot { - rows.append(operatorTelemetryHeight(for: snapshot)) - } - guard rows.isEmpty == false else { return 0 } @@ -690,19 +674,6 @@ struct AccountPanelView: View { + CGFloat(rows.count - 1) * AccountPanelLayout.telemetryRowSpacing } - private func operatorTelemetryHeight(for snapshot: OperatorSnapshotResponse) -> CGFloat { - var rows: [CGFloat] = [AccountPanelLayout.telemetryOperatorMetricHeight] - if snapshot.activeRuns.isEmpty == false { - rows.append(AccountRunChipLayout.height) - } - if snapshot.warningSummary != nil { - rows.append(AccountPanelLayout.telemetryOperatorWarningHeight) - } - - return rows.reduce(0, +) - + CGFloat(rows.count - 1) * AccountPanelLayout.telemetryOperatorRowSpacing - } - private func displayName(for account: CodexAccount) -> String { if emailsHidden { return AccountDisplay.aliases(for: store.accounts)[account.id] @@ -1471,9 +1442,6 @@ private enum AccountPanelLayout { static let telemetryProfileHeight: CGFloat = 50 static let telemetryPoolHeight: CGFloat = 16 static let telemetryPoolMeasuredHeight: CGFloat = 29 - static let telemetryOperatorMetricHeight: CGFloat = 16 - static let telemetryOperatorWarningHeight: CGFloat = 16 - static let telemetryOperatorRowSpacing: CGFloat = 4 static let noticeHeight: CGFloat = 44 static let minimumScrollableListHeight: CGFloat = 312 @@ -1669,9 +1637,6 @@ private struct AccountTelemetryMatrixView: View { let aggregate: AccountProfileAggregate? let usageEstimate: AccountUsageEstimate? let accounts: [CodexAccount] - let snapshot: OperatorSnapshotResponse? - let updatedAt: Date? - let currentTime: Date @Environment(\.colorScheme) private var colorScheme var body: some View { @@ -1683,14 +1648,6 @@ private struct AccountTelemetryMatrixView: View { if let usageEstimate { AccountPoolUsageEstimateView(estimate: usageEstimate, accounts: accounts) } - - if let snapshot { - OperatorStatusStripView( - snapshot: snapshot, - updatedAt: updatedAt, - currentTime: currentTime - ) - } } .padding(.horizontal, AccountPanelLayout.telemetryHorizontalPadding) .padding(.top, AccountPanelLayout.telemetryTopPadding) @@ -2503,104 +2460,6 @@ struct NoticeView: View { } } -struct OperatorStatusStripView: View { - let snapshot: OperatorSnapshotResponse - let updatedAt: Date? - let currentTime: Date - private let liveFreshnessWindow: TimeInterval = 5 - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - VStack(alignment: .leading, spacing: AccountPanelLayout.telemetryOperatorRowSpacing) { - HStack(spacing: 5) { - ForEach(Array(metrics.enumerated()), id: \.element.id) { index, metric in - OperatorFlowMetricView(metric: metric) - - if index < metrics.count - 1 { - Spacer(minLength: 3) - } - } - } - .frame(height: 16) - - if snapshot.activeRuns.isEmpty == false { - AccountRunSummaryView(runs: snapshot.activeRuns) - } - - if let warning = snapshot.warningSummary { - HStack(alignment: .firstTextBaseline, spacing: 5) { - PanelMetricIconView( - symbol: "exclamationmark.circle", - tint: PanelPalette.warning(colorScheme).opacity(0.82) - ) - - Text(warning) - .font(PanelFont.metricLabel) - .foregroundStyle(PanelPalette.secondaryText(colorScheme)) - .lineLimit(1) - .truncationMode(.tail) - - Spacer(minLength: 4) - - Text(refreshMeta) - .font(PanelFont.tertiary) - .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.68)) - .monospacedDigit() - .frame(minWidth: 38, alignment: .trailing) - } - .frame(height: 16) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var metrics: [OperatorFlowMetric] { - [ - OperatorFlowMetric( - title: "Intake", - value: snapshot.queuedCount, - unitSingular: "issue", - unitPlural: "issues", - tint: PanelPalette.secondaryText(colorScheme) - ), - OperatorFlowMetric( - title: "Running", - value: snapshot.activeRunCount, - unitSingular: "lane", - unitPlural: "lanes", - tint: PanelPalette.routeAccent(colorScheme) - ), - OperatorFlowMetric( - title: "Review", - value: snapshot.reviewCount, - unitSingular: "PR", - unitPlural: "PRs", - tint: PanelPalette.codexAccent(colorScheme) - ), - OperatorFlowMetric( - title: "Landing", - value: snapshot.landingCount, - unitSingular: "PR", - unitPlural: "PRs", - tint: PanelPalette.landingAccent(colorScheme) - ), - ] - } - - private var refreshMeta: String { - guard let updatedAt else { - return "WS live" - } - - let age = max(0, Int(currentTime.timeIntervalSince(updatedAt).rounded())) - if TimeInterval(age) < liveFreshnessWindow { - return "live" - } - - return "\(age)s ago" - } -} - struct OperatorLanePopoverView: View { let run: OperatorRunStatus @@ -3314,70 +3173,6 @@ struct OperatorLaneReadoutDivider: View { } } -struct OperatorFlowMetric: Identifiable { - let title: String - let value: Int - let unitSingular: String - let unitPlural: String - let tint: Color - - init( - title: String, - value: Int, - unitSingular: String, - unitPlural: String, - tint: Color - ) { - self.title = title - self.value = value - self.unitSingular = unitSingular - self.unitPlural = unitPlural - self.tint = tint - } - - var id: String { - title - } - - var unit: String { - value == 1 ? unitSingular : unitPlural - } - - var fullText: String { - "\(title) \(value) \(unit)" - } -} - -struct OperatorFlowMetricView: View { - let metric: OperatorFlowMetric - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 3) { - Text(metric.title) - .font(PanelFont.usageLabel) - .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) - .lineLimit(1) - - Text("\(metric.value)") - .font(PanelFont.usageValue) - .foregroundStyle(valueTint) - .monospacedDigit() - .lineLimit(1) - .minimumScaleFactor(0.72) - } - .lineLimit(1) - .help(metric.fullText) - .accessibilityLabel(metric.fullText) - } - - private var valueTint: Color { - metric.value > 0 - ? metric.tint - : PanelPalette.primaryText(colorScheme).opacity(colorScheme == .dark ? 0.76 : 0.66) - } -} - private struct PanelMetricIconView: View { let symbol: String let tint: Color diff --git a/apps/decodex/src/config.rs b/apps/decodex/src/config.rs index 80e529c..8f2341d 100644 --- a/apps/decodex/src/config.rs +++ b/apps/decodex/src/config.rs @@ -884,11 +884,25 @@ fn validate_env_var_name(field_name: &str, value: &str) -> Result<()> { fn resolve_secret_env_var(field_name: &str, env_var: &str) -> Result { validate_env_var_name(field_name, env_var)?; - let value = env::var(env_var).map_err(|error| { - eyre::eyre!( - "Failed to read environment variable `{env_var}` referenced by `{field_name}`: {error}" - ) - })?; + let value = match env::var(env_var) { + Ok(value) if !value.trim().is_empty() => value, + Ok(_) => + if let Some(value) = resolve_secret_launchd_env_var(env_var) { + value + } else { + eyre::bail!( + "Environment variable `{env_var}` referenced by `{field_name}` must not be blank." + ); + }, + Err(error) => + if let Some(value) = resolve_secret_launchd_env_var(env_var) { + value + } else { + return Err(eyre::eyre!( + "Failed to read environment variable `{env_var}` referenced by `{field_name}`: {error}" + )); + }, + }; if value.trim().is_empty() { eyre::bail!( @@ -899,6 +913,24 @@ fn resolve_secret_env_var(field_name: &str, env_var: &str) -> Result { Ok(value) } +#[cfg(target_os = "macos")] +fn resolve_secret_launchd_env_var(env_var: &str) -> Option { + let output = Command::new("/bin/launchctl").args(["getenv", env_var]).output().ok()?; + + if !output.status.success() { + return None; + } + + let value = String::from_utf8(output.stdout).ok()?.trim().to_owned(); + + if value.is_empty() { None } else { Some(value) } +} + +#[cfg(not(target_os = "macos"))] +fn resolve_secret_launchd_env_var(_env_var: &str) -> Option { + None +} + #[cfg(test)] mod tests { use std::{ diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index 5cc0d82..db60a71 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -2,6 +2,8 @@ use state::{ConnectorBackoff, ConnectorBackoffInput}; use crate::runtime; +const CONTROL_PLANE_TICK_CONTEXT_FAILED_WARNING: &str = "control_plane_tick_context_failed"; + struct ControlPlaneProjectTick { snapshot: Option, project_status: Option, @@ -1140,19 +1142,12 @@ fn run_control_plane_project_tick( allow_unverified_codex, ), Err(error) => { - let _ = error; - tracing::warn!( project_id = project.service_id(), "Control-plane tick context failed; sensitive runtime details were withheld." ); - snapshot_warnings.push("control_plane_tick_context_failed"); - - ControlPlaneProjectTick { - snapshot: None, - project_status: Some(operator_project_status_from_registration(project, 1)), - } + control_plane_tick_context_failed_tick(project, &error, 1) }, } } @@ -1303,17 +1298,12 @@ fn control_plane_project_deferred_snapshot( }, }, Err(error) => { - let _ = error; - tracing::warn!( project_id = project.service_id(), "Deferred control-plane snapshot context failed; sensitive runtime details were withheld." ); - ControlPlaneProjectTick { - snapshot: None, - project_status: Some(operator_project_status_from_registration(project, 1)), - } + control_plane_tick_context_failed_tick(project, &error, 1) }, } } @@ -1391,21 +1381,12 @@ fn control_plane_project_local_snapshot( }, }, Err(error) => { - let _ = error; - tracing::warn!( project_id = project.service_id(), "Control-plane local snapshot context failed; sensitive runtime details were withheld." ); - snapshot_warnings.push("control_plane_tick_context_failed"); - ControlPlaneProjectTick { - snapshot: None, - project_status: Some(operator_project_status_from_registration( - project, - snapshot_warnings.len(), - )), - } + control_plane_tick_context_failed_tick(project, &error, snapshot_warnings.len() + 1) }, } } @@ -1534,6 +1515,91 @@ fn complete_project_status( status } +fn control_plane_tick_context_failed_tick( + project: &ProjectRegistration, + error: &Report, + warning_count: usize, +) -> ControlPlaneProjectTick { + let mut snapshot = empty_control_plane_snapshot(DEFAULT_OPERATOR_DASHBOARD_RUN_LIMIT); + + add_operator_snapshot_warning(&mut snapshot, CONTROL_PLANE_TICK_CONTEXT_FAILED_WARNING); + + snapshot + .warning_details + .push(control_plane_tick_context_failed_warning_detail(project, error)); + + ControlPlaneProjectTick { + snapshot: Some(snapshot), + project_status: Some(operator_project_status_from_registration(project, warning_count)), + } +} + +fn control_plane_tick_context_failed_warning_detail( + project: &ProjectRegistration, + error: &Report, +) -> OperatorSnapshotWarningDetail { + let error_message = error.to_string(); + let (reason, next_action) = + control_plane_tick_context_failed_warning_text(project, &error_message); + + OperatorSnapshotWarningDetail { + warning: String::from(CONTROL_PLANE_TICK_CONTEXT_FAILED_WARNING), + project_id: Some(project.service_id().to_owned()), + repo_root: Some(project.repo_root().display().to_string()), + reason, + next_action: Some(next_action), + } +} + +fn control_plane_tick_context_failed_warning_text( + project: &ProjectRegistration, + error_message: &str, +) -> (String, String) { + if let Some(env_var) = context_failure_env_var(error_message) { + return ( + format!( + "Control-plane context could not read configured environment variable `{env_var}` for `{}`.", + project.config_path().display() + ), + format!( + "Expose `{env_var}` to the `decodex serve` process, then restart the Decodex App/helper. For macOS GUI launches, set it with `launchctl setenv {env_var} `." + ), + ); + } + + if error_message.contains("WORKFLOW.md") { + return ( + format!( + "Control-plane context could not load the project workflow for `{}`: {error_message}", + project.config_path().display() + ), + String::from( + "Restore or fix the registered project `WORKFLOW.md`, then request a Linear scan or restart the control plane.", + ), + ); + } + + ( + format!( + "Control-plane context could not load registered project `{}`: {error_message}", + project.config_path().display() + ), + String::from( + "Inspect the registered `project.toml`, `WORKFLOW.md`, and configured credential environment, then restart or rescan the control plane.", + ), + ) +} + +fn context_failure_env_var(error_message: &str) -> Option { + error_message + .split("environment variable `") + .nth(1)? + .split('`') + .next() + .filter(|env_var| !env_var.is_empty()) + .map(str::to_owned) +} + fn empty_control_plane_snapshot(limit: usize) -> OperatorStatusSnapshot { OperatorStatusSnapshot { project_id: String::from("all"), 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 7a09a4f..1dabbc6 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs @@ -79,6 +79,71 @@ fn control_plane_snapshot_includes_disabled_project_active_runs_without_ticking( assert!(project_runtimes.is_empty(), "disabled projects should not be ticked"); } +#[test] +fn control_plane_context_failure_includes_project_warning_detail() { + let (_temp_dir, base_config, _workflow) = temp_project_layout(); + let missing_env_var = "DECODEX_TEST_MISSING_CONTROL_PLANE_LINEAR_API_KEY"; + let _env_lock = TestEnvVarGuard::lock(); + + unsafe { + env::remove_var(missing_env_var); + } + + write_service_config( + base_config.repo_root(), + &sample_service_config_toml( + base_config.service_id(), + missing_env_var, + base_config.github().token_env_var(), + None, + base_config.codex().internal_review_mode(), + base_config.codex().external_review_enabled(), + ), + ); + + let config = load_service_config(base_config.repo_root()); + 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 mut project_runtimes = HashMap::new(); + let snapshot = orchestrator::run_control_plane_tick(&state_store, &mut project_runtimes, &[]) + .expect("control-plane snapshot should build"); + let project = snapshot.projects.first().expect("enabled project should be listed"); + let detail = snapshot + .warning_details + .iter() + .find(|detail| detail.warning == "control_plane_tick_context_failed") + .expect("context warning detail should be surfaced"); + + assert!(snapshot.warnings.contains(&String::from("control_plane_tick_context_failed"))); + assert_eq!(project.project_id, "pubfi"); + assert_eq!(project.connector_state, "degraded"); + assert_eq!(project.warning_count, 1); + assert_eq!(detail.project_id.as_deref(), Some("pubfi")); + assert_eq!(detail.repo_root.as_deref(), Some(config.repo_root().to_str().expect("utf-8 path"))); + assert!(detail.reason.contains(missing_env_var), "detail reason: {}", detail.reason); + assert!( + detail.reason.contains(®istration.config_path().display().to_string()), + "detail reason should include config path: {}", + detail.reason, + ); + assert!( + detail.next_action.as_deref().is_some_and(|action| { + action.contains("launchctl setenv") && action.contains(missing_env_var) + }), + "detail next action should explain macOS GUI env setup: {:?}", + detail.next_action, + ); +} + #[test] fn control_plane_linear_scan_cadence_uses_fixed_window_and_manual_override() { let now = Instant::now();