From e8a4a1138bd1f5fceaaa93fd8225130d9a82fd5e Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 30 Mar 2026 01:31:07 +0300 Subject: [PATCH] feat: connect alpine agents to admin api --- TASKS.md | 18 +- apps/admin-api/src/app.rs | 140 ++++++- apps/admin-api/src/routes/agents.rs | 32 +- apps/admin-api/src/service.rs | 224 ++++++++++- apps/ui/html/agents.html | 12 +- apps/ui/js/agents.js | 511 ++++++++++++++++---------- apps/ui/js/api.js | 21 ++ crates/crank-registry/src/postgres.rs | 55 +++ docs/alpine-ui-integration-plan.md | 14 +- 9 files changed, 807 insertions(+), 220 deletions(-) diff --git a/TASKS.md b/TASKS.md index e8bda65..921590d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,24 +2,20 @@ ## Current -### `feat/alpine-ui` +### `feat/alpine-agents` -Status: in_progress +Status: completed DoD: -- `apps/ui` заменен на новый Alpine.js UI из `test-ui` +- `Agents` page читает live catalog агентов из `admin-api` +- backend для `Agents` поддерживает `PATCH`, `DELETE` и richer summary для Alpine UI +- `Agents` page использует live `create/edit/delete` flow вместо `agents.json` +- `Agents` page использует live operations picker вместо `operations.json` - `test-ui` остается нетронутым как fallback -- UI продолжает раздаваться как статический контейнер через текущий deployment path -- legacy React/Vite артефакты удалены из `apps/ui` -- backend для `Operations/Wizard` поддерживает server-side `PATCH`, `DELETE`, `archive`, `category`, `usage_summary`, `agent_refs` -- backend summary для `Operations` отдает `target_url` и `target_action` -- первые экраны Alpine UI можно сажать на реальные catalog/detail/mutation endpoints без расширения backend-контракта -- `Operations` page читает live catalog из `admin-api` и удаляет операции через backend -- `Wizard` использует live create/get/update flow вместо `localStorage` и `sessionStorage` ## Next -- `feat/demo-assets` +- `feat/alpine-api-keys` ## Backlog diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 4d61f31..914e417 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -10,8 +10,8 @@ use crate::{ list_invitations, list_memberships, list_platform_api_keys, revoke_platform_api_key, }, agents::{ - create_agent, get_agent, get_agent_version, list_agents, publish_agent, - save_agent_bindings, + create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent, + save_agent_bindings, update_agent, }, auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs}, @@ -76,7 +76,10 @@ pub fn build_app(state: AppState) -> Router { ) .route("/operations/{operation_id}/export", get(export_operation)) .route("/agents", get(list_agents).post(create_agent)) - .route("/agents/{agent_id}", get(get_agent)) + .route( + "/agents/{agent_id}", + get(get_agent).patch(update_agent).delete(delete_agent), + ) .route( "/agents/{agent_id}/versions/{version}", get(get_agent_version), @@ -453,6 +456,137 @@ mod tests { assert_eq!(published["published_version"], 1); } + #[tokio::test] + async fn updates_lists_and_deletes_agent() { + let registry = test_registry().await; + let storage_root = test_storage_root("agent_mutations"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = reqwest::Client::new(); + + let operation = client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "crm_create_lead_agents_page", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = operation["operation_id"].as_str().unwrap().to_owned(); + + client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(); + + let created = client + .post(format!("{base_url}/agents")) + .json(&json!({ + "slug": "support-team", + "display_name": "Support Team", + "description": "Support workflows", + "instructions": {}, + "tool_selection_policy": {} + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let agent_id = created["agent_id"].as_str().unwrap().to_owned(); + + client + .post(format!("{base_url}/agents/{agent_id}/bindings")) + .json(&json!([ + { + "operation_id": operation_id, + "operation_version": 1, + "tool_name": "crm_create_lead_agents_page", + "tool_title": "Create Lead", + "tool_description_override": null, + "enabled": true + } + ])) + .send() + .await + .unwrap(); + + client + .post(format!("{base_url}/agents/{agent_id}/publish")) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(); + + let listed = client + .get(format!("{base_url}/agents")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(listed["items"][0]["operation_count"], 1); + assert_eq!(listed["items"][0]["operation_ids"][0], operation_id); + assert_eq!( + listed["items"][0]["mcp_endpoint"], + "/mcp/v1/default/support-team" + ); + assert_eq!(listed["items"][0]["status"], "published"); + + let updated = client + .patch(format!("{base_url}/agents/{agent_id}")) + .json(&json!({ + "slug": "support-escalation", + "display_name": "Support Escalation", + "description": "Escalation workflows" + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(updated["agent_id"], agent_id); + + let detail = client + .get(format!("{base_url}/agents/{agent_id}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(detail["slug"], "support-escalation"); + assert_eq!(detail["display_name"], "Support Escalation"); + assert_eq!(detail["operation_count"], 1); + assert_eq!(detail["mcp_endpoint"], "/mcp/v1/default/support-escalation"); + + let deleted = client + .delete(format!("{base_url}/agents/{agent_id}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(deleted["agent_id"], agent_id); + + let missing = client + .get(format!("{base_url}/agents/{agent_id}")) + .send() + .await + .unwrap(); + assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND); + } + #[tokio::test] async fn manages_platform_access_resources() { let registry = test_registry().await; diff --git a/apps/admin-api/src/routes/agents.rs b/apps/admin-api/src/routes/agents.rs index 8d5b74c..c9ac4b5 100644 --- a/apps/admin-api/src/routes/agents.rs +++ b/apps/admin-api/src/routes/agents.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use crate::{ error::ApiError, - service::{AgentBindingPayload, AgentPayload, PublishPayload}, + service::{AgentBindingPayload, AgentPayload, PublishPayload, UpdateAgentPayload}, state::AppState, }; @@ -66,6 +66,36 @@ pub async fn get_agent( Ok(Json(json!(agent))) } +pub async fn update_agent( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let updated = state + .service + .update_agent( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!(updated))) +} + +pub async fn delete_agent( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let deleted = state + .service + .delete_agent( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + ) + .await?; + Ok(Json(json!(deleted))) +} + pub async fn get_agent_version( Path(path): Path, State(state): State, diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 2af239b..d808d0c 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -118,6 +118,13 @@ pub struct AgentPayload { pub tool_selection_policy: Value, } +#[derive(Clone, Debug, Deserialize)] +pub struct UpdateAgentPayload { + pub slug: String, + pub display_name: String, + pub description: String, +} + #[derive(Clone, Debug, Deserialize)] pub struct AgentBindingPayload { pub operation_id: String, @@ -145,6 +152,33 @@ pub struct PublishAgentResponse { pub published_at: String, } +#[derive(Clone, Debug, Serialize)] +pub struct AgentSummaryView { + pub id: String, + pub workspace_id: String, + pub slug: String, + pub display_name: String, + pub description: String, + pub status: AgentStatus, + pub current_draft_version: u32, + pub latest_published_version: Option, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, + pub operation_count: usize, + pub operation_ids: Vec, + pub key_count: usize, + pub calls_today: u64, + pub mcp_endpoint: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AgentMutationResult { + pub agent_id: String, + pub workspace_id: String, + pub updated_at: String, +} + #[derive(Clone, Debug, Deserialize)] pub struct InvitationPayload { pub email: String, @@ -1170,9 +1204,54 @@ impl AdminService { pub async fn list_agents( &self, workspace_id: &WorkspaceId, - ) -> Result, ApiError> { + ) -> Result, ApiError> { self.ensure_workspace_exists(workspace_id).await?; - Ok(self.registry.list_agents(workspace_id).await?) + let workspace = self.get_workspace(workspace_id).await?; + let summaries = self.registry.list_agents(workspace_id).await?; + let usage = self + .registry + .list_usage_by_agent(UsageQuery { + workspace_id, + period: UsagePeriod::Last24Hours, + source: None, + created_after: &today_start_utc()?, + bucket: crank_registry::UsageBucket::Hour, + }) + .await?; + let calls_today = usage + .into_iter() + .map(|item| (item.agent_id.as_str().to_owned(), item.calls_total)) + .collect::>(); + let key_count = self + .registry + .list_platform_api_keys(workspace_id) + .await? + .len(); + + let mut items = Vec::with_capacity(summaries.len()); + for summary in summaries { + let version = self + .get_agent_version(workspace_id, &summary.id, summary.current_draft_version) + .await?; + let operation_ids = version + .bindings + .iter() + .map(|binding| binding.operation_id.as_str().to_owned()) + .collect::>(); + items.push(AgentSummaryView { + operation_count: operation_ids.len(), + operation_ids, + key_count, + calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0), + mcp_endpoint: agent_mcp_endpoint( + workspace.workspace.slug.as_str(), + summary.slug.as_str(), + ), + ..map_agent_summary_view(summary) + }); + } + + Ok(items) } #[instrument(skip(self))] @@ -1180,13 +1259,54 @@ impl AdminService { &self, workspace_id: &WorkspaceId, agent_id: &AgentId, - ) -> Result { - self.registry + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let workspace = self.get_workspace(workspace_id).await?; + let summary = self + .registry .get_agent_summary(workspace_id, agent_id) .await? .ok_or_else(|| { ApiError::not_found(format!("agent {} was not found", agent_id.as_str())) - }) + })?; + let version = self + .get_agent_version(workspace_id, agent_id, summary.current_draft_version) + .await?; + let operation_ids = version + .bindings + .iter() + .map(|binding| binding.operation_id.as_str().to_owned()) + .collect::>(); + let usage = self + .registry + .get_usage_for_agent( + UsageQuery { + workspace_id, + period: UsagePeriod::Last24Hours, + source: None, + created_after: &today_start_utc()?, + bucket: crank_registry::UsageBucket::Hour, + }, + agent_id, + ) + .await?; + let key_count = self + .registry + .list_platform_api_keys(workspace_id) + .await? + .len(); + + Ok(AgentSummaryView { + operation_count: operation_ids.len(), + operation_ids, + key_count, + calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0), + mcp_endpoint: agent_mcp_endpoint( + workspace.workspace.slug.as_str(), + summary.slug.as_str(), + ), + ..map_agent_summary_view(summary) + }) } #[instrument(skip(self))] @@ -1267,6 +1387,75 @@ impl AdminService { }) } + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))] + pub async fn update_agent( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + payload: UpdateAgentPayload, + ) -> Result { + let existing = self + .registry + .get_agent_summary(workspace_id, agent_id) + .await? + .ok_or_else(|| { + ApiError::not_found(format!("agent {} was not found", agent_id.as_str())) + })?; + + if payload.slug != existing.slug + && self + .find_agent_by_slug(workspace_id, &payload.slug) + .await? + .is_some() + { + return Err(ApiError::conflict(format!( + "agent with slug {} already exists", + payload.slug + ))); + } + + let updated_at = now_string()?; + self.registry + .update_agent_summary( + workspace_id, + agent_id, + &payload.slug, + &payload.display_name, + &payload.description, + &updated_at, + ) + .await?; + + Ok(AgentMutationResult { + agent_id: agent_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + updated_at, + }) + } + + #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))] + pub async fn delete_agent( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + ) -> Result { + let existing = self + .registry + .get_agent_summary(workspace_id, agent_id) + .await? + .ok_or_else(|| { + ApiError::not_found(format!("agent {} was not found", agent_id.as_str())) + })?; + + self.registry.delete_agent(workspace_id, agent_id).await?; + + Ok(AgentMutationResult { + agent_id: agent_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + updated_at: existing.updated_at, + }) + } + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))] pub async fn save_agent_bindings( &self, @@ -2060,6 +2249,31 @@ fn agent_ref_map(items: Vec) -> BTreeMap AgentSummaryView { + AgentSummaryView { + id: summary.id.as_str().to_owned(), + workspace_id: summary.workspace_id.as_str().to_owned(), + slug: summary.slug, + display_name: summary.display_name, + description: summary.description, + status: summary.status, + current_draft_version: summary.current_draft_version, + latest_published_version: summary.latest_published_version, + created_at: summary.created_at, + updated_at: summary.updated_at, + published_at: summary.published_at, + operation_count: 0, + operation_ids: Vec::new(), + key_count: 0, + calls_today: 0, + mcp_endpoint: String::new(), + } +} + +fn agent_mcp_endpoint(workspace_slug: &str, agent_slug: &str) -> String { + format!("/mcp/v1/{workspace_slug}/{agent_slug}") +} + fn enrich_operation_summary( summary: OperationSummary, usage_summary: OperationUsageSummaryView, diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html index 61bc34f..508d465 100644 --- a/apps/ui/html/agents.html +++ b/apps/ui/html/agents.html @@ -15,6 +15,7 @@ + @@ -160,8 +161,13 @@ Loading agents… +
+
Failed to load agents
+
+
+ -
+
@@ -174,13 +180,13 @@
-
+
No agents match ""
Try a different search term.
-
+