polyc-tui 2026.8.3

Operator cockpit TUI (pc-tui) for the polychrome control plane: fleet, transcript, approvals, and tools panes.
//! Async data adapters between the live polychrome surfaces and the cockpit's
//! frozen view types.
//!
//! Each submodule owns one pane's data:
//! - [`fleet`]   — kube `Conversation` CR list/watch (+ forensics enrichment)
//! - [`transcript`] — `polyc-rpc-client` streaming turn + forensics transcript
//! - [`approvals`]  — forensics approvals projection + `ApprovalService.Respond`
//! - [`tools`]   — `ConversationSpec.tools_enabled`
//! - [`questions`]  — `QuestionService.ListPending` + `QuestionService.Respond`
//!   (`#1660`) — the question-pause SIBLING of [`approvals`], not a reuse of it
//!
//! The function SIGNATURES here are the frozen contract; bodies are stubs.

use std::sync::OnceLock;
use std::time::Duration;

pub(crate) mod approvals;
pub(crate) mod fleet;
pub(crate) mod portforward;
pub(crate) mod questions;
pub(crate) mod tools;
pub(crate) mod transcript;

/// Bounds the TCP/TLS connect so a dead/terminating forensics peer fails fast
/// instead of blackholing the SYN for the kernel's `tcp_syn_retries` (~130s).
/// Kept `<=` the request timeout below.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);

/// The shared forensics HTTP client.
///
/// Built once and reused across every forensics read (fleet enrichment,
/// transcript history, approvals projection) so connection pooling is reused
/// instead of paying a fresh `reqwest::Client` per call. The request timeout
/// bounds each call, so a slow or hung forensics endpoint cannot wedge an
/// enrichment pass indefinitely.
#[must_use]
pub(crate) fn http_client() -> &'static reqwest::Client {
    static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
    CLIENT.get_or_init(|| {
        reqwest::Client::builder()
            .timeout(Duration::from_secs(5))
            .connect_timeout(CONNECT_TIMEOUT)
            .build()
            .unwrap_or_default()
    })
}

/// The namespace the manifests pin every polychrome resource to. Matches the
/// CLI's `context::DEFAULT_NAMESPACE`, so the cockpit and `polychrome status`
/// agree on where to look without any flags.
pub(crate) const DEFAULT_NAMESPACE: &str = "polychrome";

/// Shared connection configuration for the data adapters.
///
/// Both endpoints are `Option`: `None` means "auto port-forward to the
/// control-plane pod on startup" (the zero-config default), while `Some` is an
/// explicit env/flag override that skips the forward — e.g. pointing at a
/// local-process control plane or a hand-run `kubectl port-forward`. The
/// `Model` fills any `None` endpoint with the localhost URL of the forward it
/// stands up (see [`portforward`]).
#[derive(Debug, Clone)]
pub(crate) struct DataConfig {
    /// Kubernetes namespace the `Conversation` CRs live in.
    pub namespace: String,
    /// `http://host:port` of the control-plane `AgentService`/`ApprovalService`.
    /// `None` until the startup auto-forward (or an env/flag override) sets it.
    pub agent_addr: Option<String>,
    /// Base URL of the read-only forensics HTTP server. `None` until the startup
    /// auto-forward (or an env/flag override) sets it.
    pub forensics_base_url: Option<String>,
}

impl DataConfig {
    /// Resolve the connection configuration from the environment.
    ///
    /// - `PC_TUI_NAMESPACE` / `POLYCHROME_NAMESPACE` — kube namespace (default
    ///   [`DEFAULT_NAMESPACE`], the namespace the manifests pin resources to).
    /// - `PC_TUI_AGENT_ADDR` — explicit `AgentService`/`ApprovalService`
    ///   endpoint. Unset → the cockpit auto-forwards control-plane `:8080`.
    /// - `PC_TUI_FORENSICS_URL` — explicit forensics base URL. Unset → the
    ///   cockpit auto-forwards control-plane `:8090`.
    #[must_use]
    pub(crate) fn from_env() -> Self {
        let namespace = std::env::var("PC_TUI_NAMESPACE")
            .ok()
            .filter(|s| !s.is_empty())
            .or_else(|| {
                std::env::var("POLYCHROME_NAMESPACE")
                    .ok()
                    .filter(|s| !s.is_empty())
            })
            .unwrap_or_else(|| DEFAULT_NAMESPACE.to_owned());
        let agent_addr = std::env::var("PC_TUI_AGENT_ADDR")
            .ok()
            .filter(|s| !s.trim().is_empty());
        let forensics_base_url = std::env::var("PC_TUI_FORENSICS_URL")
            .ok()
            .filter(|s| !s.trim().is_empty());
        Self {
            namespace,
            agent_addr,
            forensics_base_url,
        }
    }

    /// Resolve the configuration from the environment, then apply any
    /// caller-supplied overrides (e.g. `polychrome tui --namespace …`). A
    /// `None` override leaves the environment-derived value untouched; an
    /// empty string override is treated as unset.
    #[must_use]
    pub(crate) fn resolve(
        namespace: Option<String>,
        agent_addr: Option<String>,
        forensics_base_url: Option<String>,
    ) -> Self {
        let mut cfg = Self::from_env();
        if let Some(ns) = namespace.filter(|s| !s.is_empty()) {
            cfg.namespace = ns;
        }
        if let Some(addr) = agent_addr.filter(|s| !s.trim().is_empty()) {
            cfg.agent_addr = Some(addr);
        }
        if let Some(url) = forensics_base_url.filter(|s| !s.trim().is_empty()) {
            cfg.forensics_base_url = Some(url);
        }
        cfg
    }
}