From cf470f50489af1e7885fecaf05f856bc23b71239 Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 28 Apr 2026 13:55:40 +0000 Subject: [PATCH] huskies: merge 776 --- frontend/src/api/gateway.ts | 16 --- frontend/src/components/GatewayPanel.tsx | 126 +---------------------- server/src/crdt_state/lww_maps.rs | 93 ++++++++++++++++- server/src/crdt_state/mod.rs | 15 +-- server/src/crdt_state/state.rs | 21 ++++ server/src/crdt_state/types.rs | 18 ++++ 6 files changed, 138 insertions(+), 151 deletions(-) diff --git a/frontend/src/api/gateway.ts b/frontend/src/api/gateway.ts index c114a0a2..ac68bc99 100644 --- a/frontend/src/api/gateway.ts +++ b/frontend/src/api/gateway.ts @@ -146,22 +146,6 @@ export const gatewayApi = { return gatewayRequest("/api/gateway"); }, - /// Add a new project to the gateway config. - addProject(name: string, url: string): Promise { - return gatewayRequest("/api/gateway/projects", { - method: "POST", - body: JSON.stringify({ name, url }), - }); - }, - - /// Remove a project from the gateway config. - removeProject(name: string): Promise { - return gatewayRequest( - `/api/gateway/projects/${encodeURIComponent(name)}`, - { method: "DELETE" }, - ); - }, - /// Send a heartbeat for an agent to update its last-seen timestamp. heartbeat(id: string): Promise { return gatewayRequest(`/gateway/agents/${id}/heartbeat`, { diff --git a/frontend/src/components/GatewayPanel.tsx b/frontend/src/components/GatewayPanel.tsx index 11bab076..ae92cc35 100644 --- a/frontend/src/components/GatewayPanel.tsx +++ b/frontend/src/components/GatewayPanel.tsx @@ -368,11 +368,6 @@ export function GatewayPanel() { const [error, setError] = useState(null); const [pipeline, setPipeline] = useState(null); - // Add-project form state - const [newProjectName, setNewProjectName] = useState(""); - const [newProjectUrl, setNewProjectUrl] = useState(""); - const [addingProject, setAddingProject] = useState(false); - // Keep stable refs so polling intervals don't recreate on state changes. const setAgentsRef = useRef(setAgents); setAgentsRef.current = setAgents; @@ -447,24 +442,6 @@ export function GatewayPanel() { [], ); - const handleAddProject = useCallback(async () => { - const name = newProjectName.trim(); - const url = newProjectUrl.trim(); - if (!name || !url) return; - setAddingProject(true); - setError(null); - try { - const created = await gatewayApi.addProject(name, url); - setProjects((prev) => [...prev, created]); - setNewProjectName(""); - setNewProjectUrl(""); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setAddingProject(false); - } - }, [newProjectName, newProjectUrl]); - const handleSwitchProject = useCallback(async (name: string) => { setError(null); try { @@ -481,18 +458,6 @@ export function GatewayPanel() { } }, []); - const handleRemoveProject = useCallback(async (name: string) => { - if (!window.confirm(`Remove project "${name}"? This cannot be undone.`)) { - return; - } - setError(null); - try { - await gatewayApi.removeProject(name); - setProjects((prev) => prev.filter((p) => p.name !== name)); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - }, []); return (
{p.name}
{p.url}
- - + ))} - - {/* Add project form */} -
-
-
- Name -
- setNewProjectName(e.target.value)} - style={{ - width: "100%", - padding: "6px 10px", - borderRadius: "4px", - border: "1px solid #30363d", - background: "#0d1117", - color: "#e6edf3", - fontSize: "0.85em", - }} - /> -
-
-
- Container URL -
- setNewProjectUrl(e.target.value)} - style={{ - width: "100%", - padding: "6px 10px", - borderRadius: "4px", - border: "1px solid #30363d", - background: "#0d1117", - color: "#e6edf3", - fontSize: "0.85em", - }} - /> -
- -
{error && ( diff --git a/server/src/crdt_state/lww_maps.rs b/server/src/crdt_state/lww_maps.rs index 14198ab9..474cdd50 100644 --- a/server/src/crdt_state/lww_maps.rs +++ b/server/src/crdt_state/lww_maps.rs @@ -35,11 +35,13 @@ fn list_id_at(list: &ListCrdt, idx: usize) -> Option { use super::state::{ apply_and_persist, get_crdt, rebuild_active_agent_index, rebuild_agent_throttle_index, - rebuild_merge_job_index, rebuild_test_job_index, rebuild_token_index, + rebuild_gateway_project_index, rebuild_merge_job_index, rebuild_test_job_index, + rebuild_token_index, }; use super::types::{ - ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, MergeJobCrdt, - MergeJobView, TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, + ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, GatewayProjectCrdt, + GatewayProjectView, MergeJobCrdt, MergeJobView, TestJobCrdt, TestJobView, TokenUsageCrdt, + TokenUsageView, }; // ── tokens ─────────────────────────────────────────────────────────── @@ -605,6 +607,91 @@ fn extract_agent_throttle_view(entry: &AgentThrottleCrdt) -> Option Option> { + let state_mutex = get_crdt()?; + let state = state_mutex.lock().ok()?; + let mut out = Vec::new(); + for entry in state.crdt.doc.gateway_projects.iter() { + if let Some(v) = extract_gateway_project_view(entry) { + out.push(v); + } + } + Some(out) +} + +/// Read a single gateway-project entry by `name`. +pub fn read_gateway_project(name: &str) -> Option { + let state_mutex = get_crdt()?; + let state = state_mutex.lock().ok()?; + let &idx = state.gateway_project_index.get(name)?; + extract_gateway_project_view(&state.crdt.doc.gateway_projects[idx]) +} + +/// Tombstone a gateway-project entry by `name`. +/// +/// Returns `true` if the entry existed and a delete op was issued. +pub fn delete_gateway_project(name: &str) -> bool { + let Some(state_mutex) = get_crdt() else { + return false; + }; + let Ok(mut state) = state_mutex.lock() else { + return false; + }; + let Some(&idx) = state.gateway_project_index.get(name) else { + return false; + }; + let Some(op_id) = list_id_at(&state.crdt.doc.gateway_projects, idx) else { + return false; + }; + apply_and_persist(&mut state, |s| s.crdt.doc.gateway_projects.delete(op_id)); + state.gateway_project_index = rebuild_gateway_project_index(&state.crdt); + true +} + +fn extract_gateway_project_view(entry: &GatewayProjectCrdt) -> Option { + let name = match entry.name.view() { + JsonValue::String(s) if !s.is_empty() => s, + _ => return None, + }; + let url = match entry.url.view() { + JsonValue::String(s) => s, + _ => String::new(), + }; + Some(GatewayProjectView { name, url }) +} + // ── Tests ───────────────────────────────────────────────────────────── // // The `tokens` collection is used as the representative collection for the diff --git a/server/src/crdt_state/mod.rs b/server/src/crdt_state/mod.rs index 363c7c50..b6bb5894 100644 --- a/server/src/crdt_state/mod.rs +++ b/server/src/crdt_state/mod.rs @@ -28,11 +28,12 @@ mod write; pub use gateway_config::{read_gateway_active_project, write_gateway_active_project}; pub use lww_maps::{ - delete_active_agent, delete_agent_throttle, delete_merge_job, delete_test_job, - delete_token_usage, read_active_agent, read_agent_throttle, read_all_active_agents, - read_all_agent_throttles, read_all_merge_jobs, read_all_test_jobs, read_all_token_usage, + delete_active_agent, delete_agent_throttle, delete_gateway_project, delete_merge_job, + delete_test_job, delete_token_usage, read_active_agent, read_agent_throttle, + read_all_active_agents, read_all_agent_throttles, read_all_gateway_projects, + read_all_merge_jobs, read_all_test_jobs, read_all_token_usage, read_gateway_project, read_merge_job, read_test_job, read_token_usage, write_active_agent, write_agent_throttle, - write_merge_job, write_test_job, write_token_usage, + write_gateway_project, write_merge_job, write_test_job, write_token_usage, }; pub use ops::{all_ops_json, apply_remote_op, ops_since, our_vector_clock, subscribe_ops}; pub use presence::{ @@ -46,9 +47,9 @@ pub use read::{ pub use state::init; pub use types::{ ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent, - GatewayConfigCrdt, MergeJobCrdt, MergeJobView, NodePresenceCrdt, NodePresenceView, PipelineDoc, - PipelineItemCrdt, PipelineItemView, TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, - subscribe, + GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, MergeJobCrdt, MergeJobView, + NodePresenceCrdt, NodePresenceView, PipelineDoc, PipelineItemCrdt, PipelineItemView, + TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, subscribe, }; pub use write::{ migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id, write_item, diff --git a/server/src/crdt_state/state.rs b/server/src/crdt_state/state.rs index af2d0f4f..ef5e3eb3 100644 --- a/server/src/crdt_state/state.rs +++ b/server/src/crdt_state/state.rs @@ -71,6 +71,8 @@ pub(super) struct CrdtState { pub(super) test_job_index: HashMap, /// Maps node_id → index in the agent_throttle ListCrdt for O(1) lookup. pub(super) agent_throttle_index: HashMap, + /// Maps project name → index in the gateway_projects ListCrdt for O(1) lookup. + pub(super) gateway_project_index: HashMap, /// Channel sender for fire-and-forget op persistence. pub(super) persist_tx: mpsc::UnboundedSender, /// Max sequence number seen across all ops during init() replay. @@ -161,6 +163,7 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> { let active_agent_index = rebuild_active_agent_index(&crdt); let test_job_index = rebuild_test_job_index(&crdt); let agent_throttle_index = rebuild_agent_throttle_index(&crdt); + let gateway_project_index = rebuild_gateway_project_index(&crdt); // Advance the top-level list clocks to the Lamport floor so that // list-level inserts don't re-emit low seq numbers. @@ -171,6 +174,7 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> { crdt.doc.active_agents.advance_seq(lamport_floor); crdt.doc.test_jobs.advance_seq(lamport_floor); crdt.doc.agent_throttle.advance_seq(lamport_floor); + crdt.doc.gateway_projects.advance_seq(lamport_floor); crdt.doc .gateway_config .active_project @@ -228,6 +232,7 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> { active_agent_index, test_job_index, agent_throttle_index, + gateway_project_index, persist_tx, lamport_floor, }; @@ -271,6 +276,7 @@ pub fn init_for_test() { active_agent_index: HashMap::new(), test_job_index: HashMap::new(), agent_throttle_index: HashMap::new(), + gateway_project_index: HashMap::new(), persist_tx, lamport_floor: 0, }; @@ -385,6 +391,19 @@ pub(super) fn rebuild_agent_throttle_index(crdt: &BaseCrdt) -> Hash map } +/// Rebuild the project name → gateway_projects list index. +pub(super) fn rebuild_gateway_project_index( + crdt: &BaseCrdt, +) -> HashMap { + let mut map = HashMap::new(); + for (i, entry) in crdt.doc.gateway_projects.iter().enumerate() { + if let JsonValue::String(ref k) = entry.name.view() { + map.insert(k.clone(), i); + } + } + map +} + // ── Write path ─────────────────────────────────────────────────────── /// Create a CRDT op via `op_fn`, sign it, apply it, and send it to the @@ -597,6 +616,7 @@ mod tests { active_agent_index: HashMap::new(), test_job_index: HashMap::new(), agent_throttle_index: HashMap::new(), + gateway_project_index: HashMap::new(), persist_tx, lamport_floor: 0, }; @@ -669,6 +689,7 @@ mod tests { active_agent_index: HashMap::new(), test_job_index: HashMap::new(), agent_throttle_index: HashMap::new(), + gateway_project_index: HashMap::new(), persist_tx, lamport_floor: 0, }; diff --git a/server/src/crdt_state/types.rs b/server/src/crdt_state/types.rs index 7d79b1cc..febbcb9b 100644 --- a/server/src/crdt_state/types.rs +++ b/server/src/crdt_state/types.rs @@ -53,6 +53,7 @@ pub struct PipelineDoc { pub active_agents: ListCrdt, pub test_jobs: ListCrdt, pub agent_throttle: ListCrdt, + pub gateway_projects: ListCrdt, pub gateway_config: GatewayConfigCrdt, } @@ -205,6 +206,16 @@ pub struct AgentThrottleCrdt { pub limit: LwwRegisterCrdt, } +/// CRDT entry for a gateway project registered in `gateway_config.projects`. +#[add_crdt_fields] +#[derive(Clone, CrdtNode, Debug)] +pub struct GatewayProjectCrdt { + /// Unique key: project name (e.g. `"huskies"`). + pub name: LwwRegisterCrdt, + /// Container base URL (e.g. `"http://huskies:3001"`). + pub url: LwwRegisterCrdt, +} + // ── LWW-map view types ─────────────────────────────────────────────── /// Snapshot of a single token-usage entry. @@ -256,6 +267,13 @@ pub struct AgentThrottleView { pub limit: f64, } +/// Snapshot of a single gateway-project entry. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct GatewayProjectView { + pub name: String, + pub url: String, +} + #[cfg(test)] mod tests { use super::super::state::emit_event;