huskies: merge 567_story_gateway_ui_project_management_add_and_remove_projects

This commit is contained in:
dave
2026-04-15 18:02:47 +00:00
parent d235fd41ac
commit beb84ade9f
3 changed files with 352 additions and 27 deletions
+168 -27
View File
@@ -97,8 +97,8 @@ struct AssignAgentRequest {
/// Shared gateway state threaded through HTTP handlers.
#[derive(Clone)]
pub struct GatewayState {
/// The parsed gateway config with all registered projects.
pub config: GatewayConfig,
/// The live set of registered projects (initially loaded from `projects.toml`).
pub projects: Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
/// The currently active project name.
pub active_project: Arc<RwLock<String>>,
/// HTTP client for proxying requests to project containers.
@@ -126,6 +126,21 @@ fn load_agents(config_dir: &Path) -> Vec<JoinedAgent> {
}
}
/// Persist the current projects map to `<config_dir>/projects.toml`.
/// Silently ignores write errors or skips when `config_dir` is empty.
async fn save_config(projects: &BTreeMap<String, ProjectEntry>, config_dir: &Path) {
if config_dir.as_os_str().is_empty() {
return;
}
let path = config_dir.join("projects.toml");
let config = GatewayConfig {
projects: projects.clone(),
};
if let Ok(data) = toml::to_string_pretty(&config) {
let _ = tokio::fs::write(&path, data).await;
}
}
/// Persist the current agent list to `<config_dir>/gateway_agents.json`.
/// Silently ignores write errors (e.g. read-only filesystem or empty path).
async fn save_agents(agents: &[JoinedAgent], config_dir: &Path) {
@@ -151,7 +166,7 @@ impl GatewayState {
let first = config.projects.keys().next().unwrap().clone();
let agents = load_agents(&config_dir);
Ok(Self {
config,
projects: Arc::new(RwLock::new(config.projects)),
active_project: Arc::new(RwLock::new(first)),
client: Client::new(),
joined_agents: Arc::new(RwLock::new(agents)),
@@ -165,8 +180,9 @@ impl GatewayState {
/// Get the URL of the currently active project.
async fn active_url(&self) -> Result<String, String> {
let name = self.active_project.read().await.clone();
self.config
.projects
self.projects
.read()
.await
.get(&name)
.map(|p| p.url.clone())
.ok_or_else(|| format!("active project '{name}' not found in config"))
@@ -485,27 +501,30 @@ async fn handle_switch_project(params: &Value, state: &GatewayState) -> JsonRpcR
return JsonRpcResponse::error(None, -32602, "missing required parameter: project".into());
}
if !state.config.projects.contains_key(project) {
let available: Vec<&str> = state.config.projects.keys().map(|s| s.as_str()).collect();
return JsonRpcResponse::error(
None,
-32602,
format!(
"unknown project '{project}'. Available: {}",
available.join(", ")
),
);
}
let url = {
let projects = state.projects.read().await;
if !projects.contains_key(project) {
let available: Vec<&str> = projects.keys().map(|s| s.as_str()).collect();
return JsonRpcResponse::error(
None,
-32602,
format!(
"unknown project '{project}'. Available: {}",
available.join(", ")
),
);
}
projects[project].url.clone()
};
*state.active_project.write().await = project.to_string();
let url = &state.config.projects[project].url;
JsonRpcResponse::success(
None,
json!({
"content": [{
"type": "text",
"text": format!("Switched to project '{project}' ({})", url)
"text": format!("Switched to project '{project}' ({url})")
}]
}),
)
@@ -562,8 +581,15 @@ async fn handle_gateway_status(state: &GatewayState) -> JsonRpcResponse {
async fn handle_gateway_health(state: &GatewayState) -> JsonRpcResponse {
let mut results = BTreeMap::new();
for (name, entry) in &state.config.projects {
let health_url = format!("{}/health", entry.url.trim_end_matches('/'));
let project_entries: Vec<(String, String)> = state
.projects
.read()
.await
.iter()
.map(|(n, e)| (n.clone(), e.url.clone()))
.collect();
for (name, url) in &project_entries {
let health_url = format!("{}/health", url.trim_end_matches('/'));
let status = match state.client.get(&health_url).send().await {
Ok(resp) => {
if resp.status().is_success() {
@@ -749,7 +775,7 @@ pub async fn gateway_assign_agent_handler(
.and_then(|p| if p.is_empty() { None } else { Some(p) });
if let Some(ref p) = project
&& !state.config.projects.contains_key(p.as_str())
&& !state.projects.read().await.contains_key(p.as_str())
{
return Response::builder()
.status(StatusCode::BAD_REQUEST)
@@ -797,8 +823,15 @@ pub async fn gateway_health_handler(state: Data<&Arc<GatewayState>>) -> Response
let mut all_healthy = true;
let mut statuses = BTreeMap::new();
for (name, entry) in &state.config.projects {
let health_url = format!("{}/health", entry.url.trim_end_matches('/'));
let project_entries: Vec<(String, String)> = state
.projects
.read()
.await
.iter()
.map(|(n, e)| (n.clone(), e.url.clone()))
.collect();
for (name, url) in &project_entries {
let health_url = format!("{}/health", url.trim_end_matches('/'));
let healthy = match state.client.get(&health_url).send().await {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
@@ -1000,8 +1033,9 @@ pub async fn gateway_index_handler() -> Response {
pub async fn gateway_api_handler(state: Data<&Arc<GatewayState>>) -> Response {
let active = state.active_project.read().await.clone();
let projects: Vec<Value> = state
.config
.projects
.read()
.await
.iter()
.map(|(name, entry)| {
json!({
@@ -1065,6 +1099,104 @@ pub async fn gateway_switch_handler(
))
}
// ── Project management API ───────────────────────────────────────────
/// Request body for adding a new project.
#[derive(Deserialize)]
struct AddProjectRequest {
name: String,
url: String,
}
/// `POST /api/gateway/projects` — add a new project to the gateway config.
///
/// Expects JSON `{ "name": "...", "url": "..." }`. Returns the created project
/// or 409 Conflict if a project with the same name already exists.
#[handler]
pub async fn gateway_add_project_handler(
state: Data<&Arc<GatewayState>>,
body: Json<AddProjectRequest>,
) -> Response {
let name = body.0.name.trim().to_string();
let url = body.0.url.trim().to_string();
if name.is_empty() {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from("project name must not be empty"));
}
if url.is_empty() {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from("project url must not be empty"));
}
{
let mut projects = state.projects.write().await;
if projects.contains_key(&name) {
return Response::builder()
.status(StatusCode::CONFLICT)
.body(Body::from(format!("project '{name}' already exists")));
}
projects.insert(name.clone(), ProjectEntry { url: url.clone() });
}
let snapshot = state.projects.read().await.clone();
save_config(&snapshot, &state.config_dir).await;
crate::slog!("[gateway] Added project '{name}' ({url})");
let body_val = json!({ "name": name, "url": url });
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(
serde_json::to_vec(&body_val).unwrap_or_default(),
))
}
/// `DELETE /api/gateway/projects/:name` — remove a project from the gateway config.
///
/// Returns 204 No Content on success. Returns 400 if this is the last project
/// (the gateway requires at least one project to remain configured).
#[handler]
pub async fn gateway_remove_project_handler(
PoemPath(name): PoemPath<String>,
state: Data<&Arc<GatewayState>>,
) -> Response {
let active = state.active_project.read().await.clone();
{
let mut projects = state.projects.write().await;
if !projects.contains_key(&name) {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from(format!("project '{name}' not found")));
}
if projects.len() == 1 {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from("cannot remove the last project"));
}
projects.remove(&name);
}
let snapshot = state.projects.read().await.clone();
save_config(&snapshot, &state.config_dir).await;
// If the removed project was active, switch to the first remaining.
if active == name {
let first = state.projects.read().await.keys().next().cloned();
if let Some(new_active) = first {
*state.active_project.write().await = new_active;
}
}
crate::slog!("[gateway] Removed project '{name}'");
Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
}
// ── Bot configuration API ────────────────────────────────────────────
/// Request/response body for the bot configuration API.
@@ -1173,7 +1305,7 @@ pub async fn gateway_bot_config_save_handler(
if let Some(h) = handle.take() {
h.abort();
}
let gateway_projects: Vec<String> = state.config.projects.keys().cloned().collect();
let gateway_projects: Vec<String> = state.projects.read().await.keys().cloned().collect();
let new_handle = spawn_gateway_bot(
&state.config_dir,
Arc::clone(&state.active_project),
@@ -1422,8 +1554,9 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
crate::slog!(
"[gateway] Registered projects: {}",
state_arc
.config
.projects
.read()
.await
.keys()
.cloned()
.collect::<Vec<_>>()
@@ -1437,7 +1570,7 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
}
// Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory.
let gateway_projects: Vec<String> = state_arc.config.projects.keys().cloned().collect();
let gateway_projects: Vec<String> = state_arc.projects.read().await.keys().cloned().collect();
let bot_abort = spawn_gateway_bot(
&config_dir,
Arc::clone(&state_arc.active_project),
@@ -1451,6 +1584,14 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
.at("/bot-config", poem::get(gateway_bot_config_page_handler))
.at("/api/gateway", poem::get(gateway_api_handler))
.at("/api/gateway/switch", poem::post(gateway_switch_handler))
.at(
"/api/gateway/projects",
poem::post(gateway_add_project_handler),
)
.at(
"/api/gateway/projects/:name",
poem::delete(gateway_remove_project_handler),
)
.at(
"/api/gateway/bot-config",
poem::get(gateway_bot_config_get_handler).post(gateway_bot_config_save_handler),