Skip to main content

embacle_server/uhp/
manage.rs

1// ABOUTME: Harness management — create, update and delete configured harnesses over the API
2// ABOUTME: A base this server cannot run is refused at config time, not at task time
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Configured harnesses.
8//!
9//! The catalogue this server advertises is discovered: one harness per embacle
10//! runner whose binary is installed. Management adds a second kind — a *named
11//! configuration* over one of those bases, so a client can keep "the reviewer"
12//! and "the summariser" as distinct harnesses that happen to run on the same
13//! CLI.
14//!
15//! A base this server cannot run is refused when the harness is **created**,
16//! not when a task is finally sent to it. The specification is explicit about
17//! why: a server that accepts a base it cannot run fails at task time instead,
18//! after the client has committed.
19
20use 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/// What a client sends to create or update a harness.
35#[derive(Debug, Clone, Deserialize)]
36pub struct HarnessConfig {
37    /// Human-readable name.
38    pub name: String,
39    /// Which installed harness family it runs on.
40    pub base: String,
41    /// The model to use when a task names none.
42    #[serde(default, alias = "defaultModel")]
43    pub default_model: Option<String>,
44    /// Skill bundles to attach.
45    #[serde(default)]
46    pub skills: Vec<Skill>,
47    /// MCP servers this harness may reach.
48    #[serde(default, alias = "mcpServers")]
49    pub mcp_servers: Vec<McpServer>,
50    /// Tools withheld from the model.
51    #[serde(default, alias = "disabledTools")]
52    pub disabled_tools: Vec<String>,
53}
54
55/// Refuse a skill bundle that carries no manifest.
56fn 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/// Harnesses a client configured, as opposed to the ones discovery found.
75///
76/// LIMITATION(registre#410): `ConfiguredHarnesses` is per-process, so a harness created
77/// on one instance is invisible to another.
78#[derive(Debug, Default)]
79pub struct ConfiguredHarnesses(RwLock<HashMap<String, Harness>>);
80
81impl ConfiguredHarnesses {
82    /// Every configured harness.
83    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    /// One configured harness by id.
91    pub fn get(&self, id: &str) -> Option<Harness> {
92        self.0.read().ok()?.get(id).cloned()
93    }
94
95    /// Store or replace one.
96    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    /// Remove one, reporting whether it was there.
103    pub fn remove(&self, id: &str) -> bool {
104        self.0.write().is_ok_and(|mut all| all.remove(id).is_some())
105    }
106}
107
108/// Refuse a base this server has no installed harness for.
109async 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    // The supported list travels with the refusal so a client can correct
125    // itself without a second round trip to discover what it may ask for.
126    .with_detail(json!({ "supported": supported })))
127}
128
129/// Handle `POST /v1/harnesses`.
130///
131/// # Errors
132///
133/// Answers `400 unsupported_base` when no installed harness runs that base.
134pub 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
155/// Handle `PUT /v1/harnesses/{harness_id}`.
156///
157/// The base is immutable: changing it would silently repoint every session
158/// already running on this harness at a different CLI.
159///
160/// # Errors
161///
162/// Answers `404 harness_not_found` for an unknown id and `400 unsupported_base`
163/// when the body names a base this server cannot run.
164pub 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    // An edit that names no skills keeps the ones already there. A client that
176    // PUTs back a rename must not lose the bundle it never mentioned.
177    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
203/// Handle `DELETE /v1/harnesses/{harness_id}`.
204///
205/// # Errors
206///
207/// Answers `404 harness_not_found` when no configured harness has that id.
208pub 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/// The files of one skill bundle.
223#[derive(Debug, Clone, serde::Serialize)]
224pub struct SkillFiles {
225    /// Every file in the bundle, as stored.
226    pub files: Vec<super::harnesses::SkillFile>,
227}
228
229/// Handle `GET /v1/harnesses/{harness_id}/skills/{skill_name}/files`.
230///
231/// The whole folder comes back, not just the manifest.
232///
233/// # Errors
234///
235/// Answers `404 harness_not_found` for an unknown harness and
236/// `404 skill_not_found` when it carries no bundle by that name.
237pub 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        // The conformance bundle carries assets/blob.bin as content_b64, not
279        // content. A type that models only text drops it silently, and the
280        // folder comes back missing a member the client set.
281        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"); // Safe: test assertion
288        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}