#![forbid(unsafe_code)]
use std::{collections::BTreeMap, fmt};
use dekopon_capability::{EffectKind, Idempotency, Permission};
pub use dekopon_core::AgentStatus;
use dekopon_core::{CapabilityId, ProviderId, RiskLevel};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
pub enum ApiVersion {
#[serde(rename = "dekopon.dev/v1alpha1")]
V1Alpha1,
}
impl fmt::Display for ApiVersion {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::V1Alpha1 => formatter.write_str("dekopon.dev/v1alpha1"),
}
}
}
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
#[serde(rename_all = "PascalCase")]
pub enum Kind {
Agent,
Capability,
Provider,
AgentList,
CapabilityList,
ProviderList,
}
impl fmt::Display for Kind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{self:?}")
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ObjectMeta {
pub name: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
}
impl ObjectMeta {
#[must_use]
pub fn named(name: impl Into<String>) -> Self {
Self {
name: name.into(),
labels: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct AgentSpec {
pub description: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<CapabilityId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub providers: Vec<ProviderId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_class: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_profile: Option<String>,
}
const fn default_enabled() -> bool {
true
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct Agent {
pub api_version: ApiVersion,
pub kind: Kind,
pub metadata: ObjectMeta,
pub spec: AgentSpec,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<AgentStatus>,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct CapabilitySpec {
pub description: String,
pub provider: ProviderId,
pub effect: EffectKind,
pub risk: RiskLevel,
pub idempotency: Idempotency,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub permissions: Vec<Permission>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "PascalCase")]
pub enum CapabilityStatus {
Available,
Unavailable,
Unknown,
}
impl fmt::Display for CapabilityStatus {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{self:?}")
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct Capability {
pub api_version: ApiVersion,
pub kind: Kind,
pub metadata: ObjectMeta,
pub spec: CapabilitySpec,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<CapabilityStatus>,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ProviderSpec {
pub description: String,
#[serde(rename = "type")]
pub provider_type: String,
pub credential_ref: String,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "PascalCase")]
pub enum ProviderStatus {
Ready,
Unavailable,
Unknown,
}
impl fmt::Display for ProviderStatus {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{self:?}")
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct Provider {
pub api_version: ApiVersion,
pub kind: Kind,
pub metadata: ObjectMeta,
pub spec: ProviderSpec,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<ProviderStatus>,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct AgentList {
pub api_version: ApiVersion,
pub kind: Kind,
pub items: Vec<Agent>,
}
impl AgentList {
#[must_use]
pub const fn new(items: Vec<Agent>) -> Self {
Self {
api_version: ApiVersion::V1Alpha1,
kind: Kind::AgentList,
items,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct CapabilityList {
pub api_version: ApiVersion,
pub kind: Kind,
pub items: Vec<Capability>,
}
impl CapabilityList {
#[must_use]
pub const fn new(items: Vec<Capability>) -> Self {
Self {
api_version: ApiVersion::V1Alpha1,
kind: Kind::CapabilityList,
items,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ProviderList {
pub api_version: ApiVersion,
pub kind: Kind,
pub items: Vec<Provider>,
}
impl ProviderList {
#[must_use]
pub const fn new(items: Vec<Provider>) -> Self {
Self {
api_version: ApiVersion::V1Alpha1,
kind: Kind::ProviderList,
items,
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use dekopon_capability::{EffectKind, Idempotency};
use dekopon_core::{AgentStatus, ProviderId, RiskLevel};
use schemars::schema_for;
use super::{Agent, AgentSpec, ApiVersion, Capability, CapabilitySpec, Kind, ObjectMeta};
fn agent() -> Agent {
Agent {
api_version: ApiVersion::V1Alpha1,
kind: Kind::Agent,
metadata: ObjectMeta {
name: "reviewer".to_owned(),
labels: BTreeMap::from([("team".to_owned(), "platform".to_owned())]),
},
spec: AgentSpec {
description: "Reviews pull requests".to_owned(),
enabled: true,
capabilities: vec![
"github.pull-request.read"
.parse()
.expect("valid capability fixture"),
],
providers: vec!["github".parse().expect("valid provider fixture")],
model_class: Some("reasoning".to_owned()),
policy_profile: Some("review-read-only".to_owned()),
},
status: Some(AgentStatus::Ready),
}
}
#[test]
fn agent_round_trips_through_json_and_yaml() {
let original = agent();
let json = serde_json::to_string(&original).expect("agent serializes as JSON");
let from_json = serde_json::from_str::<Agent>(&json).expect("agent parses as JSON");
assert_eq!(from_json, original);
let yaml = serde_yaml::to_string(&original).expect("agent serializes as YAML");
let from_yaml = serde_yaml::from_str::<Agent>(&yaml).expect("agent parses as YAML");
assert_eq!(from_yaml, original);
assert!(yaml.contains("apiVersion: dekopon.dev/v1alpha1"));
}
#[test]
fn rejects_unknown_authored_fields() {
let input = r#"
apiVersion: dekopon.dev/v1alpha1
kind: Capability
metadata:
name: github.pull-request.read
spec:
description: Reads pull requests
provider: github
effect: read-only
risk: Low
idempotency: idempotent
permisssions: []
"#;
let error = serde_yaml::from_str::<Capability>(input)
.expect_err("misspelled permissions must not be ignored");
assert!(error.to_string().contains("unknown field `permisssions`"));
}
#[test]
fn generates_json_schema() {
let schema = schema_for!(Agent);
let encoded = serde_json::to_value(schema).expect("schema serializes");
assert_eq!(encoded["title"], "Agent");
}
#[test]
fn capability_wire_values_are_explicit() {
let capability = Capability {
api_version: ApiVersion::V1Alpha1,
kind: Kind::Capability,
metadata: ObjectMeta::named("github.pull-request.read"),
spec: CapabilitySpec {
description: "Reads pull requests".to_owned(),
provider: "github".parse::<ProviderId>().expect("valid fixture"),
effect: EffectKind::ReadOnly,
risk: RiskLevel::Low,
idempotency: Idempotency::Idempotent,
permissions: Vec::new(),
},
status: None,
};
let value = serde_json::to_value(capability).expect("capability serializes");
assert_eq!(value["spec"]["effect"], "read-only");
assert_eq!(value["spec"]["risk"], "Low");
}
}