polyc-controller 2026.9.0

Conversation CRD + kube reconciler for the polychrome control plane.
//! The `Conversation` custom resource (`polychrome.dev/v1alpha1`).
//!
//! One `Conversation` is reconciled into one agent-sandbox `SandboxClaim`
//! (and thus one isolated harness pod). See [the reconciler](mod@crate::reconcile).

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

// The condition shape (and `upsert_condition`) started life here, then moved
// to its own module once `RoutineStatus` (#1370) needed the identical
// `metav1.Condition` shape — re-exported so every existing
// `crate::conversation::{Condition, ConditionStatus, upsert_condition}`
// caller keeps working unchanged.
pub use crate::condition::{Condition, ConditionStatus, upsert_condition};

/// Finalizer key the controller adds so a `Conversation` deletion blocks until
/// its `SandboxClaim` has been cleaned up.
pub const FINALIZER: &str = "polychrome.dev/cleanup";

/// Desired state of a conversation.
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[kube(
    group = "polychrome.dev",
    version = "v1alpha1",
    kind = "Conversation",
    namespaced,
    status = "ConversationStatus",
    shortname = "conv",
    category = "polychrome",
    derive = "PartialEq",
    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#,
    printcolumn = r#"{"name":"Model","type":"string","jsonPath":".spec.model"}"#,
    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct ConversationSpec {
    /// Model identifier the planner should use (e.g. `"fast-2"`).
    pub model: String,
    /// The durable principal this conversation runs under: the `PersonaId`
    /// (`UUIDv7`) the caller's edge identity resolved to. Personas — not raw
    /// `(provider, id)` strings — are the principal (personas design §1). Empty
    /// for turns with no attributable caller, which run under the system
    /// principal.
    // `#[serde(default)]`: a missing `principalRef` deserializes to the empty
    // (system) principal instead of failing to parse, so the control plane can
    // read a CR that does not carry the field. The API server does not populate
    // a field a stored object was written without, so the reader tolerates its
    // absence. (Plain `//` so this implementation note stays out of the CRD's
    // public field description.)
    #[serde(default)]
    pub principal_ref: String,
    /// Idle timeout, in seconds, after which the conversation (and its pod)
    /// is torn down.
    pub idle_timeout_seconds: u32,
    /// Subagent/tool names enabled for this conversation
    /// (e.g. `["slacksearch", "websearch", "py"]`).
    #[serde(default)]
    pub tools_enabled: Vec<String>,
    /// Connector names withdrawn from this conversation. The deny wins over
    /// both the operator's `defaultEnabled` broadcast and `toolsEnabled`, so
    /// a misbehaving broadcast connector can be pulled from one conversation
    /// without un-broadcasting it for everyone. Empty denies nothing.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tools_disabled: Vec<String>,
    /// Identifies the parent conversation for a handoff-created child. The
    /// value forms the `conv-{parent_conversation_id}` partition suffix. The
    /// controller still gives the child its own `SandboxClaim`. This metadata
    /// adds the `polychrome.dev/parent-conversation` label to that claim. The
    /// control plane writes the parent journal event through a separate path.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_conversation_id: Option<String>,
    /// Optional agent identifier this conversation should run (e.g.
    /// `"researcher"`). For a top-level conversation this is the planner the
    /// adapter selected; for a child it carries the parent's
    /// `handoff_to(child_agent_id, …)` argument across the controller
    /// boundary so the child harness knows which planner to load on its
    /// first turn.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent_id: Option<String>,
}

/// Observed state, written back to the `status` subresource by the controller.
///
/// `struct_excessive_bools`: each bool is an independently-toggled wire field
/// on a Kubernetes CR status (`closed`/`harnessReady`/`idleReclaimed`/
/// `rollingHarness`), not a set of caller-supplied flags a builder API would
/// confuse for each other — a state-machine enum would still need to project
/// back onto these same independent JSON keys for `kubectl`/status-patch
/// compatibility, so it would only add an indirection layer here.
#[allow(clippy::struct_excessive_bools)]
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ConversationStatus {
    /// Name of the `SandboxClaim` created for this conversation, once it exists.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sandbox_claim_name: Option<String>,
    /// IP of the harness pod, once the `SandboxClaim` reports `Ready`. Set by
    /// the agent-sandbox backend; the control plane dials
    /// `http://{podIp}:{harnessPort}`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pod_ip: Option<String>,
    /// A complete dial URL (`http://host:port`) for the harness, for a backend
    /// that doesn't address the harness by a raw pod IP. No current backend
    /// sets this — agent-sandbox always populates [`Self::pod_ip`] instead —
    /// kept on the status schema (additive-only) for a future backend that
    /// needs it. Would take precedence over [`Self::pod_ip`] on the
    /// control-plane dial path.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub harness_endpoint: Option<String>,
    /// `true` once the harness is dialable.
    #[serde(default)]
    pub harness_ready: bool,
    /// Set by the planner/adapter when the conversation should be torn down.
    #[serde(default)]
    pub closed: bool,
    /// Coarse lifecycle phase for humans/dashboards
    /// (`"Pending"`, `"Ready"`, `"Closing"`, `"Paused"`). Display only — the idle
    /// reaper keys on `idle_reclaimed`, not this string.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub phase: Option<String>,
    /// Unix-seconds timestamp of the last turn, stamped by the control plane on
    /// every turn. The reconciler closes a conversation idle longer than
    /// `spec.idleTimeoutSeconds` so its harness pod is reaped — `ttlSecondsAfterFinished`
    /// can't, because a long-running harness never "finishes" (issue #265).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_activity_unix: Option<i64>,
    /// Whether the backend has reclaimed this idle conversation's resources
    /// while keeping it resumable. No code path sets this true anymore — the
    /// backend hook that used to (a resource-reclaiming idle disposition) was
    /// removed along with its only implementation. Kept on the schema
    /// (CRD status fields are additive-only) and still consulted defensively
    /// by the idle reaper and the phase display, in case an existing object
    /// already carries it true. The control plane's per-turn activity stamp
    /// still clears it on every turn regardless, so a resumed conversation can
    /// never get stuck showing `Paused`.
    #[serde(default)]
    pub idle_reclaimed: bool,
    /// Standard `status.conditions`, wire-compatible with `metav1.Condition`
    /// (`Ready`/`Progressing`/`Degraded`), so `kubectl get conversation` and
    /// generic condition-aware tooling can reason about readiness without
    /// polychrome-specific knowledge of [`Self::phase`]'s free-form strings.
    /// Populated by the reconciler at every point it patches `phase` (see
    /// `reconcile::conditions_triad`).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub conditions: Vec<Condition>,
    /// The harness image generation this conversation's execution unit was
    /// last created or rolled onto — an operator-supplied opaque string (see
    /// `POLYCHROME_HARNESS_IMAGE_GENERATION`), not something compared
    /// semantically. `None` until either the roll-on-image-change feature has
    /// never been enabled, or the claim predates this field. Stamped by the
    /// `SyncStatus` reconcile arm once a desired generation is known (see
    /// `reconcile::ReconcileAction::RollHarness`'s doc comment for the
    /// adopt-without-rolling semantics), and cleared back to `None` by a
    /// `RollHarness` teardown so the post-roll `SyncStatus` pass re-stamps the
    /// new value through the same path.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub harness_image_generation: Option<String>,
    /// `true` from the instant a `RollHarness` teardown fires until the
    /// replacement execution unit resyncs back to `Ready` — the
    /// reconciler-visible "currently mid-roll" signal
    /// `reconcile::Context::count_in_flight_rolls` counts to enforce the
    /// harness-roll concurrency cap. Distinct from [`Self::phase`] (which is
    /// display-only and gets overwritten to `"Pending"` by the very next
    /// `CreateSandboxClaim` pass, well before the pod is `Ready`): this field
    /// is the one the cap actually reads, so it has to survive that
    /// overwrite. Cleared by the `SyncStatus` reconcile arm once the
    /// replacement claim reports `harness_ready`.
    #[serde(default)]
    pub rolling_harness: bool,
    /// Unix-seconds timestamp of the moment this conversation's execution
    /// unit finished tearing down while `closed` — i.e. when
    /// `sandboxClaimName` was cleared with `closed` already `true`. Stamped
    /// by the `Cleanup` reconcile arm's non-finalizer-removal branch.
    /// Deliberately NOT [`Self::last_activity_unix`] (the last real turn,
    /// which predates closing and stays frozen through dormancy): the
    /// closed-conversation GC measures retention from this stamp so an
    /// operator can tune how long a fully-torn-down CR lingers independently
    /// of how long the conversation took to go idle. `None` for a CR that
    /// predates this field or has never fully closed — either way, GC never
    /// fires without a real stamp (mirrors the idle reaper's own "no stamp ⇒
    /// never closed" rule).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub closed_at: Option<i64>,
}

#[cfg(test)]
mod tests {
    use super::ConversationSpec;

    /// A Conversation spec that does not carry `principalRef` must still
    /// deserialize — to the empty (system) principal — rather than failing to
    /// parse. Locks the `#[serde(default)]` so the control plane can read a CR
    /// written without the field.
    #[test]
    fn spec_without_principal_ref_deserializes_to_empty() {
        let spec: ConversationSpec = serde_json::from_value(serde_json::json!({
            "model": "gemini-3-flash-preview",
            "idleTimeoutSeconds": 600,
            "toolsEnabled": [],
        }))
        .expect("a pre-principalRef spec must still deserialize");
        assert!(
            spec.principal_ref.is_empty(),
            "a missing principalRef defaults to the empty (system) principal"
        );
    }

    /// The per-conversation deny list is optional on the wire (a CR written
    /// before the field existed still parses) and round-trips when present.
    #[test]
    fn tools_disabled_defaults_to_empty_and_round_trips() {
        let spec: ConversationSpec = serde_json::from_value(serde_json::json!({
            "model": "m",
            "idleTimeoutSeconds": 600,
        }))
        .expect("a spec without toolsDisabled must still deserialize");
        assert!(
            spec.tools_disabled.is_empty(),
            "missing deny list means deny nothing"
        );

        let spec: ConversationSpec = serde_json::from_value(serde_json::json!({
            "model": "m",
            "idleTimeoutSeconds": 600,
            "toolsDisabled": ["standup"],
        }))
        .expect("deserialize");
        assert_eq!(spec.tools_disabled, vec!["standup".to_owned()]);
    }

    /// A present `principalRef` still round-trips.
    #[test]
    fn spec_with_principal_ref_round_trips() {
        let spec: ConversationSpec = serde_json::from_value(serde_json::json!({
            "model": "m",
            "principalRef": "019eb8da-a39f-7dc0-af61-49c0dbbca45f",
            "idleTimeoutSeconds": 600,
        }))
        .expect("deserialize");
        assert_eq!(spec.principal_ref, "019eb8da-a39f-7dc0-af61-49c0dbbca45f");
    }
}