feat: connect alpine agents to admin api
This commit is contained in:
+137
-3
@@ -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::<Value>()
|
||||
.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::<Value>()
|
||||
.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::<Value>()
|
||||
.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::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated["agent_id"], agent_id);
|
||||
|
||||
let detail = client
|
||||
.get(format!("{base_url}/agents/{agent_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.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::<Value>()
|
||||
.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;
|
||||
|
||||
@@ -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<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateAgentPayload>,
|
||||
) -> Result<Json<Value>, 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<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, 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<WorkspaceAgentVersionPath>,
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -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<u32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub published_at: Option<String>,
|
||||
pub operation_count: usize,
|
||||
pub operation_ids: Vec<String>,
|
||||
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<Vec<AgentSummary>, ApiError> {
|
||||
) -> Result<Vec<AgentSummaryView>, 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::<BTreeMap<_, _>>();
|
||||
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::<Vec<_>>();
|
||||
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<AgentSummary, ApiError> {
|
||||
self.registry
|
||||
) -> Result<AgentSummaryView, ApiError> {
|
||||
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::<Vec<_>>();
|
||||
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<AgentMutationResult, ApiError> {
|
||||
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<AgentMutationResult, ApiError> {
|
||||
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<OperationAgentRef>) -> BTreeMap<String, Vec<Operatio
|
||||
map
|
||||
}
|
||||
|
||||
fn map_agent_summary_view(summary: AgentSummary) -> 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,
|
||||
|
||||
Reference in New Issue
Block a user