use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::condition::Condition;
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub enum RoutineScope {
Public,
#[default]
Private,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum RoutineSchedule {
Cron {
expression: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
timezone: Option<String>,
},
Once {
at: String,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RoutinePayload {
pub prompt: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RoutineProvenance {
pub creator_persona: String,
pub conversation_id: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RoutineSuspend {
pub paused_by: String,
pub paused_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[kube(
group = "polychrome.dev",
version = "v1alpha1",
kind = "Routine",
namespaced,
status = "RoutineStatus",
shortname = "rtn",
category = "polychrome",
derive = "PartialEq",
validation = Rule::new("self.spec.provenance == oldSelf.spec.provenance").message(
"A routine's provenance records who created it and from which conversation. It's set once \
when the routine is created and can't be changed afterward."
),
printcolumn = r#"{"name":"Ready","type":"boolean","jsonPath":".status.ready"}"#,
printcolumn = r#"{"name":"Scope","type":"string","jsonPath":".spec.scope"}"#,
printcolumn = r#"{"name":"LastFire","type":"date","jsonPath":".status.lastFireTime"}"#,
printcolumn = r#"{"name":"NextFire","type":"date","jsonPath":".status.nextFireTime"}"#,
printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct RoutineSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub scope: RoutineScope,
pub schedule: RoutineSchedule,
pub payload: RoutinePayload,
pub provenance: RoutineProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suspend: Option<RoutineSuspend>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RoutineStatus {
#[serde(default)]
pub ready: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_fire_time: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_fire_time: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conditions: Vec<Condition>,
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use kube::CustomResourceExt;
use serde_json::{Value, json};
fn provenance() -> RoutineProvenance {
RoutineProvenance {
creator_persona: "persona-1".to_owned(),
conversation_id: "conv-1".to_owned(),
}
}
#[test]
fn crd_identity_is_polychrome_routine() {
let crd = Routine::crd();
assert_eq!(crd.spec.group, "polychrome.dev");
assert_eq!(crd.spec.names.kind, "Routine");
assert_eq!(crd.spec.names.plural, "routines");
}
#[test]
fn crd_carries_a_provenance_immutability_cel_rule() {
let crd = Routine::crd();
let json = serde_json::to_value(&crd).expect("crd serializes");
let rules =
json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["x-kubernetes-validations"]
.clone();
let rules = rules.as_array().expect("at least one CEL rule");
assert!(
rules.iter().any(|r| {
r["rule"]
.as_str()
.is_some_and(|rule| rule.contains("provenance") && rule.contains("oldSelf"))
}),
"expected a provenance immutability rule referencing oldSelf, got: {rules:?}"
);
}
#[test]
fn crd_schema_carries_no_run_as_property() {
let crd = Routine::crd();
let json = serde_json::to_value(&crd).expect("crd serializes");
let properties = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
["properties"];
assert!(
properties.get("runAs").is_none(),
"runAs must not appear in the CRD schema: {properties}"
);
}
#[test]
fn full_routine_yaml_round_trips() {
let yaml = json!({
"description": "post a daily standup summary",
"scope": "private",
"schedule": { "cron": { "expression": "0 9 * * 1-5" } },
"payload": { "prompt": "Post a short standup summary to #standup." },
"provenance": { "creatorPersona": "persona-1", "conversationId": "conv-1" },
});
let spec: RoutineSpec = serde_json::from_value(yaml).expect("full spec deserializes");
assert_eq!(spec.scope, RoutineScope::Private);
assert_eq!(
spec.payload.prompt,
"Post a short standup summary to #standup."
);
assert_eq!(spec.suspend, None, "omitted suspend defaults absent");
let v: Value = serde_json::to_value(&spec).unwrap();
assert!(v.get("runAs").is_none(), "runAs must never serialize: {v}");
}
#[test]
fn suspend_is_additive_and_absent_by_default() {
let spec = spec_with("Post a short standup summary to #standup.");
assert_eq!(spec.suspend, None);
let v = serde_json::to_value(&spec).unwrap();
assert!(
v.get("suspend").is_none(),
"an unset suspend must not even serialize the key: {v}"
);
}
#[test]
fn suspend_with_a_reason_round_trips_camel_case() {
let mut spec = spec_with("Post a short standup summary to #standup.");
spec.suspend = Some(RoutineSuspend {
paused_by: "persona-1".to_owned(),
paused_at: "2026-07-23T00:00:00Z".to_owned(),
reason: Some("rotating out old announcements".to_owned()),
});
let v = serde_json::to_value(&spec).unwrap();
assert_eq!(v["suspend"]["pausedBy"], "persona-1");
assert_eq!(v["suspend"]["pausedAt"], "2026-07-23T00:00:00Z");
assert_eq!(v["suspend"]["reason"], "rotating out old announcements");
assert_eq!(serde_json::from_value::<RoutineSpec>(v).unwrap(), spec);
}
#[test]
fn suspend_without_a_reason_round_trips_and_omits_the_key() {
let mut spec = spec_with("Post a short standup summary to #standup.");
spec.suspend = Some(RoutineSuspend {
paused_by: "persona-1".to_owned(),
paused_at: "2026-07-23T00:00:00Z".to_owned(),
reason: None,
});
let v = serde_json::to_value(&spec).unwrap();
assert!(
v["suspend"].get("reason").is_none(),
"an absent reason must not serialize the key: {v}"
);
assert_eq!(serde_json::from_value::<RoutineSpec>(v).unwrap(), spec);
}
#[test]
fn bare_string_schedule_is_rejected_not_migrated() {
let yaml = json!({
"scope": "private",
"schedule": "0 9 * * 1-5",
"payload": { "prompt": "Post a short standup summary to #standup." },
"provenance": { "creatorPersona": "persona-1", "conversationId": "conv-1" },
});
assert!(
serde_json::from_value::<RoutineSpec>(yaml).is_err(),
"a bare-string schedule must fail to deserialize, not silently migrate"
);
}
#[test]
fn schedule_cron_and_once_both_deserialize() {
let cron: RoutineSchedule =
serde_json::from_value(json!({ "cron": { "expression": "0 9 * * 1-5" } }))
.expect("cron variant deserializes");
assert_eq!(
cron,
RoutineSchedule::Cron {
expression: "0 9 * * 1-5".to_owned(),
timezone: None,
}
);
let once: RoutineSchedule =
serde_json::from_value(json!({ "once": { "at": "2026-08-01T15:00:00Z" } }))
.expect("once variant deserializes");
assert_eq!(
once,
RoutineSchedule::Once {
at: "2026-08-01T15:00:00Z".to_owned(),
}
);
}
#[test]
fn scope_accepts_exactly_public_and_private() {
for (raw, expected) in [
("public", RoutineScope::Public),
("private", RoutineScope::Private),
] {
let scope: RoutineScope =
serde_json::from_value(json!(raw)).unwrap_or_else(|_| panic!("{raw} parses"));
assert_eq!(scope, expected);
}
assert_eq!(RoutineScope::default(), RoutineScope::Private);
}
#[test]
fn scope_rejects_the_retired_reserved_values() {
for raw in ["instance", "persona", "shared"] {
assert!(
serde_json::from_value::<RoutineScope>(json!(raw)).is_err(),
"{raw} must be rejected, not migrated"
);
}
}
#[test]
fn spec_round_trips_camel_case() {
let spec = RoutineSpec {
description: Some("standup".to_owned()),
scope: RoutineScope::Private,
schedule: RoutineSchedule::Cron {
expression: "0 9 * * 1-5".to_owned(),
timezone: Some("America/New_York".to_owned()),
},
payload: RoutinePayload {
prompt: "Post a short standup summary to #standup.".to_owned(),
},
provenance: provenance(),
suspend: None,
};
let v = serde_json::to_value(&spec).unwrap();
assert!(v["schedule"]["cron"].is_object(), "{v}");
assert_eq!(v["schedule"]["cron"]["timezone"], "America/New_York");
assert_eq!(
v["payload"]["prompt"],
Value::from("Post a short standup summary to #standup."),
"{v}"
);
assert_eq!(v["provenance"]["creatorPersona"], Value::from("persona-1"));
assert!(v.get("runAs").is_none(), "runAs must never serialize: {v}");
assert_eq!(serde_json::from_value::<RoutineSpec>(v).unwrap(), spec);
}
#[test]
fn old_shape_manifest_with_class_field_is_rejected() {
let yaml = json!({
"class": "fixedContent",
"agentId": null,
"schedule": "0 9 * * *",
"contentTemplates": [],
});
assert!(
serde_json::from_value::<RoutineSpec>(yaml).is_err(),
"old-shape `class`-discriminated manifest must fail admission, not migrate"
);
}
#[test]
fn old_fixed_content_payload_shape_is_rejected_not_migrated() {
let yaml = json!({
"scope": "private",
"schedule": { "cron": { "expression": "0 9 * * *" } },
"payload": {
"fixedContent": {
"contentTemplates": [{
"name": "standup_summary_v1",
"destination": { "provider": "slack", "channel": "C0STANDUP" },
"slots": [],
"prose": "{title_line}...",
}],
},
},
"provenance": { "creatorPersona": "persona-1", "conversationId": "conv-1" },
});
assert!(
serde_json::from_value::<RoutineSpec>(yaml).is_err(),
"the pre-#1591 tagged-union payload shape must fail to deserialize, not migrate"
);
}
fn spec_with(prompt: &str) -> RoutineSpec {
RoutineSpec {
description: None,
scope: RoutineScope::Private,
schedule: RoutineSchedule::Cron {
expression: "0 9 * * 1-5".to_owned(),
timezone: None,
},
payload: RoutinePayload {
prompt: prompt.to_owned(),
},
provenance: provenance(),
suspend: None,
}
}
}