1use std::{collections::BTreeSet, fmt};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
7
8pub const SERVICE_NAME: &str = "agent-registry";
9pub const AUDIENCE: &str = "agent-registry";
10pub const DEFAULT_PORT: u16 = 5100;
11pub const HEALTH_PATH: &str = "/health";
12pub const AGENTS_PATH: &str = "/internal/v1/agents";
13pub const AGENT_PATH: &str = "/internal/v1/agents/{agent_id}";
14pub const PUBLIC_AGENTS_PATH: &str = "/v1/agents";
15pub const PUBLIC_AGENT_PATH: &str = "/v1/agents/{agent_id}";
16
17const MAX_IDENTIFIER_BYTES: usize = 255;
18const MAX_RELATIONS_PER_KIND: usize = 256;
19
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
21#[serde(transparent)]
22pub struct AgentId(String);
23
24impl AgentId {
25 pub fn new(value: impl Into<String>) -> Result<Self, String> {
26 let value = value.into();
27 if !value.starts_with("agt_") {
28 return Err("agent_id must start with 'agt_'".into());
29 }
30 validate_identifier("agent_id", &value)?;
31 Ok(Self(value))
32 }
33
34 pub fn as_str(&self) -> &str {
35 &self.0
36 }
37}
38
39impl<'de> Deserialize<'de> for AgentId {
40 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
41 where
42 D: Deserializer<'de>,
43 {
44 let value = String::deserialize(deserializer)?;
45 Self::new(value).map_err(D::Error::custom)
46 }
47}
48
49impl fmt::Display for AgentId {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 self.0.fmt(formatter)
52 }
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase", deny_unknown_fields)]
61pub struct ResourceRelations {
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub agent_ids: Vec<AgentId>,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub runtime_ids: Vec<String>,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub user_ids: Vec<String>,
68}
69
70impl ResourceRelations {
71 pub fn normalize(mut self) -> Result<Self, String> {
72 normalize_values("agent_ids", &mut self.agent_ids)?;
73 normalize_strings("runtime_ids", &mut self.runtime_ids)?;
74 normalize_strings("user_ids", &mut self.user_ids)?;
75 Ok(self)
76 }
77
78 pub fn contains_user(&self, user_id: &str) -> bool {
79 self.user_ids.iter().any(|candidate| candidate == user_id)
80 }
81}
82
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum AgentStatus {
86 #[default]
87 Active,
88 Archived,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase", deny_unknown_fields)]
93pub struct Agent {
94 pub id: AgentId,
95 pub owner_user_id: String,
96 pub name: String,
97 pub description: String,
98 pub status: AgentStatus,
99 pub revision: u64,
100 #[serde(default)]
101 pub relations: ResourceRelations,
102 pub created_at: DateTime<Utc>,
103 pub updated_at: DateTime<Utc>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase", deny_unknown_fields)]
108pub struct CreateAgentRequest {
109 pub name: String,
110 #[serde(default)]
111 pub description: String,
112 #[serde(default)]
113 pub relations: ResourceRelations,
114 pub idempotency_key: String,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase", deny_unknown_fields)]
119pub struct UpdateAgentRequest {
120 pub expected_revision: u64,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub name: Option<String>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub description: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub status: Option<AgentStatus>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub relations: Option<ResourceRelations>,
129}
130
131#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct ListAgentsRequest {
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub status: Option<AgentStatus>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub related_agent_id: Option<AgentId>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub related_runtime_id: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub related_user_id: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub cursor: Option<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub limit: Option<u32>,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase", deny_unknown_fields)]
150pub struct AgentPage {
151 pub items: Vec<Agent>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub next_cursor: Option<String>,
154}
155
156pub fn validate_name(value: &str) -> Result<String, String> {
157 let value = value.trim();
158 if value.is_empty() || value.chars().count() > 128 {
159 return Err("agent name must contain 1..=128 characters".into());
160 }
161 if value.chars().any(char::is_control) {
162 return Err("agent name must not contain control characters".into());
163 }
164 Ok(value.to_string())
165}
166
167pub fn validate_description(value: &str) -> Result<String, String> {
168 let value = value.trim();
169 if value.chars().count() > 4096 {
170 return Err("agent description must contain at most 4096 characters".into());
171 }
172 if value.chars().any(|character| character == '\0') {
173 return Err("agent description must not contain NUL".into());
174 }
175 Ok(value.to_string())
176}
177
178pub fn validate_identifier(field: &str, value: &str) -> Result<(), String> {
179 if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES {
180 return Err(format!(
181 "{field} must contain 1..={MAX_IDENTIFIER_BYTES} bytes"
182 ));
183 }
184 if !value.bytes().all(|byte| {
185 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'@')
186 }) {
187 return Err(format!("{field} contains unsupported characters"));
188 }
189 Ok(())
190}
191
192fn normalize_strings(field: &str, values: &mut Vec<String>) -> Result<(), String> {
193 if values.len() > MAX_RELATIONS_PER_KIND {
194 return Err(format!("{field} contains too many relations"));
195 }
196 for value in values.iter_mut() {
197 *value = value.trim().to_string();
198 validate_identifier(field, value)?;
199 }
200 values.sort();
201 values.dedup();
202 Ok(())
203}
204
205fn normalize_values(field: &str, values: &mut Vec<AgentId>) -> Result<(), String> {
206 if values.len() > MAX_RELATIONS_PER_KIND {
207 return Err(format!("{field} contains too many relations"));
208 }
209 let unique = values.iter().cloned().collect::<BTreeSet<_>>();
210 *values = unique.into_iter().collect();
211 Ok(())
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn relations_are_sets_not_singular_scope_fields() {
220 let relations = ResourceRelations {
221 agent_ids: vec![
222 AgentId::new("agt_b").unwrap(),
223 AgentId::new("agt_a").unwrap(),
224 ],
225 runtime_ids: vec!["runtime-1".into(), "runtime-1".into()],
226 user_ids: vec!["user-2".into(), "user-1".into()],
227 }
228 .normalize()
229 .unwrap();
230 assert_eq!(relations.agent_ids[0].as_str(), "agt_a");
231 assert_eq!(relations.runtime_ids, ["runtime-1"]);
232 assert_eq!(relations.user_ids, ["user-1", "user-2"]);
233 }
234
235 #[test]
236 fn agent_id_validation_cannot_be_bypassed_by_json() {
237 let error = serde_json::from_str::<AgentId>(r#""agent-legacy""#).unwrap_err();
238 assert!(error.to_string().contains("must start with 'agt_'"));
239 }
240}