use std::collections::BTreeMap; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, SearchableTool, ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog, }; use crank_registry::{ AgentStateExpectation, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest, SaveAgentCatalogConfigRequest, UpdateAgentSummaryRequest, UsageBucket, UsageQuery, }; use serde_json::json; use sha2::{Digest, Sha256}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::{info, instrument}; use crate::{ error::ApiError, service::{ AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView, CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload, agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id, now_string, today_start_utc, }, }; impl AdminService { pub fn agent_state_etag(agent: &AgentSummaryView) -> String { let policy = serde_json::to_string(&agent.tool_selection_policy).unwrap_or_else(|_| "{}".to_owned()); let operation_ids = agent.operation_ids.join(","); let material = format!( "agent-state-v2\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{:?}\0{:?}\0{}\0{}\0{}\0{}\0{}", agent.workspace_id, agent.id, agent.slug, agent.display_name, agent.description, agent.updated_at, agent.current_draft_version, agent.status, agent.latest_published_version, agent.catalog_revision, agent.operation_count, operation_ids, policy, agent.key_count ); format!("\"{:x}\"", Sha256::digest(material.as_bytes())) } pub fn agent_state_expectation( agent: &AgentSummaryView, ) -> Result { let updated_at = OffsetDateTime::parse(&agent.updated_at, &Rfc3339) .map_err(|_| ApiError::internal("invalid agent state timestamp"))?; Ok(AgentStateExpectation { status: agent.status, current_draft_version: agent.current_draft_version, latest_published_version: agent.latest_published_version, catalog_revision: agent.catalog_revision, updated_at, }) } #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))] pub async fn preview_tool_search( &self, workspace_id: &WorkspaceId, payload: ToolSearchPreviewPayload, ) -> Result, ApiError> { self.ensure_workspace_exists(workspace_id).await?; if payload.tool_selection_policy.mode != ToolAccessMode::Search { return Err(ApiError::validation_with_context( "tool search preview requires search mode", json!({"field": "tool_selection_policy.mode"}), )); } let bindings = payload .bindings .iter() .map(|binding| AgentOperationBinding { operation_id: OperationId::new(binding.operation_id.clone()), operation_version: binding.operation_version, tool_name: binding.tool_name.clone(), tool_title: binding.tool_title.clone(), tool_description_override: binding.tool_description_override.clone(), enabled: binding.enabled, }) .collect::>(); validate_tool_selection_policy(&payload.tool_selection_policy, &bindings)?; let mut tools = Vec::new(); for binding in bindings.iter().filter(|binding| binding.enabled) { let version = self .registry .get_operation_version( workspace_id, &binding.operation_id, binding.operation_version, ) .await? .ok_or_else(|| { ApiError::not_found_with_context( format!("operation {} was not found", binding.operation_id.as_str()), json!({"operation_id": binding.operation_id.as_str()}), ) })?; let groups = payload .tool_selection_policy .groups .iter() .filter(|group| { group .tool_names .iter() .any(|name| name == &binding.tool_name) }) .collect::>(); tools.push(SearchableTool { name: binding.tool_name.clone(), title: binding.tool_title.clone(), description: binding .tool_description_override .clone() .unwrap_or(version.snapshot.tool_description.description), input_schema: serde_json::Value::Null, group_ids: groups.iter().map(|group| group.id.clone()).collect(), group_context: groups .iter() .map(|group| format!("{} {}", group.name, group.description)) .collect::>() .join(" "), }); } let max_results = payload.tool_selection_policy.search.max_results; Ok(search_tool_catalog( &tools, &payload.query, &payload.group_ids, max_results, )) } #[instrument(skip(self))] pub async fn list_agents( &self, workspace_id: &WorkspaceId, ) -> Result, ApiError> { self.ensure_workspace_exists(workspace_id).await?; let workspace = self.get_workspace(workspace_id).await?; let summaries = self.registry.list_agents(workspace_id).await?; let usage_start = today_start_utc()?; let usage_end = now_string()?; let usage = self .registry .list_usage_by_agent(UsageQuery { workspace_id, period: UsagePeriod::Last24Hours, source: None, created_after: &usage_start, created_before: &usage_end, bucket: UsageBucket::Hour, }) .await?; let calls_today = usage .into_iter() .map(|item| (item.agent_id.as_str().to_owned(), item.calls_total)) .collect::>(); let key_counts = self .registry .list_platform_api_keys(workspace_id) .await? .into_iter() .fold(BTreeMap::new(), |mut counts, record| { if let Some(agent_id) = record.api_key.agent_id { *counts.entry(agent_id.as_str().to_owned()).or_insert(0usize) += 1; } counts }); 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, tool_selection_policy: version.snapshot.tool_selection_policy, key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0), 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))] pub async fn get_agent( &self, workspace_id: &WorkspaceId, agent_id: &AgentId, ) -> 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_with_context( format!("agent {} was not found", agent_id.as_str()), json!({ "agent_id": 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_start = today_start_utc()?; let usage_end = now_string()?; let usage = self .registry .get_usage_for_agent( UsageQuery { workspace_id, period: UsagePeriod::Last24Hours, source: None, created_after: &usage_start, created_before: &usage_end, bucket: UsageBucket::Hour, }, agent_id, ) .await?; let key_count = self .registry .list_platform_api_keys_for_agent(workspace_id, agent_id) .await? .len(); Ok(AgentSummaryView { operation_count: operation_ids.len(), operation_ids, tool_selection_policy: version.snapshot.tool_selection_policy, 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))] pub async fn get_agent_version( &self, workspace_id: &WorkspaceId, agent_id: &AgentId, version: u32, ) -> Result { self.registry .get_agent_version(workspace_id, agent_id, version) .await? .ok_or_else(|| { ApiError::not_found_with_context( format!( "agent version {version} for {} was not found", agent_id.as_str() ), json!({ "agent_id": agent_id.as_str(), "version": version, }), ) }) } #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_slug = %payload.slug))] pub async fn create_agent( &self, workspace_id: &WorkspaceId, payload: AgentPayload, ) -> Result { self.ensure_workspace_exists(workspace_id).await?; if self .find_agent_by_slug(workspace_id, &payload.slug) .await? .is_some() { return Err(ApiError::conflict_with_context( format!("agent with slug {} already exists", payload.slug), json!({ "slug": payload.slug }), )); } let now = OffsetDateTime::now_utc(); let agent_id = AgentId::new(new_prefixed_id("agent")); let agent = Agent { id: agent_id.clone(), workspace_id: workspace_id.clone(), slug: payload.slug, display_name: payload.display_name, description: payload.description, status: AgentStatus::Draft, current_draft_version: 1, latest_published_version: None, created_at: now, updated_at: now, published_at: None, }; let version = AgentVersion { agent_id: agent_id.clone(), version: 1, status: AgentStatus::Draft, instructions: payload.instructions, tool_selection_policy: payload.tool_selection_policy, created_at: now, }; self.registry .create_agent(CreateAgentRequest { agent: &agent, version: &version, bindings: &[], }) .await?; info!( name: "admin.agent.created", agent_id = %agent_id.as_str(), version = 1, "agent created" ); Ok(CreatedAgentResponse { agent_id: agent_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(), version: 1, status: AgentStatus::Draft, }) } #[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, expected_state: Option<&AgentStateExpectation>, ) -> Result { let existing = self .registry .get_agent_summary(workspace_id, agent_id) .await? .ok_or_else(|| { ApiError::not_found_with_context( format!("agent {} was not found", agent_id.as_str()), json!({ "agent_id": agent_id.as_str() }), ) })?; if existing.status == AgentStatus::Published && (payload.slug != existing.slug || payload.display_name != existing.display_name || payload.description != existing.description) { return Err(ApiError::conflict_with_context( "published Agent summary is immutable; unpublish before editing", json!({ "agent_id": agent_id.as_str(), "error_code": "agent_published_summary_immutable", "recovery": "unpublish_edit_publish" }), )); } if payload.slug != existing.slug && self .find_agent_by_slug(workspace_id, &payload.slug) .await? .is_some() { return Err(ApiError::conflict_with_context( format!("agent with slug {} already exists", payload.slug), json!({ "slug": payload.slug }), )); } let updated_at = OffsetDateTime::now_utc(); self.registry .update_agent_summary( workspace_id, agent_id, UpdateAgentSummaryRequest { slug: &payload.slug, display_name: &payload.display_name, description: &payload.description, updated_at: &updated_at, expected_state, }, ) .await?; Ok(AgentMutationResult { agent_id: agent_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(), updated_at: format_timestamp(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, expected_state: Option<&AgentStateExpectation>, ) -> Result { let existing = self .registry .get_agent_summary(workspace_id, agent_id) .await? .ok_or_else(|| { ApiError::not_found_with_context( format!("agent {} was not found", agent_id.as_str()), json!({ "agent_id": agent_id.as_str() }), ) })?; if existing.latest_published_version.is_some() { return Err(ApiError::conflict_with_context( "published Agent cannot be deleted; archive or unpublish it instead", json!({ "agent_id": agent_id.as_str(), "error_code": "agent_delete_forbidden", "recovery": "archive_or_unpublish" }), )); } self.registry .delete_agent(workspace_id, agent_id, expected_state) .await?; Ok(AgentMutationResult { agent_id: agent_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(), updated_at: format_timestamp(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, workspace_id: &WorkspaceId, agent_id: &AgentId, payload: AgentCatalogPayload, expected_state: Option<&AgentStateExpectation>, ) -> Result { let current_version = self .ensure_editable_agent_version(workspace_id, agent_id) .await?; let (payload, requested_policy) = payload.into_parts(); let bindings = payload .into_iter() .map(|binding| AgentOperationBinding { operation_id: OperationId::new(binding.operation_id), operation_version: binding.operation_version, tool_name: binding.tool_name, tool_title: binding.tool_title, tool_description_override: binding.tool_description_override, enabled: binding.enabled, }) .collect::>(); self.validate_exact_published_agent_bindings(workspace_id, &bindings) .await?; let tool_selection_policy = requested_policy .unwrap_or_else(|| current_version.snapshot.tool_selection_policy.clone()); validate_tool_selection_policy(&tool_selection_policy, &bindings)?; self.registry .save_agent_catalog_config(SaveAgentCatalogConfigRequest { workspace_id, agent_id, agent_version: current_version.version, bindings: &bindings, tool_selection_policy: &tool_selection_policy, expected_state, }) .await?; info!( name: "admin.agent.bindings_saved", agent_id = %agent_id.as_str(), version = current_version.version, binding_count = bindings.len(), "agent bindings saved" ); self.get_agent_version(workspace_id, agent_id, current_version.version) .await } async fn ensure_editable_agent_version( &self, workspace_id: &WorkspaceId, agent_id: &AgentId, ) -> Result { let agent = self.get_agent(workspace_id, agent_id).await?; let current = self .get_agent_version(workspace_id, agent_id, agent.current_draft_version) .await?; if agent.latest_published_version != Some(agent.current_draft_version) { return Ok(current); } let now = OffsetDateTime::now_utc(); let draft = AgentVersion { agent_id: agent_id.clone(), version: current.version + 1, status: AgentStatus::Draft, instructions: current.snapshot.instructions.clone(), tool_selection_policy: current.snapshot.tool_selection_policy.clone(), created_at: now, }; self.registry .create_agent_draft_version(CreateAgentDraftVersionRequest { workspace_id, agent_id, version: &draft, bindings: ¤t.bindings, updated_at: &now, }) .await?; self.get_agent_version(workspace_id, agent_id, draft.version) .await } #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), version))] pub async fn publish_agent( &self, workspace_id: &WorkspaceId, agent_id: &AgentId, version: u32, expected_state: Option<&AgentStateExpectation>, ) -> Result { let agent_version = self .get_agent_version(workspace_id, agent_id, version) .await?; validate_tool_selection_policy( &agent_version.snapshot.tool_selection_policy, &agent_version.bindings, )?; if agent_version .bindings .iter() .filter(|binding| binding.enabled) .count() == 0 { return Err(ApiError::conflict_with_context( "agent cannot be published without published enabled tools", json!({ "agent_id": agent_id.as_str(), "version": version, "binding_count": agent_version.bindings.len() }), )); } self.validate_exact_published_agent_bindings(workspace_id, &agent_version.bindings) .await?; let published_at = OffsetDateTime::now_utc(); self.registry .publish_agent(PublishAgentRequest { workspace_id, agent_id, version, published_at: &published_at, published_by: None, expected_state, }) .await?; info!( name: "admin.agent.published", agent_id = %agent_id.as_str(), version, "agent published" ); Ok(PublishAgentResponse { agent_id: agent_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(), published_version: version, published_at: format_timestamp(published_at), }) } async fn validate_exact_published_agent_bindings( &self, workspace_id: &WorkspaceId, bindings: &[AgentOperationBinding], ) -> Result<(), ApiError> { for binding in bindings { let Some(summary) = self .registry .get_operation_summary(workspace_id, &binding.operation_id) .await? else { return Err(ApiError::not_found_with_context( "operation was not found for Agent binding", json!({ "error_code": "agent_binding_scope_denied", "operation_id": binding.operation_id.as_str() }), )); }; if summary.status == crank_core::OperationStatus::Archived { return Err(ApiError::unprocessable_with_context( "archived operation cannot be added to an Agent binding", json!({ "error_code": "agent_binding_archived_operation", "operation_id": binding.operation_id.as_str() }), )); } let Some(version) = self .registry .get_operation_version( workspace_id, &binding.operation_id, binding.operation_version, ) .await? else { return Err(ApiError::unprocessable_with_context( "operation version is not published for Agent binding", json!({ "error_code": "agent_binding_not_published", "operation_id": binding.operation_id.as_str(), "operation_version": binding.operation_version }), )); }; if !version.snapshot.is_published() { return Err(ApiError::unprocessable_with_context( "operation version is not published for Agent binding", json!({ "error_code": "agent_binding_not_published", "operation_id": binding.operation_id.as_str(), "operation_version": binding.operation_version }), )); } } Ok(()) } #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))] pub async fn unpublish_agent( &self, workspace_id: &WorkspaceId, agent_id: &AgentId, expected_state: Option<&AgentStateExpectation>, ) -> Result { self.ensure_workspace_exists(workspace_id).await?; let updated_at = OffsetDateTime::now_utc(); self.registry .unpublish_agent(workspace_id, agent_id, &updated_at, expected_state) .await?; info!( name: "admin.agent.unpublished", agent_id = %agent_id.as_str(), "agent moved to draft" ); Ok(AgentMutationResult { agent_id: agent_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(), updated_at: format_timestamp(updated_at), }) } #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))] pub async fn archive_agent( &self, workspace_id: &WorkspaceId, agent_id: &AgentId, expected_state: Option<&AgentStateExpectation>, ) -> Result { self.ensure_workspace_exists(workspace_id).await?; let updated_at = OffsetDateTime::now_utc(); self.registry .archive_agent(workspace_id, agent_id, &updated_at, expected_state) .await?; info!( name: "admin.agent.archived", agent_id = %agent_id.as_str(), "agent archived" ); Ok(AgentMutationResult { agent_id: agent_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(), updated_at: format_timestamp(updated_at), }) } } fn validate_tool_selection_policy( policy: &ToolSelectionPolicy, bindings: &[AgentOperationBinding], ) -> Result<(), ApiError> { policy .validate_for_tools( bindings .iter() .filter(|binding| binding.enabled) .map(|binding| binding.tool_name.as_str()), ) .map_err(|error| { ApiError::validation_with_context( error.to_string(), json!({"field": "tool_selection_policy"}), ) }) }