diff --git a/crates/cli/src/commands/project.rs b/crates/cli/src/commands/project.rs index da9e3223..d7a618d9 100644 --- a/crates/cli/src/commands/project.rs +++ b/crates/cli/src/commands/project.rs @@ -292,8 +292,6 @@ async fn show_project(client: &OrchestratorClient, id_or_name: &str, json: bool) if let Some(desc) = &project.description { println!(" Description: {}", desc); } - println!(" Agents: {}", project.agent_count); - println!(" Workflows: {}", project.workflow_count); println!(" Created: {}", project.created_at.format("%Y-%m-%d %H:%M:%S UTC")); println!(" Updated: {}", project.updated_at.format("%Y-%m-%d %H:%M:%S UTC")); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 07514408..f53847e4 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -712,12 +712,16 @@ async fn main() -> Result<()> { } Commands::Project { command } => { let token = load_token_or_warn(); - let url = gateway_url("orchestrator"); - let mut client = OrchestratorClient::new(url); + let orch_url = gateway_url("orchestrator"); + // Project CRUD now lives in core; association operations stay on the + // orchestrator. Build a client that routes CRUD to core and keeps + // associations on the orchestrator base URL. + let core_api_url = format!("{}/api/v1", client::core_url()); + let mut orch_client = OrchestratorClient::new(orch_url).with_core_url(core_api_url); if let Some(t) = token { - client = client.with_token(t); + orch_client = orch_client.with_token(t); } - command.execute(&client, cli.json).await?; + command.execute(&orch_client, cli.json).await?; } Commands::Control => { agentd_tui::run_control().await?; diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index a7986f4d..442ffb50 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -455,6 +455,8 @@ impl Default for CorePamConfig { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct McpConfig { + /// Core service URL (API gateway). Defaults to `"http://localhost:17000"`. + pub core_url: String, /// Orchestrator service URL. Defaults to `"http://localhost:17006"`. pub orchestrator_url: String, /// Notify service URL. Defaults to `"http://localhost:17004"`. @@ -478,6 +480,7 @@ pub struct McpConfig { impl Default for McpConfig { fn default() -> Self { Self { + core_url: "http://localhost:17000".to_string(), orchestrator_url: "http://localhost:17006".to_string(), notify_url: "http://localhost:17004".to_string(), ask_url: "http://localhost:17001".to_string(), @@ -731,6 +734,7 @@ impl ValidateConfig for MonitorConfig { impl ValidateConfig for McpConfig { fn validate(&self) -> Result<()> { for (name, url) in [ + ("mcp.core_url", self.core_url.as_str()), ("mcp.orchestrator_url", self.orchestrator_url.as_str()), ("mcp.notify_url", self.notify_url.as_str()), ("mcp.ask_url", self.ask_url.as_str()), @@ -1164,6 +1168,11 @@ fn merge(base: AgentdConfig, file: AgentdConfig) -> AgentdConfig { }, }, mcp: McpConfig { + core_url: pick( + &base.services.mcp.core_url, + &file.services.mcp.core_url, + &d.services.mcp.core_url, + ), orchestrator_url: pick( &base.services.mcp.orchestrator_url, &file.services.mcp.orchestrator_url, @@ -1368,6 +1377,9 @@ fn apply_env_overrides(cfg: &mut AgentdConfig) { } // ── MCP ─────────────────────────────────────────────────────────────── + if let Ok(v) = env::var("AGENTD_MCP_CORE_URL") { + cfg.services.mcp.core_url = v; + } if let Ok(v) = env::var("AGENTD_MCP_ORCHESTRATOR_URL") { cfg.services.mcp.orchestrator_url = v; } diff --git a/crates/core/src/api/projects.rs b/crates/core/src/api/projects.rs index 4569ffeb..925f8139 100644 --- a/crates/core/src/api/projects.rs +++ b/crates/core/src/api/projects.rs @@ -270,7 +270,7 @@ mod tests { async fn test_app() -> (Router, tempfile::TempDir) { let (conn, tmp) = create_test_connection().await; let storage = Storage::new(conn).await.unwrap(); - let state = AppState { storage }; + let state = AppState::new(storage); let app = crate::api::create_router(state); (app, tmp) } diff --git a/crates/mcp/src/client.rs b/crates/mcp/src/client.rs index 386aec3e..8326ab62 100644 --- a/crates/mcp/src/client.rs +++ b/crates/mcp/src/client.rs @@ -22,6 +22,11 @@ impl AgentdClient { Self { inner: Client::new(), config } } + /// Returns the base URL for the core service (API gateway). + pub fn core_url(&self) -> &str { + &self.config.core_url + } + /// Returns the base URL for the orchestrator service. pub fn orchestrator_url(&self) -> &str { &self.config.orchestrator_url diff --git a/crates/mcp/src/config.rs b/crates/mcp/src/config.rs index 6ffa57dc..9cf15458 100644 --- a/crates/mcp/src/config.rs +++ b/crates/mcp/src/config.rs @@ -11,6 +11,8 @@ use std::env; /// Configuration for connecting to agentd services. #[derive(Debug, Clone)] pub struct AgentdMcpConfig { + /// Core service URL (API gateway, default: `http://127.0.0.1:17000`) + pub core_url: String, /// Orchestrator service URL (default: `http://127.0.0.1:17006`) pub orchestrator_url: String, /// Communicate service URL (default: `http://127.0.0.1:17010`) @@ -41,6 +43,7 @@ impl AgentdMcpConfig { /// /// | Variable | Default | /// |---------------------------------|-----------------------------| + /// | `AGENTD_CORE_URL` | `http://127.0.0.1:17000` | /// | `AGENTD_ORCHESTRATOR_URL` | `http://127.0.0.1:17006` | /// | `AGENTD_COMMUNICATE_URL` | `http://127.0.0.1:17010` | /// | `AGENTD_MEMORY_URL` | `http://127.0.0.1:17008` | @@ -58,6 +61,7 @@ impl AgentdMcpConfig { let base = shared.services.mcp; Self { + core_url: env::var("AGENTD_CORE_URL").unwrap_or(base.core_url), orchestrator_url: env::var("AGENTD_ORCHESTRATOR_URL").unwrap_or(base.orchestrator_url), communicate_url: env::var("AGENTD_COMMUNICATE_URL").unwrap_or(base.communicate_url), memory_url: env::var("AGENTD_MEMORY_URL").unwrap_or(base.memory_url), @@ -80,6 +84,7 @@ impl AgentdMcpConfig { impl ValidateConfig for AgentdMcpConfig { fn validate(&self) -> Result<()> { let urls = [ + ("mcp.core_url", self.core_url.as_str()), ("mcp.orchestrator_url", self.orchestrator_url.as_str()), ("mcp.communicate_url", self.communicate_url.as_str()), ("mcp.memory_url", self.memory_url.as_str()), @@ -111,6 +116,7 @@ mod tests { fn test_defaults() { let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let vars = [ + "AGENTD_CORE_URL", "AGENTD_ORCHESTRATOR_URL", "AGENTD_COMMUNICATE_URL", "AGENTD_MEMORY_URL", @@ -133,6 +139,7 @@ mod tests { env::set_var("AGENTD_CONFIG", "/nonexistent/agentd-mcp-test-config.toml"); let config = AgentdMcpConfig::from_env(); + assert_eq!(config.core_url, "http://localhost:17000"); assert_eq!(config.orchestrator_url, "http://localhost:17006"); assert_eq!(config.communicate_url, "http://localhost:17010"); assert_eq!(config.memory_url, "http://localhost:17008"); @@ -168,6 +175,7 @@ mod tests { fn test_validate_default_passes() { let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let vars = [ + "AGENTD_CORE_URL", "AGENTD_ORCHESTRATOR_URL", "AGENTD_COMMUNICATE_URL", "AGENTD_MEMORY_URL", @@ -195,6 +203,7 @@ mod tests { #[test] fn test_validate_bad_url_fails() { let config = AgentdMcpConfig { + core_url: "http://127.0.0.1:17000".to_string(), orchestrator_url: "not-a-url".to_string(), communicate_url: "http://127.0.0.1:17010".to_string(), memory_url: "http://127.0.0.1:17008".to_string(), diff --git a/crates/mcp/src/tools/orchestrator_debug.rs b/crates/mcp/src/tools/orchestrator_debug.rs index 5f77c52b..a9f17057 100644 --- a/crates/mcp/src/tools/orchestrator_debug.rs +++ b/crates/mcp/src/tools/orchestrator_debug.rs @@ -90,8 +90,6 @@ struct ProjectDetail { description: Option, created_at: String, updated_at: String, - agent_count: u64, - workflow_count: u64, } #[derive(Debug, Deserialize)] @@ -302,13 +300,13 @@ pub async fn run_get_conversation_summary(client: &AgentdClient, agent_id: &str) } pub async fn run_list_projects(client: &AgentdClient, limit: Option) -> String { - let base = client.orchestrator_url(); + let base = client.core_url(); let limit_val = limit.unwrap_or(50).clamp(1, 200); - let url = format!("{base}/projects?limit={limit_val}"); + let url = format!("{base}/api/v1/projects?limit={limit_val}"); let resp = match client.inner.get(&url).send().await { Ok(r) => r, - Err(e) => return format!("Error: orchestrator unreachable at {base}: {e}"), + Err(e) => return format!("Error: core unreachable at {base}: {e}"), }; if !resp.status().is_success() { return format!("Error: HTTP {} listing projects", resp.status()); @@ -338,12 +336,12 @@ pub async fn run_list_projects(client: &AgentdClient, limit: Option) -> Str } pub async fn run_get_project(client: &AgentdClient, project_id: &str) -> String { - let base = client.orchestrator_url(); - let url = format!("{base}/projects/{project_id}"); + let base = client.core_url(); + let url = format!("{base}/api/v1/projects/{project_id}"); let resp = match client.inner.get(&url).send().await { Ok(r) => r, - Err(e) => return format!("Error: orchestrator unreachable at {base}: {e}"), + Err(e) => return format!("Error: core unreachable at {base}: {e}"), }; if resp.status() == reqwest::StatusCode::NOT_FOUND { return format!("Project `{project_id}` not found."); @@ -361,8 +359,6 @@ pub async fn run_get_project(client: &AgentdClient, project_id: &str) -> String if let Some(ref d) = p.description { out.push_str(&format!("- **Description**: {d}\n")); } - out.push_str(&format!("- **Agents**: {}\n", p.agent_count)); - out.push_str(&format!("- **Workflows**: {}\n", p.workflow_count)); out.push_str(&format!("- **Created**: {}\n", p.created_at)); out.push_str(&format!("- **Updated**: {}\n", p.updated_at)); out diff --git a/crates/mcp/tests/common/mod.rs b/crates/mcp/tests/common/mod.rs index 4f137c1a..8ecb2bf1 100644 --- a/crates/mcp/tests/common/mod.rs +++ b/crates/mcp/tests/common/mod.rs @@ -418,6 +418,7 @@ pub async fn mock_monitor_server() -> MockServer { /// Build an `AgentdClient` pointed at the given mock servers. pub fn test_client(orch_addr: &str, notify_addr: &str, monitor_addr: &str) -> AgentdClient { let config = Arc::new(AgentdMcpConfig { + core_url: "http://127.0.0.1:1".to_string(), // unused orchestrator_url: orch_addr.to_string(), communicate_url: "http://127.0.0.1:1".to_string(), // unused memory_url: "http://127.0.0.1:1".to_string(), diff --git a/crates/orchestrator/src/client.rs b/crates/orchestrator/src/client.rs index af4cea5e..5c6b1142 100644 --- a/crates/orchestrator/src/client.rs +++ b/crates/orchestrator/src/client.rs @@ -37,9 +37,9 @@ use crate::types::{ AddDirRequest, AddDirResponse, AgentResponse, AgentUsageStats, ApprovalActionRequest, ClearContextRequest, ClearContextResponse, ConversationEventResponse, ConversationHistoryQuery, ConversationHistoryResponse, ConversationSummary, CreateAgentRequest, CreateProjectRequest, - HealthResponse, PaginatedResponse, PendingApproval, Project, ProjectResponse, - SendMessageRequest, SendMessageResponse, SetModelRequest, ToolPolicy, UpdateAgentRequest, - UpdateAgentResponse, UpdateProjectRequest, + HealthResponse, PaginatedResponse, PendingApproval, Project, SendMessageRequest, + SendMessageResponse, SetModelRequest, ToolPolicy, UpdateAgentRequest, UpdateAgentResponse, + UpdateProjectRequest, }; /// Typed HTTP client for the orchestrator service. @@ -58,6 +58,12 @@ use crate::types::{ pub struct OrchestratorClient { client: reqwest::Client, base_url: String, + /// Optional base URL for the core service (used for project CRUD). + /// + /// When set, project CRUD operations (`list_projects`, `create_project`, + /// `get_project`, `update_project`, `delete_project`) target this URL + /// instead of `base_url`. Association operations remain on `base_url`. + core_base_url: Option, token: Option, } @@ -72,7 +78,12 @@ impl OrchestratorClient { /// let client = OrchestratorClient::new("http://localhost:7006"); /// ``` pub fn new(base_url: impl Into) -> Self { - Self { client: reqwest::Client::new(), base_url: base_url.into(), token: None } + Self { + client: reqwest::Client::new(), + base_url: base_url.into(), + core_base_url: None, + token: None, + } } /// Attach a bearer token to all requests made by this client. @@ -83,6 +94,26 @@ impl OrchestratorClient { self } + /// Set the core service base URL for project CRUD operations. + /// + /// Project CRUD (`list_projects`, `create_project`, `get_project`, + /// `update_project`, `delete_project`) will target `{core_base_url}/projects` + /// instead of the orchestrator. Association operations remain on the + /// orchestrator `base_url`. + /// + /// # Examples + /// + /// ```ignore + /// use orchestrator::client::OrchestratorClient; + /// + /// let client = OrchestratorClient::new("http://localhost:17006") + /// .with_core_url("http://localhost:17000/api/v1"); + /// ``` + pub fn with_core_url(mut self, core_base_url: impl Into) -> Self { + self.core_base_url = Some(core_base_url.into()); + self + } + /// The base URL this client targets (e.g. the core gateway /// `{core_url}/api/v1/orchestrator`). /// @@ -408,37 +439,82 @@ impl OrchestratorClient { self.delete_with_response(&format!("/queues/{}", queue_name)).await } - // -- Project management -- + // -- Project management (CRUD targets core; associations stay on orchestrator) -- /// List all projects. + /// + /// Targets the core service when a core base URL is configured via + /// [`with_core_url`]; otherwise falls back to the orchestrator base URL. pub async fn list_projects(&self) -> Result> { - self.get("/projects").await + self.get_core("/projects").await } /// Create a new project. + /// + /// Targets the core service when a core base URL is configured. pub async fn create_project(&self, req: &CreateProjectRequest) -> Result { - self.post("/projects", req).await + self.post_core("/projects", req).await } - /// Get a project by UUID, including agent and workflow counts. - pub async fn get_project(&self, id: &Uuid) -> Result { - self.get(&format!("/projects/{id}")).await + /// Get a project by UUID. + /// + /// Targets the core service when a core base URL is configured. + /// Returns the project without agent/workflow counts (core does not + /// compute those; query the orchestrator association endpoints separately + /// if counts are needed). + pub async fn get_project(&self, id: &Uuid) -> Result { + self.get_core(&format!("/projects/{id}")).await } /// Find a project by name (client-side search). + /// + /// Targets the core service when a core base URL is configured. pub async fn get_project_by_name(&self, name: &str) -> Result> { - let resp: PaginatedResponse = self.get("/projects?limit=500").await?; + let resp: PaginatedResponse = self.get_core("/projects?limit=500").await?; Ok(resp.items.into_iter().find(|p| p.name == name)) } /// Update a project's name and/or description. + /// + /// Targets the core service when a core base URL is configured. pub async fn update_project(&self, id: &Uuid, req: &UpdateProjectRequest) -> Result { - self.put(&format!("/projects/{id}"), req).await + self.put_core(&format!("/projects/{id}"), req).await } - /// Delete a project (fails if agents or workflows are still associated). + /// Delete a project. + /// + /// Checks the orchestrator for active agent and workflow associations + /// before sending the DELETE to core, preventing orphaned references + /// (core does not enforce cross-service constraints at this layer). + /// Returns an error if any associations remain. + /// + /// Targets the core service when a core base URL is configured. pub async fn delete_project(&self, id: &Uuid) -> Result<()> { - self.delete(&format!("/projects/{id}")).await + // Guard: verify no agent associations remain on the orchestrator. + let agents = self + .list_project_agents(id) + .await + .context("Failed to check project agent associations before deletion")?; + if agents.total > 0 { + anyhow::bail!( + "cannot delete project {id}: {} agent(s) still associated \ + (dissociate them first with `project remove-agent`)", + agents.total + ); + } + // Guard: verify no workflow associations remain on the orchestrator. + let workflows = self + .list_project_workflows(id) + .await + .context("Failed to check project workflow associations before deletion")?; + if workflows.total > 0 { + anyhow::bail!( + "cannot delete project {id}: {} workflow(s) still associated \ + (dissociate them first with `project remove-workflow`)", + workflows.total + ); + } + self.delete_core(&format!("/projects/{id}")).await } /// List agents associated with a project. @@ -554,49 +630,126 @@ impl OrchestratorClient { } // -- Private HTTP helpers -- + // + // `url_for` is the single URL-computation function used by all helpers. + // Pass `use_core = true` to route to `core_base_url` (falling back to + // `base_url` when unset); `use_core = false` always uses `base_url`. + // + // The `*_core` wrappers are thin aliases that set `use_core = true` so + // call sites in the project-CRUD methods remain readable without repeating + // the flag everywhere. + + fn url_for(&self, path: &str, use_core: bool) -> String { + let base = if use_core { + self.core_base_url.as_deref().unwrap_or(&self.base_url) + } else { + &self.base_url + }; + format!("{base}{path}") + } + + // -- Core-routing wrappers (use_core = true) -- + + async fn get_core(&self, path: &str) -> Result { + self.get_url(self.url_for(path, true)).await + } + + async fn post_core( + &self, + path: &str, + body: &T, + ) -> Result { + self.post_url(self.url_for(path, true), body).await + } + + async fn put_core(&self, path: &str, body: &T) -> Result { + self.put_url(self.url_for(path, true), body).await + } + + async fn delete_core(&self, path: &str) -> Result<()> { + self.delete_url(self.url_for(path, true)).await + } + + // -- Orchestrator-routing wrappers (use_core = false) -- async fn get(&self, path: &str) -> Result { - let url = format!("{}{}", self.base_url, path); - let mut req = self.client.get(&url); + self.get_url(self.url_for(path, false)).await + } + + async fn post(&self, path: &str, body: &T) -> Result { + self.post_url(self.url_for(path, false), body).await + } + + async fn put(&self, path: &str, body: &T) -> Result { + self.put_url(self.url_for(path, false), body).await + } + + async fn patch(&self, path: &str, body: &T) -> Result { + let url = self.url_for(path, false); + let mut req = self.client.patch(&url).json(body); if let Some(t) = &self.token { req = req.bearer_auth(t); } - let response = req.send().await.context(format!("Failed to GET {url}"))?; + let response = req.send().await.context(format!("Failed to PATCH {url}"))?; Self::handle_response(response).await } - async fn post(&self, path: &str, body: &T) -> Result { - let url = format!("{}{}", self.base_url, path); - let mut req = self.client.post(&url).json(body); + async fn delete(&self, path: &str) -> Result<()> { + self.delete_url(self.url_for(path, false)).await + } + + async fn delete_with_body( + &self, + path: &str, + body: &T, + ) -> Result { + let url = self.url_for(path, false); + let mut req = self.client.delete(&url).json(body); if let Some(t) = &self.token { req = req.bearer_auth(t); } - let response = req.send().await.context(format!("Failed to POST {url}"))?; + let response = req.send().await.context(format!("Failed to DELETE {url}"))?; Self::handle_response(response).await } - async fn put(&self, path: &str, body: &T) -> Result { - let url = format!("{}{}", self.base_url, path); - let mut req = self.client.put(&url).json(body); + async fn delete_with_response(&self, path: &str) -> Result { + self.delete_with_response_url(self.url_for(path, false)).await + } + + // -- URL-based implementations (shared by both routing variants) -- + + async fn get_url(&self, url: String) -> Result { + let mut req = self.client.get(&url); if let Some(t) = &self.token { req = req.bearer_auth(t); } - let response = req.send().await.context(format!("Failed to PUT {url}"))?; + let response = req.send().await.context(format!("Failed to GET {url}"))?; Self::handle_response(response).await } - async fn patch(&self, path: &str, body: &T) -> Result { - let url = format!("{}{}", self.base_url, path); - let mut req = self.client.patch(&url).json(body); + async fn post_url( + &self, + url: String, + body: &T, + ) -> Result { + let mut req = self.client.post(&url).json(body); if let Some(t) = &self.token { req = req.bearer_auth(t); } - let response = req.send().await.context(format!("Failed to PATCH {url}"))?; + let response = req.send().await.context(format!("Failed to POST {url}"))?; Self::handle_response(response).await } - async fn delete(&self, path: &str) -> Result<()> { - let url = format!("{}{}", self.base_url, path); + async fn put_url(&self, url: String, body: &T) -> Result { + let mut req = self.client.put(&url).json(body); + if let Some(t) = &self.token { + req = req.bearer_auth(t); + } + let response = req.send().await.context(format!("Failed to PUT {url}"))?; + Self::handle_response(response).await + } + + async fn delete_url(&self, url: String) -> Result<()> { let mut req = self.client.delete(&url); if let Some(t) = &self.token { req = req.bearer_auth(t); @@ -611,22 +764,7 @@ impl OrchestratorClient { } } - async fn delete_with_body( - &self, - path: &str, - body: &T, - ) -> Result { - let url = format!("{}{}", self.base_url, path); - let mut req = self.client.delete(&url).json(body); - if let Some(t) = &self.token { - req = req.bearer_auth(t); - } - let response = req.send().await.context(format!("Failed to DELETE {url}"))?; - Self::handle_response(response).await - } - - async fn delete_with_response(&self, path: &str) -> Result { - let url = format!("{}{}", self.base_url, path); + async fn delete_with_response_url(&self, url: String) -> Result { let mut req = self.client.delete(&url); if let Some(t) = &self.token { req = req.bearer_auth(t); @@ -650,7 +788,21 @@ impl OrchestratorClient { mod tests { // reqwest::Client::new() triggers macOS system-configuration TLS // initialisation which panics when called from non-main test threads. - // These tests verify URL string handling without constructing the client. + // Tests that only need to verify URL string handling use a lightweight + // helper that skips reqwest construction. + + use super::*; + + /// Build a minimal `OrchestratorClient` for URL-computation tests only. + /// Does NOT construct a live reqwest client; safe to call from test threads. + fn url_only_client(base: &str, core: Option<&str>) -> OrchestratorClient { + OrchestratorClient { + client: reqwest::Client::new(), + base_url: base.to_string(), + core_base_url: core.map(|s| s.to_string()), + token: None, + } + } #[test] fn test_base_url_string_conversion() { @@ -670,4 +822,32 @@ mod tests { let url: String = String::from("http://localhost:7006"); assert_eq!(url, "http://localhost:7006"); } + + // -- url_for tests -- + + #[test] + fn test_url_for_uses_base_when_core_not_set() { + let c = url_only_client("http://orch", None); + // use_core = true falls back to base_url when core_base_url is None + assert_eq!(c.url_for("/projects", true), "http://orch/projects"); + // use_core = false always uses base_url + assert_eq!(c.url_for("/agents", false), "http://orch/agents"); + } + + #[test] + fn test_url_for_uses_core_base_when_set() { + let c = url_only_client("http://orch", Some("http://core/api/v1")); + // use_core = true targets core_base_url + assert_eq!(c.url_for("/projects", true), "http://core/api/v1/projects"); + // use_core = false still targets base_url (orchestrator) + assert_eq!(c.url_for("/agents", false), "http://orch/agents"); + } + + #[test] + fn test_with_core_url_builder_sets_core_base() { + // Verify the public builder wires up core_base_url correctly via url_for. + let c = url_only_client("http://orch", None); + let c = OrchestratorClient { core_base_url: Some("http://core/api/v1".into()), ..c }; + assert_eq!(c.url_for("/projects", true), "http://core/api/v1/projects"); + } } diff --git a/ui/src/components/knowledge/ProjectPicker.tsx b/ui/src/components/knowledge/ProjectPicker.tsx index f664ef6a..3f483bb6 100644 --- a/ui/src/components/knowledge/ProjectPicker.tsx +++ b/ui/src/components/knowledge/ProjectPicker.tsx @@ -6,7 +6,7 @@ import { ChevronDown, FolderOpen } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { Project } from "@/types/orchestrator"; -import { orchestratorClient } from "@/services/orchestrator"; +import { coreClient } from "@/services/core"; interface ProjectPickerProps { selectedId: string | null; @@ -27,7 +27,7 @@ export function ProjectPicker({ useEffect(() => { let cancelled = false; setLoading(true); - orchestratorClient + coreClient .listProjects({ limit: 200 }) .then((page) => { if (!cancelled) setProjects(page.items); diff --git a/ui/src/services/core.ts b/ui/src/services/core.ts new file mode 100644 index 00000000..8e8f89cf --- /dev/null +++ b/ui/src/services/core.ts @@ -0,0 +1,32 @@ +/** + * Client for the Core service (default port 17000). + * + * The core service is the API gateway and owns the canonical project entity. + * Project CRUD operations are served from `/api/v1/projects`. + */ + +import type { PaginatedResponse } from "@/types/common"; +import type { ListProjectsParams, Project } from "@/types/orchestrator"; +import { ApiClient, withAuth } from "./base"; +import { serviceConfig } from "./config"; + +export class CoreClient extends ApiClient { + // ------------------------------------------------------------------------- + // Projects + // ------------------------------------------------------------------------- + + /** `GET /api/v1/projects` — list all projects. */ + listProjects( + params?: ListProjectsParams, + ): Promise> { + return this.get>( + "/api/v1/projects", + params as Record, + ); + } +} + +/** Singleton client instance using the configured core service URL */ +export const coreClient = new CoreClient( + withAuth({ baseUrl: serviceConfig.coreServiceUrl }), +);