polyc-controller 2026.9.0

Conversation CRD + kube reconciler for the polychrome control plane.
//! The `metav1.Condition`-compatible condition shape shared by every CRD
//! status that reports readiness this way.
//!
//! Originally the `Conversation` status's own types (`#[kube(status =
//! "ConversationStatus")]`'s `conditions` field); promoted here once a second
//! status — [`crate::routine::RoutineStatus`] (#1370's `MissedFire`
//! condition) — needed the identical shape. [`crate::conversation`]
//! re-exports these three names unchanged, so every existing
//! `crate::conversation::Condition`/`ConditionStatus`/`upsert_condition`
//! caller keeps working without a rename.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// A single `status.condition`, wire-compatible with Kubernetes'
/// `metav1.Condition` convention (`type`/`status`/`reason`/`message`/
/// `lastTransitionTime`/`observedGeneration`).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Condition {
    /// The condition's type, e.g. `"Ready"`, `"Progressing"`, `"Degraded"`.
    #[serde(rename = "type")]
    pub type_: String,
    /// Whether the condition currently holds.
    pub status: ConditionStatus,
    /// Machine-readable reason for the current status (upper camel case,
    /// e.g. `"HarnessReady"`).
    pub reason: String,
    /// Human-readable detail.
    #[serde(default)]
    pub message: String,
    /// RFC3339 timestamp of when `status` last *changed* — not merely when
    /// the condition was last observed/re-written. See [`upsert_condition`].
    pub last_transition_time: String,
    /// The `.metadata.generation` this condition was computed from, when
    /// known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub observed_generation: Option<i64>,
}

/// The three-valued `status.status` a [`Condition`] carries.
///
/// `Unknown` is distinct from `False` (untested vs. tested-and-failing),
/// matching `metav1.Condition`; polychrome's reconcilers only ever emit
/// `True`/`False` today (every status point has a definite readiness), but
/// the variant is kept for wire-format completeness and forward
/// compatibility.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
pub enum ConditionStatus {
    /// The condition holds.
    True,
    /// The condition does not hold.
    False,
    /// Not evaluated / indeterminate.
    Unknown,
}

impl From<bool> for ConditionStatus {
    fn from(value: bool) -> Self {
        if value { Self::True } else { Self::False }
    }
}

/// Insert or update the condition named `type_` in `conditions`.
///
/// Follows `metav1.Condition` semantics: `last_transition_time` only
/// advances when `status` actually *changes* — an unchanged condition
/// re-observed at a later reconcile keeps its original transition time, so
/// `kubectl get -o yaml` reflects when the state last changed, not merely
/// when it was last reconciled. `reason`/`message` are always refreshed
/// (they may narrate a changed *cause* even when `status` itself hasn't
/// flipped). Pure — `now` is the caller's clock reading (RFC3339).
pub fn upsert_condition(
    conditions: &mut Vec<Condition>,
    type_: &str,
    status: ConditionStatus,
    reason: &str,
    message: &str,
    now: &str,
) {
    if let Some(existing) = conditions.iter_mut().find(|c| c.type_ == type_) {
        if existing.status != status {
            existing.status = status;
            now.clone_into(&mut existing.last_transition_time);
        }
        reason.clone_into(&mut existing.reason);
        message.clone_into(&mut existing.message);
    } else {
        conditions.push(Condition {
            type_: type_.to_owned(),
            status,
            reason: reason.to_owned(),
            message: message.to_owned(),
            last_transition_time: now.to_owned(),
            observed_generation: None,
        });
    }
}

#[cfg(test)]
mod tests {
    use super::{Condition, ConditionStatus, upsert_condition};

    /// A fresh condition is inserted with the given `now` as its transition
    /// time, and serializes with the k8s wire field names (`type`, not
    /// `type_`).
    #[test]
    fn upsert_condition_inserts_a_fresh_condition() {
        let mut conditions = Vec::new();
        upsert_condition(
            &mut conditions,
            "Progressing",
            ConditionStatus::True,
            "CreatingExecutionUnit",
            "waiting for the sandbox claim",
            "2026-07-11T00:00:00Z",
        );
        assert_eq!(conditions.len(), 1);
        assert_eq!(conditions[0].type_, "Progressing");
        assert_eq!(conditions[0].status, ConditionStatus::True);
        assert_eq!(conditions[0].last_transition_time, "2026-07-11T00:00:00Z");

        let json = serde_json::to_value(&conditions[0]).unwrap();
        assert_eq!(json["type"], "Progressing");
        assert_eq!(json["status"], "True");
    }

    /// The Ready → Progressing transition (and back) each stamp a fresh
    /// `lastTransitionTime`; re-observing the SAME status at a later
    /// reconcile must NOT bump it — only a real status flip does.
    #[test]
    fn upsert_condition_only_bumps_transition_time_on_a_real_flip() {
        let mut conditions = vec![Condition {
            type_: "Ready".to_owned(),
            status: ConditionStatus::False,
            reason: "WaitingForHarness".to_owned(),
            message: String::new(),
            last_transition_time: "2026-07-11T00:00:00Z".to_owned(),
            observed_generation: None,
        }];

        // Re-observed at a LATER time but SAME status: reason/message may
        // refresh, but the transition time must stay put.
        upsert_condition(
            &mut conditions,
            "Ready",
            ConditionStatus::False,
            "WaitingForHarness",
            "still waiting",
            "2026-07-11T00:05:00Z",
        );
        assert_eq!(conditions[0].last_transition_time, "2026-07-11T00:00:00Z");
        assert_eq!(conditions[0].message, "still waiting");

        // Now it actually flips True: the transition time DOES advance.
        upsert_condition(
            &mut conditions,
            "Ready",
            ConditionStatus::True,
            "HarnessReady",
            "the harness is dialable",
            "2026-07-11T00:10:00Z",
        );
        assert_eq!(conditions[0].status, ConditionStatus::True);
        assert_eq!(conditions[0].last_transition_time, "2026-07-11T00:10:00Z");
    }

    /// Ready/Progressing are independent condition types keyed by `type_` —
    /// upserting one never disturbs the other.
    #[test]
    fn ready_and_progressing_are_independent_conditions() {
        let mut conditions = Vec::new();
        upsert_condition(
            &mut conditions,
            "Progressing",
            ConditionStatus::True,
            "CreatingExecutionUnit",
            "",
            "2026-07-11T00:00:00Z",
        );
        upsert_condition(
            &mut conditions,
            "Ready",
            ConditionStatus::False,
            "WaitingForHarness",
            "",
            "2026-07-11T00:00:00Z",
        );
        assert_eq!(conditions.len(), 2);

        // Flipping Ready true + Progressing false (steady-state Ready) leaves
        // both entries present, updated independently.
        upsert_condition(
            &mut conditions,
            "Ready",
            ConditionStatus::True,
            "HarnessReady",
            "",
            "2026-07-11T00:01:00Z",
        );
        upsert_condition(
            &mut conditions,
            "Progressing",
            ConditionStatus::False,
            "HarnessReady",
            "",
            "2026-07-11T00:01:00Z",
        );
        assert_eq!(conditions.len(), 2);
        let ready = conditions.iter().find(|c| c.type_ == "Ready").unwrap();
        let progressing = conditions
            .iter()
            .find(|c| c.type_ == "Progressing")
            .unwrap();
        assert_eq!(ready.status, ConditionStatus::True);
        assert_eq!(progressing.status, ConditionStatus::False);
    }
}