1#![forbid(unsafe_code)]
12
13use std::{collections::BTreeMap, fmt};
14
15use dekopon_capability::{EffectKind, Idempotency, Permission};
16pub use dekopon_core::AgentStatus;
17use dekopon_core::{CapabilityId, ProviderId, RiskLevel};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21#[derive(
23 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
24)]
25pub enum ApiVersion {
26 #[serde(rename = "dekopon.dev/v1alpha1")]
28 V1Alpha1,
29}
30
31impl fmt::Display for ApiVersion {
32 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33 match self {
34 Self::V1Alpha1 => formatter.write_str("dekopon.dev/v1alpha1"),
35 }
36 }
37}
38
39#[derive(
41 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
42)]
43#[serde(rename_all = "PascalCase")]
44pub enum Kind {
45 Agent,
47 Capability,
49 Provider,
51 AgentList,
53 CapabilityList,
55 ProviderList,
57}
58
59impl fmt::Display for Kind {
60 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61 write!(formatter, "{self:?}")
62 }
63}
64
65#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
67#[serde(deny_unknown_fields, rename_all = "camelCase")]
68pub struct ObjectMeta {
69 pub name: String,
71 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73 pub labels: BTreeMap<String, String>,
74}
75
76impl ObjectMeta {
77 #[must_use]
79 pub fn named(name: impl Into<String>) -> Self {
80 Self {
81 name: name.into(),
82 labels: BTreeMap::new(),
83 }
84 }
85}
86
87#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
89#[serde(deny_unknown_fields, rename_all = "camelCase")]
90pub struct AgentSpec {
91 pub description: String,
93 #[serde(default = "default_enabled")]
95 pub enabled: bool,
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub capabilities: Vec<CapabilityId>,
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub providers: Vec<ProviderId>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub model_class: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub policy_profile: Option<String>,
108}
109
110const fn default_enabled() -> bool {
111 true
112}
113
114#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
116#[serde(deny_unknown_fields, rename_all = "camelCase")]
117pub struct Agent {
118 pub api_version: ApiVersion,
120 pub kind: Kind,
122 pub metadata: ObjectMeta,
124 pub spec: AgentSpec,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub status: Option<AgentStatus>,
129}
130
131#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
133#[serde(deny_unknown_fields, rename_all = "camelCase")]
134pub struct CapabilitySpec {
135 pub description: String,
137 pub provider: ProviderId,
139 pub effect: EffectKind,
141 pub risk: RiskLevel,
143 pub idempotency: Idempotency,
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub permissions: Vec<Permission>,
148}
149
150#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
152#[serde(rename_all = "PascalCase")]
153pub enum CapabilityStatus {
154 Available,
156 Unavailable,
158 Unknown,
160}
161
162impl fmt::Display for CapabilityStatus {
163 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164 write!(formatter, "{self:?}")
165 }
166}
167
168#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
170#[serde(deny_unknown_fields, rename_all = "camelCase")]
171pub struct Capability {
172 pub api_version: ApiVersion,
174 pub kind: Kind,
176 pub metadata: ObjectMeta,
178 pub spec: CapabilitySpec,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub status: Option<CapabilityStatus>,
183}
184
185#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
187#[serde(deny_unknown_fields, rename_all = "camelCase")]
188pub struct ProviderSpec {
189 pub description: String,
191 #[serde(rename = "type")]
193 pub provider_type: String,
194 pub credential_ref: String,
196}
197
198#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
200#[serde(rename_all = "PascalCase")]
201pub enum ProviderStatus {
202 Ready,
204 Unavailable,
206 Unknown,
208}
209
210impl fmt::Display for ProviderStatus {
211 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
212 write!(formatter, "{self:?}")
213 }
214}
215
216#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
218#[serde(deny_unknown_fields, rename_all = "camelCase")]
219pub struct Provider {
220 pub api_version: ApiVersion,
222 pub kind: Kind,
224 pub metadata: ObjectMeta,
226 pub spec: ProviderSpec,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub status: Option<ProviderStatus>,
231}
232
233#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
235#[serde(deny_unknown_fields, rename_all = "camelCase")]
236pub struct AgentList {
237 pub api_version: ApiVersion,
239 pub kind: Kind,
241 pub items: Vec<Agent>,
243}
244
245impl AgentList {
246 #[must_use]
248 pub const fn new(items: Vec<Agent>) -> Self {
249 Self {
250 api_version: ApiVersion::V1Alpha1,
251 kind: Kind::AgentList,
252 items,
253 }
254 }
255}
256
257#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
259#[serde(deny_unknown_fields, rename_all = "camelCase")]
260pub struct CapabilityList {
261 pub api_version: ApiVersion,
263 pub kind: Kind,
265 pub items: Vec<Capability>,
267}
268
269impl CapabilityList {
270 #[must_use]
272 pub const fn new(items: Vec<Capability>) -> Self {
273 Self {
274 api_version: ApiVersion::V1Alpha1,
275 kind: Kind::CapabilityList,
276 items,
277 }
278 }
279}
280
281#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
283#[serde(deny_unknown_fields, rename_all = "camelCase")]
284pub struct ProviderList {
285 pub api_version: ApiVersion,
287 pub kind: Kind,
289 pub items: Vec<Provider>,
291}
292
293impl ProviderList {
294 #[must_use]
296 pub const fn new(items: Vec<Provider>) -> Self {
297 Self {
298 api_version: ApiVersion::V1Alpha1,
299 kind: Kind::ProviderList,
300 items,
301 }
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use std::collections::BTreeMap;
308
309 use dekopon_capability::{EffectKind, Idempotency};
310 use dekopon_core::{AgentStatus, ProviderId, RiskLevel};
311 use schemars::schema_for;
312
313 use super::{Agent, AgentSpec, ApiVersion, Capability, CapabilitySpec, Kind, ObjectMeta};
314
315 fn agent() -> Agent {
316 Agent {
317 api_version: ApiVersion::V1Alpha1,
318 kind: Kind::Agent,
319 metadata: ObjectMeta {
320 name: "reviewer".to_owned(),
321 labels: BTreeMap::from([("team".to_owned(), "platform".to_owned())]),
322 },
323 spec: AgentSpec {
324 description: "Reviews pull requests".to_owned(),
325 enabled: true,
326 capabilities: vec![
327 "github.pull-request.read"
328 .parse()
329 .expect("valid capability fixture"),
330 ],
331 providers: vec!["github".parse().expect("valid provider fixture")],
332 model_class: Some("reasoning".to_owned()),
333 policy_profile: Some("review-read-only".to_owned()),
334 },
335 status: Some(AgentStatus::Ready),
336 }
337 }
338
339 #[test]
340 fn agent_round_trips_through_json_and_yaml() {
341 let original = agent();
342
343 let json = serde_json::to_string(&original).expect("agent serializes as JSON");
344 let from_json = serde_json::from_str::<Agent>(&json).expect("agent parses as JSON");
345 assert_eq!(from_json, original);
346
347 let yaml = serde_yaml::to_string(&original).expect("agent serializes as YAML");
348 let from_yaml = serde_yaml::from_str::<Agent>(&yaml).expect("agent parses as YAML");
349 assert_eq!(from_yaml, original);
350 assert!(yaml.contains("apiVersion: dekopon.dev/v1alpha1"));
351 }
352
353 #[test]
354 fn rejects_unknown_authored_fields() {
355 let input = r#"
356apiVersion: dekopon.dev/v1alpha1
357kind: Capability
358metadata:
359 name: github.pull-request.read
360spec:
361 description: Reads pull requests
362 provider: github
363 effect: read-only
364 risk: Low
365 idempotency: idempotent
366 permisssions: []
367"#;
368 let error = serde_yaml::from_str::<Capability>(input)
369 .expect_err("misspelled permissions must not be ignored");
370 assert!(error.to_string().contains("unknown field `permisssions`"));
371 }
372
373 #[test]
374 fn generates_json_schema() {
375 let schema = schema_for!(Agent);
376 let encoded = serde_json::to_value(schema).expect("schema serializes");
377 assert_eq!(encoded["title"], "Agent");
378 }
379
380 #[test]
381 fn capability_wire_values_are_explicit() {
382 let capability = Capability {
383 api_version: ApiVersion::V1Alpha1,
384 kind: Kind::Capability,
385 metadata: ObjectMeta::named("github.pull-request.read"),
386 spec: CapabilitySpec {
387 description: "Reads pull requests".to_owned(),
388 provider: "github".parse::<ProviderId>().expect("valid fixture"),
389 effect: EffectKind::ReadOnly,
390 risk: RiskLevel::Low,
391 idempotency: Idempotency::Idempotent,
392 permissions: Vec::new(),
393 },
394 status: None,
395 };
396 let value = serde_json::to_value(capability).expect("capability serializes");
397
398 assert_eq!(value["spec"]["effect"], "read-only");
399 assert_eq!(value["spec"]["risk"], "Low");
400 }
401}