polyc-controller 2026.10.0

Conversation CRD + kube reconciler for the polychrome control plane.
//! The execution-backend seam.
//!
//! Defines the [`ExecutionBackend`] trait and its readiness type
//! ([`UnitReadiness`], [`DialAddress`]). It also defines [`ActorControl`], the
//! narrow port over the substrate control API that the one backend uses. The
//! backend itself is [`SubstrateActorBackend`](crate::substrate_backend::SubstrateActorBackend).
//! The reconcile state machine that drives it lives in
//! [the reconcile module](mod@crate::reconcile).
//!
//! # Why `ActorControl` lives here and not in the client crate
//!
//! This crate is published and the substrate client crate is not. The client
//! crate is also a network adapter, and `arch-capabilities.toml` forbids a
//! shared crate from depending on one. So this crate names the operations it
//! needs in plain data types. The control plane implements them over the
//! client and injects the result at the composition root.

use crate::conversation::Conversation;
use crate::reconcile::Error;

/// The lifecycle state of a substrate actor, as the control API reports it.
///
/// Mirrors `ActorState` in the substrate API without depending on it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActorState {
    /// The actor is starting or restoring on a worker.
    Resuming,
    /// The actor runs on a worker.
    Running,
    /// The actor is writing its snapshot.
    Suspending,
    /// The actor holds a snapshot and no worker.
    Suspended,
    /// The actor is pausing.
    Pausing,
    /// The actor is paused.
    Paused,
    /// The actor lost its worker or failed to restore. Substrate never
    /// recovers it. The controller deletes it and creates a new one.
    Crashed,
    /// The actor is being deleted.
    Deleting,
    /// The actor is reverting to an earlier snapshot.
    Reverting,
}

/// What the controller needs to know about one actor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActorSnapshot {
    /// The server-assigned identity of this incarnation of the actor.
    pub uid: String,
    /// The lifecycle state.
    pub state: ActorState,
    /// The name of the template the actor resumes from.
    pub template: String,
}

/// A failed call to the substrate control API.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("substrate control API: {0}")]
pub struct ActorControlError(pub String);

/// What a [`ActorControl::resume`] call reports.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResumeOutcome {
    /// The actor runs on a worker, or a worker is starting it. Resuming an
    /// actor that already runs is a no-op that reports this too.
    Running,
    /// The scheduler found no free worker for the actor. The caller may wait
    /// and try again. The actor is unchanged.
    NoCapacity,
}

/// The operations the controller performs on substrate actors.
///
/// Every method is idempotent where it can be. `get` and `delete` treat a
/// missing actor as a normal outcome, not an error.
#[async_trait::async_trait]
pub trait ActorControl: Send + Sync {
    /// Create the atespace if it does not exist.
    ///
    /// # Errors
    ///
    /// Returns [`ActorControlError`] when the call fails.
    async fn ensure_atespace(&self, atespace: &str) -> Result<(), ActorControlError>;

    /// Read an actor, or `None` when it does not exist.
    ///
    /// # Errors
    ///
    /// Returns [`ActorControlError`] when the call fails.
    async fn get(
        &self,
        atespace: &str,
        name: &str,
    ) -> Result<Option<ActorSnapshot>, ActorControlError>;

    /// Create an actor from `template`. The template lives in the same atespace.
    ///
    /// # Errors
    ///
    /// Returns [`ActorControlError`] when the call fails, including when the
    /// actor already exists.
    async fn create(
        &self,
        atespace: &str,
        name: &str,
        template: &str,
    ) -> Result<ActorSnapshot, ActorControlError>;

    /// Suspend a running actor. The actor keeps its `/workspace`.
    ///
    /// # Errors
    ///
    /// Returns [`ActorControlError`] when the call fails.
    async fn suspend(&self, atespace: &str, name: &str) -> Result<(), ActorControlError>;

    /// Resume a suspended actor onto a free worker, or report that none is free.
    ///
    /// Resuming an actor that already runs is a no-op. A missing free worker is
    /// [`ResumeOutcome::NoCapacity`], not an error.
    ///
    /// # Errors
    ///
    /// Returns [`ActorControlError`] when the call fails for any other reason.
    async fn resume(&self, atespace: &str, name: &str) -> Result<ResumeOutcome, ActorControlError>;

    /// Point an existing actor at another template. It takes effect on the
    /// next resume, and the actor keeps its `/workspace`.
    ///
    /// # Errors
    ///
    /// Returns [`ActorControlError`] when the call fails.
    async fn set_template(
        &self,
        atespace: &str,
        name: &str,
        template: &str,
    ) -> Result<(), ActorControlError>;

    /// Delete the actor only when it is still the incarnation `uid`. A missing
    /// actor is success.
    ///
    /// # Errors
    ///
    /// Returns [`ActorControlError`] when the call fails for any reason other
    /// than the actor being absent.
    async fn delete(&self, atespace: &str, name: &str, uid: &str) -> Result<(), ActorControlError>;
}

/// Readiness distilled from an execution unit's status, for mirroring into the
/// `Conversation` status.
///
/// The backend-neutral readiness type returned by
/// [`ExecutionBackend::readiness`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UnitReadiness {
    /// Whether the execution **unit object** exists at all, independent of
    /// whether its harness is reachable yet.
    ///
    /// This is the gone-versus-transient discriminator for `unit_is_gone`. A
    /// suspended actor is present. Only a genuinely absent actor reports
    /// `false`.
    pub unit_present: bool,
    /// Where the harness is dialed, if the unit exists.
    pub address: Option<DialAddress>,
    /// `true` when the control plane may dial the harness now. A suspended
    /// actor is ready, because the router resumes it on the next request.
    pub harness_ready: bool,
    /// `true` when the unit crashed. The controller deletes a crashed unit and
    /// creates a new one.
    pub crashed: bool,
    /// The identity of this incarnation of the unit, when it exists.
    pub uid: Option<String>,
}

/// How the control plane reaches a conversation's harness: through the router,
/// targeting one actor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DialAddress {
    /// A substrate actor, named by its atespace and name.
    Actor {
        /// The atespace. It is the `Conversation`'s namespace.
        atespace: String,
        /// The actor name. It is the `Conversation`'s name.
        name: String,
    },
}

/// What [`ExecutionBackend::ensure`] returns.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnsuredUnit {
    /// The identity of the unit now bound to the conversation.
    pub uid: String,
}

/// The pluggable execution mechanism the reconciler drives each
/// [`Conversation`] onto.
///
/// Today the only execution unit is a substrate actor
/// ([`SubstrateActorBackend`](crate::substrate_backend::SubstrateActorBackend)).
/// The trait lifts the IO steps the reconciler performs against that unit
/// behind a seam. The backend-agnostic parts (the finalizer patch and the
/// status patch on the `Conversation` object) stay in
/// [the reconcile module](mod@crate::reconcile).
#[async_trait::async_trait]
pub trait ExecutionBackend: Send + Sync {
    /// Create the execution unit for `conv`, named `name`, from `template`, in
    /// namespace `ns`. When the unit already exists, return it unchanged.
    ///
    /// # Errors
    ///
    /// Returns [`Error`] if the create fails.
    async fn ensure(
        &self,
        conv: &Conversation,
        name: &str,
        template: &str,
        ns: &str,
    ) -> Result<EnsuredUnit, Error>;

    /// Read the named unit's readiness. The default (not present) value means
    /// the unit does not exist.
    ///
    /// # Errors
    ///
    /// Returns [`Error`] if the read fails.
    async fn readiness(&self, name: &str, ns: &str) -> Result<UnitReadiness, Error>;

    /// Suspend the named unit, keeping its state. The next request resumes it.
    ///
    /// # Errors
    ///
    /// Returns [`Error`] if the suspend fails.
    async fn suspend(&self, name: &str, ns: &str) -> Result<(), Error>;

    /// Move the named unit onto `template` without losing its state. The move
    /// takes effect when the unit next resumes.
    ///
    /// # Errors
    ///
    /// Returns [`Error`] if the update fails.
    async fn retemplate(&self, name: &str, template: &str, ns: &str) -> Result<(), Error>;

    /// Idempotently delete the named unit. A missing unit is success. The delete
    /// applies only to the incarnation the owner recorded.
    ///
    /// # Errors
    ///
    /// Returns [`Error`] if the delete fails for any reason other than absence.
    async fn teardown(&self, owner: &Conversation, name: &str, ns: &str) -> Result<(), Error>;

    /// Short identifier for logs and metrics, for example `"substrate-actor"`.
    fn kind(&self) -> &'static str;
}