use std::collections::BTreeMap;
use std::time::Duration;
use kube::{
Api, Client, Resource, ResourceExt,
api::{DeleteParams, Patch, PatchParams, Preconditions},
};
use polyc_k8s_types::sandboxclaim::{
SandboxClaim, SandboxClaimAdditionalPodMetadata, SandboxClaimLifecycle,
SandboxClaimLifecycleShutdownPolicy, SandboxClaimSandboxTemplateRef, SandboxClaimSpec,
};
use crate::conversation::Conversation;
use crate::reconcile::{Error, PENDING_POLL, SANDBOX_READY_POLL, ignore_not_found};
pub const EXECUTION_AUDIENCE_ANNOTATION: &str = "polychrome.sh/execution-audience";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ClaimReadiness {
pub unit_present: bool,
pub address: Option<DialAddress>,
pub harness_ready: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DialAddress {
PodIp(String),
}
impl DialAddress {
#[must_use]
pub fn to_status_fields(&self) -> (Option<String>, Option<String>) {
match self {
Self::PodIp(ip) => (Some(ip.clone()), None),
}
}
}
#[must_use]
pub fn claim_readiness(claim: Option<&SandboxClaim>) -> ClaimReadiness {
let Some(claim) = claim else {
return ClaimReadiness::default();
};
let status = claim.status.as_ref();
let pod_ip = status
.and_then(|s| s.sandbox.as_ref())
.and_then(|s| s.pod_i_ps.as_ref())
.and_then(|ips| ips.first().cloned());
let harness_ready = status
.and_then(|s| s.conditions.as_ref())
.is_some_and(|cs| cs.iter().any(|c| c.type_ == "Ready" && c.status == "True"));
ClaimReadiness {
unit_present: true,
address: pod_ip.map(DialAddress::PodIp),
harness_ready,
}
}
#[async_trait::async_trait]
pub trait ExecutionBackend: Send + Sync {
async fn ensure(
&self,
conv: &Conversation,
name: &str,
template: &str,
ns: &str,
) -> Result<(), Error>;
async fn readiness(&self, name: &str, ns: &str) -> Result<ClaimReadiness, Error>;
async fn teardown(&self, owner: &Conversation, name: &str, ns: &str) -> Result<(), Error>;
fn kind(&self) -> &'static str;
fn ready_poll_interval(&self) -> Duration {
PENDING_POLL
}
}
pub struct SandboxClaimBackend {
client: Client,
}
impl SandboxClaimBackend {
#[must_use]
pub const fn new(client: Client) -> Self {
Self { client }
}
fn claims(&self, ns: &str) -> Api<SandboxClaim> {
Api::namespaced(self.client.clone(), ns)
}
}
#[async_trait::async_trait]
impl ExecutionBackend for SandboxClaimBackend {
async fn ensure(
&self,
conv: &Conversation,
name: &str,
template: &str,
ns: &str,
) -> Result<(), Error> {
let claim = build_sandbox_claim(conv, name, template, ns);
let pp = PatchParams::apply("polychrome.dev/controller").force();
self.claims(ns)
.patch(name, &pp, &Patch::Apply(&claim))
.await?;
tracing::info!(claim = name, template, ns, "applied sandbox claim");
Ok(())
}
async fn readiness(&self, name: &str, ns: &str) -> Result<ClaimReadiness, Error> {
let claim = self.claims(ns).get_opt(name).await?;
Ok(claim_readiness(claim.as_ref()))
}
async fn teardown(&self, owner: &Conversation, name: &str, ns: &str) -> Result<(), Error> {
let claims = self.claims(ns);
let Some(claim) = claims.get_opt(name).await? else {
return Ok(());
};
let Some(params) = delete_params_for_owned_claim(owner, &claim) else {
tracing::warn!(
claim = name,
conversation = %owner.name_any(),
"refusing to delete an execution unit not owned by this conversation incarnation"
);
return Ok(());
};
ignore_not_found(claims.delete(name, ¶ms).await)
}
fn kind(&self) -> &'static str {
"sandboxclaim"
}
fn ready_poll_interval(&self) -> Duration {
SANDBOX_READY_POLL
}
}
fn delete_params_for_owned_claim(
owner: &Conversation,
claim: &SandboxClaim,
) -> Option<DeleteParams> {
let owner_uid = owner.uid()?;
let has_matching_owner = claim
.metadata
.owner_references
.iter()
.flatten()
.any(|reference| {
reference.controller == Some(true)
&& reference.kind == "Conversation"
&& reference.uid == owner_uid
});
if !has_matching_owner {
return None;
}
let claim_uid = claim.uid()?;
let claim_resource_version = claim.resource_version()?;
Some(DeleteParams {
preconditions: Some(Preconditions {
uid: Some(claim_uid),
resource_version: Some(claim_resource_version),
}),
..DeleteParams::default()
})
}
fn build_sandbox_claim(
conv: &Conversation,
claim_name: &str,
template: &str,
ns: &str,
) -> SandboxClaim {
if let Some(parent) = conv.spec.parent_conversation_id.as_deref() {
tracing::info!(
child_conversation = %claim_name,
parent_conversation = parent,
"creating sandbox claim for child conversation (handoff)"
);
}
let mut claim = SandboxClaim::new(
claim_name,
SandboxClaimSpec {
sandbox_template_ref: SandboxClaimSandboxTemplateRef {
name: template.to_owned(),
},
additional_pod_metadata: Some(SandboxClaimAdditionalPodMetadata {
annotations: Some(BTreeMap::from([(
EXECUTION_AUDIENCE_ANNOTATION.to_owned(),
claim_name.to_owned(),
)])),
labels: None,
}),
env: None,
lifecycle: Some(lifecycle_for(conv)),
warmpool: None,
},
);
claim.metadata.namespace = Some(ns.to_owned());
claim.metadata.owner_references = conv.controller_owner_ref(&()).map(|r| vec![r]);
if let Some(parent) = conv.spec.parent_conversation_id.as_deref() {
let labels = claim.metadata.labels.get_or_insert_with(Default::default);
labels.insert(
"polychrome.dev/parent-conversation".to_owned(),
parent.to_owned(),
);
}
claim
}
fn lifecycle_for(conv: &Conversation) -> SandboxClaimLifecycle {
SandboxClaimLifecycle {
shutdown_policy: Some(SandboxClaimLifecycleShutdownPolicy::Delete),
shutdown_time: None,
ttl_seconds_after_finished: Some(
i32::try_from(conv.spec.idle_timeout_seconds).unwrap_or(i32::MAX),
),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
const TPL: &str = "polychrome-harness-default";
#[test]
fn claim_binds_the_audience_as_pod_metadata_and_keeps_pool_adoption() {
let owner = Conversation::new(
"conversation-a",
crate::conversation::ConversationSpec {
model: String::new(),
principal_ref: "persona-1".to_owned(),
idle_timeout_seconds: 300,
tools_enabled: Vec::new(),
tools_disabled: Vec::new(),
parent_conversation_id: None,
agent_id: None,
},
);
let claim = build_sandbox_claim(&owner, "conversation-a", TPL, "polychrome");
assert_eq!(
claim
.spec
.additional_pod_metadata
.as_ref()
.and_then(|metadata| metadata.annotations.as_ref())
.and_then(|annotations| annotations
.get("polychrome.sh/execution-audience")
.map(String::as_str)),
Some("conversation-a"),
"the claim must deliver the audience as pod metadata"
);
assert_eq!(
claim.spec.env, None,
"claim environment injection blocks warm-pod adoption"
);
assert_eq!(
claim.spec.warmpool, None,
"the claim must accept the default warm pool"
);
}
#[test]
fn claim_readiness_none_is_not_ready() {
assert_eq!(claim_readiness(None), ClaimReadiness::default());
}
#[test]
fn claim_readiness_reads_pod_ip_and_ready_condition() {
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, Time};
use polyc_k8s_types::sandboxclaim::{
SandboxClaimSpec, SandboxClaimStatus, SandboxClaimStatusSandbox,
};
let mut claim = SandboxClaim::new(
"c1",
SandboxClaimSpec {
sandbox_template_ref: super::SandboxClaimSandboxTemplateRef {
name: TPL.to_owned(),
},
additional_pod_metadata: None,
env: None,
lifecycle: None,
warmpool: None,
},
);
claim.status = Some(SandboxClaimStatus {
conditions: Some(vec![Condition {
type_: "Ready".to_owned(),
status: "True".to_owned(),
reason: "PodRunning".to_owned(),
message: String::new(),
observed_generation: None,
last_transition_time: Time("2026-05-27T00:00:00Z".parse().unwrap()),
}]),
sandbox: Some(SandboxClaimStatusSandbox {
name: Some("c1-sbx".to_owned()),
pod_i_ps: Some(vec!["10.4.2.7".to_owned(), "fd00::7".to_owned()]),
}),
});
assert_eq!(
claim_readiness(Some(&claim)),
ClaimReadiness {
unit_present: true,
address: Some(DialAddress::PodIp("10.4.2.7".to_owned())),
harness_ready: true,
}
);
}
#[test]
fn claim_readiness_not_ready_without_ready_condition() {
use polyc_k8s_types::sandboxclaim::{
SandboxClaimSpec, SandboxClaimStatus, SandboxClaimStatusSandbox,
};
let mut claim = SandboxClaim::new(
"c1",
SandboxClaimSpec {
sandbox_template_ref: super::SandboxClaimSandboxTemplateRef {
name: TPL.to_owned(),
},
additional_pod_metadata: None,
env: None,
lifecycle: None,
warmpool: None,
},
);
claim.status = Some(SandboxClaimStatus {
conditions: None,
sandbox: Some(SandboxClaimStatusSandbox {
name: None,
pod_i_ps: Some(vec!["10.4.2.7".to_owned()]),
}),
});
assert_eq!(
claim_readiness(Some(&claim)),
ClaimReadiness {
unit_present: true,
address: Some(DialAddress::PodIp("10.4.2.7".to_owned())),
harness_ready: false,
}
);
}
#[test]
fn claim_delete_is_pinned_to_the_observed_owned_incarnation() {
let mut owner = Conversation::new(
"c1",
crate::conversation::ConversationSpec {
model: String::new(),
principal_ref: "persona-1".to_owned(),
idle_timeout_seconds: 300,
tools_enabled: Vec::new(),
tools_disabled: Vec::new(),
parent_conversation_id: None,
agent_id: None,
},
);
owner.metadata.uid = Some("conversation-uid".to_owned());
let mut claim = build_sandbox_claim(&owner, "c1", TPL, "polychrome");
claim.metadata.uid = Some("claim-uid".to_owned());
claim.metadata.resource_version = Some("claim-rv".to_owned());
let params = delete_params_for_owned_claim(&owner, &claim).expect("owned live claim");
let preconditions = params.preconditions.expect("delete preconditions");
assert_eq!(preconditions.uid.as_deref(), Some("claim-uid"));
assert_eq!(preconditions.resource_version.as_deref(), Some("claim-rv"));
owner.metadata.uid = Some("replacement-conversation".to_owned());
assert!(
delete_params_for_owned_claim(&owner, &claim).is_none(),
"a stale reconcile must not delete a replacement owner's claim"
);
}
}