feat: connect alpine agents to admin api

This commit is contained in:
a.tolmachev
2026-03-30 01:31:07 +03:00
parent 85395b10f8
commit e8a4a1138b
9 changed files with 807 additions and 220 deletions
+7 -11
View File
@@ -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
+137 -3
View File
@@ -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;
+31 -1
View File
@@ -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>,
+219 -5
View File
@@ -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,
+9 -3
View File
@@ -15,6 +15,7 @@
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
<script src="js/api.js"></script>
</head>
<body x-data="agents">
@@ -160,8 +161,13 @@
<span>Loading agents…</span>
</div>
<div class="empty-state" x-show="!loading && loadError">
<div class="empty-state-title">Failed to load agents</div>
<div class="empty-state-sub" x-text="loadError"></div>
</div>
<!-- Empty state: no agents at all -->
<div class="empty-state" x-show="!loading && agents.length === 0">
<div class="empty-state" x-show="!loading && !loadError && agents.length === 0">
<div class="empty-state-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.35"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 17.5h7M17.5 14v7"/></svg>
</div>
@@ -174,13 +180,13 @@
</div>
<!-- Empty state: search returned nothing -->
<div class="empty-state" x-show="!loading && agents.length > 0 && filteredAgents.length === 0">
<div class="empty-state" x-show="!loading && !loadError && agents.length > 0 && filteredAgents.length === 0">
<div class="empty-state-title">No agents match "<span x-text="agentSearch"></span>"</div>
<div class="empty-state-sub">Try a different search term.</div>
</div>
<!-- Agents grid -->
<div class="agents-grid" x-show="!loading && filteredAgents.length > 0">
<div class="agents-grid" x-show="!loading && !loadError && filteredAgents.length > 0">
<template x-for="agent in filteredAgents" :key="agent.id">
<div class="agent-card">
+323 -188
View File
@@ -1,208 +1,343 @@
document.addEventListener('alpine:init', () => {
Alpine.data('agents', () => ({
agents: [],
operations: [],
loading: true,
function agentUiStatus(status) {
if (status === 'published') return 'active';
if (status === 'archived') return 'inactive';
return status || 'draft';
}
agentSearch: '',
openDropdown: null,
function mapAgent(agent) {
return {
id: agent.id,
slug: agent.slug,
display_name: agent.display_name,
description: agent.description || '',
status: agentUiStatus(agent.status),
raw_status: agent.status,
operation_count: agent.operation_count || 0,
operation_ids: agent.operation_ids || [],
key_count: agent.key_count || 0,
calls_today: agent.calls_today || 0,
created_at: agent.created_at,
current_draft_version: agent.current_draft_version || 1,
latest_published_version: agent.latest_published_version,
mcp_endpoint: agent.mcp_endpoint || '',
};
}
// drawer state
drawerOpen: false,
drawerMode: 'create', // 'create' | 'edit'
editingId: null,
function mapOperation(operation) {
return {
id: operation.id,
name: operation.name,
display_name: operation.display_name,
protocol: operation.protocol,
status: operation.status,
current_draft_version: operation.current_draft_version || 1,
latest_published_version: operation.latest_published_version,
};
}
// form fields
form: {
display_name: '',
slug: '',
description: '',
status: 'active',
selectedOps: [],
},
document.addEventListener('alpine:init', function() {
Alpine.data('agents', function() {
return {
agents: [],
operations: [],
loading: true,
loadError: '',
workspaceId: null,
// operations picker
opSearch: '',
slugManuallyEdited: false,
agentSearch: '',
openDropdown: null,
drawerOpen: false,
drawerMode: 'create',
editingId: null,
saving: false,
async init() {
const [agentsRes, opsRes] = await Promise.all([
fetch((window.DATA_URL||'data/')+'agents.json'),
fetch((window.DATA_URL||'data/')+'operations.json'),
]);
this.agents = await agentsRes.json();
this.operations = await opsRes.json();
this.loading = false;
form: {
display_name: '',
slug: '',
description: '',
status: 'active',
selectedOps: [],
},
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.drawerOpen) this.closeDrawer();
});
opSearch: '',
slugManuallyEdited: false,
document.addEventListener('click', () => { this.openDropdown = null; });
},
async init() {
var self = this;
// ── computed ──
get filteredAgents() {
const q = this.agentSearch.toLowerCase().trim();
if (!q) return this.agents;
return this.agents.filter(a =>
a.display_name.toLowerCase().includes(q) ||
a.slug.toLowerCase().includes(q) ||
(a.description || '').toLowerCase().includes(q)
);
},
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
this.workspaceId = workspace ? workspace.id : null;
await this.reload();
get totalCalls() {
return this.agents.reduce((s, a) => s + (a.calls_today || 0), 0);
},
get activeCount() {
return this.agents.filter(a => a.status === 'active').length;
},
get totalOpsExposed() {
return this.agents.reduce((s, a) => s + (a.operation_count || 0), 0);
},
get filteredOps() {
const q = this.opSearch.toLowerCase();
if (!q) return this.operations;
return this.operations.filter(op =>
op.name.toLowerCase().includes(q) ||
op.display_name.toLowerCase().includes(q)
);
},
// ── drawer ──
openCreate() {
this.drawerMode = 'create';
this.editingId = null;
this.form = { display_name: '', slug: '', description: '', status: 'active', selectedOps: [] };
this.opSearch = '';
this.slugManuallyEdited = false;
this.drawerOpen = true;
},
openEdit(agent) {
this.drawerMode = 'edit';
this.editingId = agent.id;
this.form = {
display_name: agent.display_name,
slug: agent.slug,
description: agent.description,
status: agent.status,
selectedOps: [...(agent.operation_ids || [])],
};
this.opSearch = '';
this.slugManuallyEdited = true;
this.drawerOpen = true;
},
closeDrawer() {
this.drawerOpen = false;
},
// ── slug auto-derive ──
onNameInput(val) {
this.form.display_name = val;
if (!this.slugManuallyEdited) {
this.form.slug = val.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
}
},
onSlugInput(val) {
this.form.slug = val.toLowerCase().replace(/[^a-z0-9-]/g, '');
this.slugManuallyEdited = true;
},
// ── operations picker ──
toggleOp(opId) {
const idx = this.form.selectedOps.indexOf(opId);
if (idx === -1) this.form.selectedOps.push(opId);
else this.form.selectedOps.splice(idx, 1);
},
isOpSelected(opId) {
return this.form.selectedOps.includes(opId);
},
// ── save ──
saveAgent() {
if (!this.form.display_name.trim() || !this.form.slug.trim()) return;
const ws = (localStorage.getItem('crank_workspace') || 'acme-workspace');
const endpoint = '/mcp/v1/' + ws + '/' + this.form.slug;
if (this.drawerMode === 'create') {
this.agents.unshift({
id: 'ag-' + Date.now(),
slug: this.form.slug,
display_name: this.form.display_name,
description: this.form.description,
status: this.form.status,
operation_count: this.form.selectedOps.length,
operation_ids: [...this.form.selectedOps],
key_count: 0,
created_at: new Date().toISOString(),
created_by: 'AT',
last_called_at: null,
calls_today: 0,
mcp_endpoint: endpoint,
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && self.drawerOpen) {
self.closeDrawer();
}
});
} else {
const idx = this.agents.findIndex(a => a.id === this.editingId);
if (idx !== -1) {
this.agents[idx] = Object.assign({}, this.agents[idx], {
display_name: this.form.display_name,
slug: this.form.slug,
description: this.form.description,
status: this.form.status,
operation_count: this.form.selectedOps.length,
operation_ids: [...this.form.selectedOps],
mcp_endpoint: endpoint,
});
document.addEventListener('click', function() {
self.openDropdown = null;
});
window.addEventListener('crank:workspacechange', async function(event) {
self.workspaceId = event.detail ? event.detail.id : null;
await self.reload();
});
},
async reload() {
this.loading = true;
this.loadError = '';
if (!this.workspaceId || !window.CrankApi) {
this.agents = [];
this.operations = [];
this.loading = false;
this.loadError = 'Workspace or API is unavailable';
return;
}
}
this.closeDrawer();
},
// ── delete ──
deleteAgent(id) {
this.agents = this.agents.filter(a => a.id !== id);
},
try {
var responses = await Promise.all([
window.CrankApi.listAgents(this.workspaceId),
window.CrankApi.listOperations(this.workspaceId),
]);
this.agents = ((responses[0] && responses[0].items) || []).map(mapAgent);
this.operations = ((responses[1] && responses[1].items) || []).map(mapOperation);
} catch (error) {
this.agents = [];
this.operations = [];
this.loadError = error.message || 'Failed to load agents';
}
// ── helpers ──
mcpEndpoint(agent) {
if (agent.mcp_endpoint) return agent.mcp_endpoint;
const ws = localStorage.getItem('crank_workspace') || 'acme-workspace';
return '/mcp/v1/' + ws + '/' + agent.slug;
},
this.loading = false;
},
copyEndpoint(agent) {
navigator.clipboard.writeText(this.mcpEndpoint(agent)).catch(() => {});
},
get filteredAgents() {
var query = this.agentSearch.toLowerCase().trim();
if (!query) return this.agents;
return this.agents.filter(function(agent) {
return agent.display_name.toLowerCase().includes(query)
|| agent.slug.toLowerCase().includes(query)
|| (agent.description || '').toLowerCase().includes(query);
});
},
statusClass(status) {
return 'agent-status-badge agent-status-' + status;
},
get totalCalls() {
return this.agents.reduce(function(sum, agent) {
return sum + (agent.calls_today || 0);
}, 0);
},
protocolBadge(op) {
if (op.protocol === 'rest') return 'badge badge-rest';
if (op.protocol === 'graphql') return 'badge badge-graphql';
return 'badge badge-grpc';
},
get activeCount() {
return this.agents.filter(function(agent) {
return agent.status === 'active';
}).length;
},
protocolLabel(op) {
if (op.protocol === 'rest') return 'REST';
if (op.protocol === 'graphql') return 'GQL';
return 'gRPC';
},
get totalOpsExposed() {
return this.agents.reduce(function(sum, agent) {
return sum + (agent.operation_count || 0);
}, 0);
},
formatDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
},
get filteredOps() {
var query = this.opSearch.toLowerCase().trim();
if (!query) return this.operations;
return this.operations.filter(function(operation) {
return operation.name.toLowerCase().includes(query)
|| operation.display_name.toLowerCase().includes(query);
});
},
formatCalls(n) {
if (!n) return '0';
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
},
}));
openCreate() {
this.drawerMode = 'create';
this.editingId = null;
this.form = {
display_name: '',
slug: '',
description: '',
status: 'active',
selectedOps: [],
};
this.opSearch = '';
this.slugManuallyEdited = false;
this.drawerOpen = true;
},
openEdit(agent) {
this.drawerMode = 'edit';
this.editingId = agent.id;
this.form = {
display_name: agent.display_name,
slug: agent.slug,
description: agent.description,
status: agent.status === 'active' ? 'active' : 'draft',
selectedOps: [].concat(agent.operation_ids || []),
};
this.opSearch = '';
this.slugManuallyEdited = true;
this.drawerOpen = true;
},
closeDrawer() {
this.drawerOpen = false;
this.saving = false;
},
onNameInput(value) {
this.form.display_name = value;
if (!this.slugManuallyEdited) {
this.form.slug = value
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '');
}
},
onSlugInput(value) {
this.form.slug = value.toLowerCase().replace(/[^a-z0-9-]/g, '');
this.slugManuallyEdited = true;
},
toggleOp(operationId) {
var index = this.form.selectedOps.indexOf(operationId);
if (index === -1) {
this.form.selectedOps.push(operationId);
} else {
this.form.selectedOps.splice(index, 1);
}
},
isOpSelected(operationId) {
return this.form.selectedOps.includes(operationId);
},
async saveAgent() {
var self = this;
if (
this.saving
|| !this.workspaceId
|| !this.form.display_name.trim()
|| !this.form.slug.trim()
) {
return;
}
this.saving = true;
try {
var agentId = this.editingId;
var currentVersion = 1;
if (this.drawerMode === 'create') {
var created = await window.CrankApi.createAgent(this.workspaceId, {
slug: this.form.slug,
display_name: this.form.display_name,
description: this.form.description,
instructions: {},
tool_selection_policy: {},
});
agentId = created.agent_id;
currentVersion = created.version || 1;
} else {
await window.CrankApi.updateAgent(this.workspaceId, this.editingId, {
slug: this.form.slug,
display_name: this.form.display_name,
description: this.form.description,
});
var agent = await window.CrankApi.getAgent(this.workspaceId, this.editingId);
currentVersion = agent.current_draft_version || 1;
}
await window.CrankApi.saveAgentBindings(
this.workspaceId,
agentId,
this.form.selectedOps.map(function(operationId) {
var operation = self.operations.find(function(item) {
return item.id === operationId;
});
return {
operation_id: operationId,
operation_version: operation && operation.latest_published_version
? operation.latest_published_version
: operation && operation.current_draft_version
? operation.current_draft_version
: 1,
tool_name: operation ? operation.name : operationId,
tool_title: operation ? (operation.display_name || operation.name) : operationId,
tool_description_override: null,
enabled: true,
};
}),
);
if (this.form.status === 'active') {
await window.CrankApi.publishAgent(this.workspaceId, agentId, {
version: currentVersion,
});
}
await this.reload();
this.closeDrawer();
} catch (error) {
alert(error.message || 'Failed to save agent');
this.saving = false;
}
},
async deleteAgent(id) {
if (!confirm('Delete this agent? This cannot be undone.')) return;
try {
await window.CrankApi.deleteAgent(this.workspaceId, id);
await this.reload();
} catch (error) {
alert(error.message || 'Failed to delete agent');
}
},
mcpEndpoint(agent) {
if (agent.mcp_endpoint) return agent.mcp_endpoint;
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
var workspaceSlug = workspace ? workspace.slug : 'default';
return '/mcp/v1/' + workspaceSlug + '/' + agent.slug;
},
copyEndpoint(agent) {
navigator.clipboard.writeText(this.mcpEndpoint(agent)).catch(function() {});
},
statusClass(status) {
return 'agent-status-badge agent-status-' + status;
},
protocolBadge(operation) {
if (operation.protocol === 'rest') return 'badge badge-rest';
if (operation.protocol === 'graphql') return 'badge badge-graphql';
return 'badge badge-grpc';
},
protocolLabel(operation) {
if (operation.protocol === 'rest') return 'REST';
if (operation.protocol === 'graphql') return 'GQL';
return 'gRPC';
},
formatDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
},
formatCalls(value) {
if (!value) return '0';
return value >= 1000 ? (value / 1000).toFixed(1) + 'k' : String(value);
},
};
});
});
+21
View File
@@ -110,5 +110,26 @@
fileName
);
},
listAgents: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents');
},
createAgent: function(workspaceId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents', payload);
},
getAgent: function(workspaceId, agentId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId));
},
updateAgent: function(workspaceId, agentId, payload) {
return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId), payload);
},
deleteAgent: function(workspaceId, agentId) {
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId));
},
saveAgentBindings: function(workspaceId, agentId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/bindings', payload);
},
publishAgent: function(workspaceId, agentId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload);
},
};
}());
+55
View File
@@ -1002,6 +1002,61 @@ impl PostgresRegistry {
Ok(())
}
pub async fn update_agent_summary(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
slug: &str,
display_name: &str,
description: &str,
updated_at: &str,
) -> Result<(), RegistryError> {
let result = sqlx::query(
"update agents
set slug = $3,
display_name = $4,
description = $5,
updated_at = $6::timestamptz
where workspace_id = $1 and id = $2",
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.bind(slug)
.bind(display_name)
.bind(description)
.bind(updated_at)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::AgentNotFound {
agent_id: agent_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn delete_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<(), RegistryError> {
let result = sqlx::query("delete from agents where workspace_id = $1 and id = $2")
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(RegistryError::AgentNotFound {
agent_id: agent_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn publish_agent(
&self,
request: PublishAgentRequest<'_>,
+5 -9
View File
@@ -152,21 +152,16 @@ UI-файлы:
- `GET /api/admin/workspaces/{workspace_id}/agents`
- `POST /api/admin/workspaces/{workspace_id}/agents`
- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}`
- `PATCH /api/admin/workspaces/{workspace_id}/agents/{agent_id}`
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/publish`
- `GET /api/admin/workspaces/{workspace_id}/usage`
Что еще не хватает:
- `PATCH /api/admin/workspaces/{workspace_id}/agents/{agent_id}`
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/versions`
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings/{operation_id}`
- более богатый `GET /agents` response:
- `calls_today`
- `key_count`
- `operation_count`
- `mcp_endpoint`
Отдельный конфликт:
@@ -176,8 +171,9 @@ UI-файлы:
Простой итог:
- база для agents уже есть;
- но UI требует более полного lifecycle, чем backend поддерживает сейчас.
- базовый live flow уже закрыт: list/create/edit/delete/bind/publish работают через backend;
- `GET /agents` уже отдает `calls_today`, `key_count`, `operation_count`, `operation_ids` и `mcp_endpoint`;
- следующий реальный разрыв здесь уже не в CRUD, а в version lifecycle и явном unpublish/archive flow для агентов.
### 4.4. API Keys