1use std::collections::HashMap;
21use std::sync::RwLock;
22
23use axum::extract::{Path, State};
24use axum::http::StatusCode;
25use axum::Json;
26use serde::Deserialize;
27use serde_json::json;
28use uuid::Uuid;
29
30use super::error::{ErrorType, UhpFailure};
31use super::harnesses::{Harness, McpServer, Skill};
32use super::state::UhpState;
33
34#[derive(Debug, Clone, Deserialize)]
36pub struct HarnessConfig {
37 pub name: String,
39 pub base: String,
41 #[serde(default, alias = "defaultModel")]
43 pub default_model: Option<String>,
44 #[serde(default)]
46 pub skills: Vec<Skill>,
47 #[serde(default, alias = "mcpServers")]
49 pub mcp_servers: Vec<McpServer>,
50 #[serde(default, alias = "disabledTools")]
52 pub disabled_tools: Vec<String>,
53}
54
55fn validated_skills(skills: &[Skill]) -> Result<(), UhpFailure> {
57 for skill in skills {
58 if !skill.has_manifest() {
59 return Err(UhpFailure::new(
60 ErrorType::InvalidRequestError,
61 "invalid_input",
62 format!(
63 "skill '{}' carries no {} — it would be stored and then silently ignored at \
64 run time",
65 skill.name,
66 Skill::MANIFEST
67 ),
68 ));
69 }
70 }
71 Ok(())
72}
73
74#[derive(Debug, Default)]
79pub struct ConfiguredHarnesses(RwLock<HashMap<String, Harness>>);
80
81impl ConfiguredHarnesses {
82 pub fn all(&self) -> Vec<Harness> {
84 self.0
85 .read()
86 .map(|h| h.values().cloned().collect())
87 .unwrap_or_default()
88 }
89
90 pub fn get(&self, id: &str) -> Option<Harness> {
92 self.0.read().ok()?.get(id).cloned()
93 }
94
95 pub fn put(&self, harness: Harness) {
97 if let Ok(mut all) = self.0.write() {
98 all.insert(harness.id.clone(), harness);
99 }
100 }
101
102 pub fn remove(&self, id: &str) -> bool {
104 self.0.write().is_ok_and(|mut all| all.remove(id).is_some())
105 }
106}
107
108async fn validated_base(state: &UhpState, base: &str) -> Result<(), UhpFailure> {
110 let supported: Vec<String> = super::harnesses::discover(&state.shared)
111 .await
112 .into_iter()
113 .map(|d| d.harness.base)
114 .collect();
115
116 if supported.iter().any(|b| b == base) {
117 return Ok(());
118 }
119 Err(UhpFailure::new(
120 ErrorType::InvalidRequestError,
121 "unsupported_base",
122 format!("this server has no installed harness for base '{base}'"),
123 )
124 .with_detail(json!({ "supported": supported })))
127}
128
129pub async fn create(
135 State(state): State<UhpState>,
136 Json(config): Json<HarnessConfig>,
137) -> Result<Json<Harness>, UhpFailure> {
138 validated_base(&state, &config.base).await?;
139 validated_skills(&config.skills)?;
140
141 let harness = Harness {
142 id: format!("chrn_{}", Uuid::new_v4().simple()),
143 object: "harness",
144 name: config.name,
145 base: config.base,
146 default_model: config.default_model,
147 skills: config.skills,
148 mcp_servers: config.mcp_servers,
149 disabled_tools: config.disabled_tools,
150 };
151 state.configured.put(harness.clone());
152 Ok(Json(harness))
153}
154
155pub async fn update(
165 State(state): State<UhpState>,
166 Path(id): Path<String>,
167 Json(config): Json<HarnessConfig>,
168) -> Result<Json<Harness>, UhpFailure> {
169 let existing = state.configured.get(&id).ok_or_else(|| {
170 UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
171 })?;
172 validated_base(&state, &config.base).await?;
173 validated_skills(&config.skills)?;
174
175 let updated = Harness {
178 id: existing.id,
179 object: "harness",
180 name: config.name,
181 base: existing.base,
182 default_model: config.default_model.or(existing.default_model),
183 skills: if config.skills.is_empty() {
184 existing.skills
185 } else {
186 config.skills
187 },
188 mcp_servers: if config.mcp_servers.is_empty() {
189 existing.mcp_servers
190 } else {
191 config.mcp_servers
192 },
193 disabled_tools: if config.disabled_tools.is_empty() {
194 existing.disabled_tools
195 } else {
196 config.disabled_tools
197 },
198 };
199 state.configured.put(updated.clone());
200 Ok(Json(updated))
201}
202
203pub async fn delete(
209 State(state): State<UhpState>,
210 Path(id): Path<String>,
211) -> Result<StatusCode, UhpFailure> {
212 if state.configured.remove(&id) {
213 Ok(StatusCode::NO_CONTENT)
214 } else {
215 Err(UhpFailure::not_found(
216 "harness_not_found",
217 "no harness with that id is configured",
218 ))
219 }
220}
221
222#[derive(Debug, Clone, serde::Serialize)]
224pub struct SkillFiles {
225 pub files: Vec<super::harnesses::SkillFile>,
227}
228
229pub async fn skill_files(
238 State(state): State<UhpState>,
239 Path((harness_id, skill_name)): Path<(String, String)>,
240) -> Result<Json<SkillFiles>, UhpFailure> {
241 let harness = state.configured.get(&harness_id).ok_or_else(|| {
242 UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
243 })?;
244 let skill = harness
245 .skills
246 .into_iter()
247 .find(|s| s.name == skill_name)
248 .ok_or_else(|| {
249 UhpFailure::not_found(
250 "skill_not_found",
251 "this harness carries no skill by that name",
252 )
253 })?;
254 Ok(Json(SkillFiles { files: skill.files }))
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn harness(id: &str, base: &str) -> Harness {
262 Harness {
263 id: id.to_owned(),
264 object: "harness",
265 name: "n".to_owned(),
266 base: base.to_owned(),
267 default_model: None,
268 skills: Vec::new(),
269 mcp_servers: Vec::new(),
270 disabled_tools: Vec::new(),
271 }
272 }
273
274 #[test]
275 fn a_binary_member_round_trips_by_its_base64() {
276 use super::super::harnesses::SkillFile;
277
278 let file = SkillFile {
282 path: "assets/blob.bin".to_owned(),
283 content: None,
284 content_b64: Some("AAECAwQF".to_owned()),
285 rest: serde_json::Map::new(),
286 };
287 let json = serde_json::to_string(&file).expect("serialises"); assert!(
289 json.contains("AAECAwQF"),
290 "the binary member's bytes must survive the round trip: {json}"
291 );
292 assert!(
293 !json.contains("\"content\":"),
294 "a member with no text content must not gain an empty one"
295 );
296 }
297
298 #[test]
299 fn a_skill_bundle_without_a_manifest_is_refused() {
300 use super::super::harnesses::SkillFile;
301
302 let ok = Skill {
303 name: "good".to_owned(),
304 enabled: true,
305 files: vec![SkillFile {
306 path: "SKILL.md".to_owned(),
307 content: Some("# skill".to_owned()),
308 content_b64: None,
309 rest: serde_json::Map::new(),
310 }],
311 };
312 assert!(validated_skills(&[ok]).is_ok());
313
314 let bad = Skill {
315 name: "no-manifest".to_owned(),
316 enabled: true,
317 files: vec![SkillFile {
318 path: "notes.md".to_owned(),
319 content: Some("no manifest here".to_owned()),
320 content_b64: None,
321 rest: serde_json::Map::new(),
322 }],
323 };
324 assert!(
325 validated_skills(&[bad]).is_err(),
326 "a bundle with no SKILL.md must be refused at config time, not ignored at run time"
327 );
328 }
329
330 #[test]
331 fn a_configured_harness_round_trips_and_then_is_gone() {
332 let store = ConfiguredHarnesses::default();
333 store.put(harness("chrn_a", "claude-code"));
334
335 assert_eq!(
336 store.get("chrn_a").map(|h| h.base),
337 Some("claude-code".to_owned())
338 );
339 assert_eq!(store.all().len(), 1);
340
341 assert!(
342 store.remove("chrn_a"),
343 "removing a present harness reports true"
344 );
345 assert!(
346 store.get("chrn_a").is_none(),
347 "a deleted harness must not still resolve"
348 );
349 assert!(
350 !store.remove("chrn_a"),
351 "removing it twice reports false, so the caller can answer 404"
352 );
353 }
354
355 #[test]
356 fn a_created_id_satisfies_the_schema_pattern() {
357 let id = format!("chrn_{}", Uuid::new_v4().simple());
358 assert!(id.starts_with("chrn_"));
359 assert!(id.len() > "chrn_".len());
360 }
361}