feat: add operations mutation api for alpine ui

This commit is contained in:
a.tolmachev
2026-03-30 00:42:41 +03:00
parent 4721bc1948
commit 23ca2cda59
14 changed files with 907 additions and 56 deletions
+2
View File
@@ -11,6 +11,8 @@ DoD:
- `test-ui` остается нетронутым как fallback - `test-ui` остается нетронутым как fallback
- UI продолжает раздаваться как статический контейнер через текущий deployment path - UI продолжает раздаваться как статический контейнер через текущий deployment path
- legacy React/Vite артефакты удалены из `apps/ui` - legacy React/Vite артефакты удалены из `apps/ui`
- backend для `Operations/Wizard` поддерживает server-side `PATCH`, `DELETE`, `archive`, `category`, `usage_summary`, `agent_refs`
- первые экраны Alpine UI можно сажать на реальные catalog/detail/mutation endpoints без расширения backend-контракта
## Next ## Next
+164 -11
View File
@@ -1,6 +1,6 @@
use axum::{ use axum::{
Router, Router,
routing::{get, post}, routing::{delete, get, post},
}; };
use crate::{ use crate::{
@@ -16,10 +16,10 @@ use crate::{
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs}, observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
operations::{ operations::{
create_operation, create_version, export_operation, generate_draft, get_operation, archive_operation, create_operation, create_version, delete_operation,
get_operation_version, list_grpc_services, list_operations, publish_operation, export_operation, generate_draft, get_operation, get_operation_version,
run_test, upload_descriptor_set, upload_input_json, upload_output_json, list_grpc_services, list_operations, publish_operation, run_test, update_operation,
upload_proto_descriptor, upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor,
}, },
workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace}, workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace},
}, },
@@ -30,7 +30,12 @@ pub fn build_app(state: AppState) -> Router {
let workspace_router = Router::new() let workspace_router = Router::new()
.route("/operations", get(list_operations).post(create_operation)) .route("/operations", get(list_operations).post(create_operation))
.route("/operations/import", post(import_operation)) .route("/operations/import", post(import_operation))
.route("/operations/{operation_id}", get(get_operation)) .route(
"/operations/{operation_id}",
get(get_operation)
.patch(update_operation)
.delete(delete_operation),
)
.route("/operations/{operation_id}/versions", post(create_version)) .route("/operations/{operation_id}/versions", post(create_version))
.route( .route(
"/operations/{operation_id}/versions/{version}", "/operations/{operation_id}/versions/{version}",
@@ -40,6 +45,10 @@ pub fn build_app(state: AppState) -> Router {
"/operations/{operation_id}/publish", "/operations/{operation_id}/publish",
post(publish_operation), post(publish_operation),
) )
.route(
"/operations/{operation_id}/archive",
post(archive_operation),
)
.route("/operations/{operation_id}/test-runs", post(run_test)) .route("/operations/{operation_id}/test-runs", post(run_test))
.route( .route(
"/operations/{operation_id}/samples/input-json", "/operations/{operation_id}/samples/input-json",
@@ -84,10 +93,7 @@ pub fn build_app(state: AppState) -> Router {
"/invitations", "/invitations",
get(list_invitations).post(create_invitation), get(list_invitations).post(create_invitation),
) )
.route( .route("/invitations/{invitation_id}", delete(delete_invitation))
"/invitations/{invitation_id}",
axum::routing::delete(delete_invitation),
)
.route( .route(
"/platform-api-keys", "/platform-api-keys",
get(list_platform_api_keys).post(create_platform_api_key), get(list_platform_api_keys).post(create_platform_api_key),
@@ -98,7 +104,7 @@ pub fn build_app(state: AppState) -> Router {
) )
.route( .route(
"/platform-api-keys/{key_id}", "/platform-api-keys/{key_id}",
axum::routing::delete(delete_platform_api_key), delete(delete_platform_api_key),
) )
.route("/logs", get(list_logs)) .route("/logs", get(list_logs))
.route("/logs/{log_id}", get(get_log)) .route("/logs/{log_id}", get(get_log))
@@ -213,6 +219,150 @@ mod tests {
assert_eq!(test_run["response_preview"]["id"], "lead_123"); assert_eq!(test_run["response_preview"]["id"], "lead_123");
} }
#[tokio::test]
async fn updates_archives_and_deletes_operation() {
let registry = test_registry().await;
let storage_root = test_storage_root("operation_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 created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_mutable_operation",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let listed = client
.get(format!("{base_url}/operations"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(listed["total"], 1);
assert_eq!(listed["items"][0]["category"], "sales");
let updated = client
.patch(format!("{base_url}/operations/{operation_id}"))
.json(&json!({
"display_name": "Create Lead Updated",
"category": "marketing",
"target": {
"kind": "rest",
"base_url": upstream_base_url,
"method": "POST",
"path_template": "/crm/leads",
"static_headers": {}
},
"input_schema": {
"type": "object",
"required": true,
"nullable": false,
"fields": {
"email": {
"type": "string",
"required": true,
"nullable": false
}
}
},
"output_schema": {
"type": "object",
"required": true,
"nullable": false,
"fields": {
"id": {
"type": "string",
"required": true,
"nullable": false
}
}
},
"input_mapping": {
"rules": [
{
"source": "$.mcp.email",
"target": "$.request.body.email",
"required": true
}
]
},
"output_mapping": {
"rules": [
{
"source": "$.response.body.id",
"target": "$.output.id",
"required": true
}
]
},
"execution_config": {
"timeout_ms": 1000,
"headers": {}
},
"tool_description": {
"title": "Create Lead Updated",
"description": "Creates a CRM lead",
"tags": ["crm"]
}
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(updated["status"], "draft");
let detail = client
.get(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(detail["display_name"], "Create Lead Updated");
assert_eq!(detail["category"], "marketing");
let archived = client
.post(format!("{base_url}/operations/{operation_id}/archive"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(archived["status"], "archived");
let deleted = client
.delete(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(deleted["operation_id"], operation_id);
let missing = client
.get(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap();
assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test] #[tokio::test]
async fn creates_binds_and_publishes_agent() { async fn creates_binds_and_publishes_agent() {
let registry = test_registry().await; let registry = test_registry().await;
@@ -984,6 +1134,7 @@ mod tests {
OperationPayload { OperationPayload {
name: name.to_owned(), name: name.to_owned(),
display_name: "Create Lead".to_owned(), display_name: "Create Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest, protocol: Protocol::Rest,
target: Target::Rest(RestTarget { target: Target::Rest(RestTarget {
base_url: base_url.to_owned(), base_url: base_url.to_owned(),
@@ -1035,6 +1186,7 @@ mod tests {
OperationPayload { OperationPayload {
name: name.to_owned(), name: name.to_owned(),
display_name: "Create Lead GraphQL".to_owned(), display_name: "Create Lead GraphQL".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Graphql, protocol: Protocol::Graphql,
target: Target::Graphql(GraphqlTarget { target: Target::Graphql(GraphqlTarget {
endpoint: endpoint.to_owned(), endpoint: endpoint.to_owned(),
@@ -1089,6 +1241,7 @@ mod tests {
OperationPayload { OperationPayload {
name: name.to_owned(), name: name.to_owned(),
display_name: "Unary Echo gRPC".to_owned(), display_name: "Unary Echo gRPC".to_owned(),
category: "support".to_owned(),
protocol: Protocol::Grpc, protocol: Protocol::Grpc,
target: Target::Grpc(GrpcTarget { target: Target::Grpc(GrpcTarget {
server_addr: server_addr.to_owned(), server_addr: server_addr.to_owned(),
+5
View File
@@ -126,6 +126,11 @@ impl From<RegistryError> for ApiError {
} => Self::not_found(format!( } => Self::not_found(format!(
"operation version {version} for {operation_id} was not found" "operation version {version} for {operation_id} was not found"
)), )),
RegistryError::OperationHasPublishedAgentBindings { operation_id } => {
Self::conflict(format!(
"operation {operation_id} cannot be deleted while it is bound to a published agent"
))
}
RegistryError::AuthProfileNotFound { auth_profile_id } => { RegistryError::AuthProfileNotFound { auth_profile_id } => {
Self::not_found(format!("auth profile {auth_profile_id} was not found")) Self::not_found(format!("auth profile {auth_profile_id} was not found"))
} }
+52 -2
View File
@@ -12,7 +12,7 @@ use crate::{
error::ApiError, error::ApiError,
service::{ service::{
ExportQuery, GenerateDraftPayload, ImportQuery, NewVersionPayload, OperationPayload, ExportQuery, GenerateDraftPayload, ImportQuery, NewVersionPayload, OperationPayload,
PublishPayload, TestRunPayload, PublishPayload, TestRunPayload, UpdateOperationPayload,
}, },
state::AppState, state::AppState,
}; };
@@ -43,7 +43,13 @@ pub async fn list_operations(
.service .service
.list_operations(&path.workspace_id.as_str().into()) .list_operations(&path.workspace_id.as_str().into())
.await?; .await?;
Ok(Json(json!({ "items": items }))) let total = items.len();
Ok(Json(json!({
"items": items,
"page": 1,
"page_size": total,
"total": total
})))
} }
pub async fn create_operation( pub async fn create_operation(
@@ -72,6 +78,36 @@ pub async fn get_operation(
Ok(Json(json!(operation))) Ok(Json(json!(operation)))
} }
pub async fn update_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Json(payload): Json<UpdateOperationPayload>,
) -> Result<Json<Value>, ApiError> {
let result = state
.service
.update_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(result)))
}
pub async fn delete_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let result = state
.service
.delete_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
)
.await?;
Ok(Json(json!(result)))
}
pub async fn get_operation_version( pub async fn get_operation_version(
Path(path): Path<WorkspaceOperationVersionPath>, Path(path): Path<WorkspaceOperationVersionPath>,
State(state): State<AppState>, State(state): State<AppState>,
@@ -119,6 +155,20 @@ pub async fn publish_operation(
Ok(Json(json!(published))) Ok(Json(json!(published)))
} }
pub async fn archive_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let archived = state
.service
.archive_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
)
.await?;
Ok(Json(json!(archived)))
}
pub async fn run_test( pub async fn run_test(
Path(path): Path<WorkspaceOperationPath>, Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>, State(state): State<AppState>,
+335 -9
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -15,11 +16,12 @@ use crank_registry::{
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
MembershipRecord, OperationSampleMetadata, OperationSummary, OperationVersionRecord, MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PostgresRegistry,
SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, WorkspaceRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
UsageSummary, UsageTimelinePoint, WorkspaceRecord,
}; };
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation}; use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
use crank_schema::Schema; use crank_schema::Schema;
@@ -43,6 +45,8 @@ pub struct AdminService {
pub struct OperationPayload { pub struct OperationPayload {
pub name: String, pub name: String,
pub display_name: String, pub display_name: String,
#[serde(default = "default_operation_category")]
pub category: String,
pub protocol: Protocol, pub protocol: Protocol,
pub target: Target, pub target: Target,
pub input_schema: Schema, pub input_schema: Schema,
@@ -240,6 +244,7 @@ pub struct CreatedOperationResponse {
pub workspace_id: String, pub workspace_id: String,
pub version: u32, pub version: u32,
pub status: OperationStatus, pub status: OperationStatus,
pub updated_at: String,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@@ -250,6 +255,86 @@ pub struct PublishResponse {
pub published_at: String, pub published_at: String,
} }
#[derive(Clone, Debug, Deserialize)]
pub struct UpdateOperationPayload {
pub display_name: String,
#[serde(default = "default_operation_category")]
pub category: String,
pub target: Target,
pub input_schema: Schema,
pub output_schema: Schema,
pub input_mapping: MappingSet,
pub output_mapping: MappingSet,
pub execution_config: crank_core::ExecutionConfig,
pub tool_description: crank_core::ToolDescription,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperationUsageSummaryView {
pub calls_today: u64,
pub error_rate_pct: f64,
pub avg_latency_ms: u64,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperationAgentRefView {
pub agent_id: String,
pub agent_slug: String,
pub display_name: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperationSummaryView {
pub id: String,
pub workspace_id: String,
pub name: String,
pub display_name: String,
pub category: String,
pub protocol: Protocol,
pub status: OperationStatus,
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 usage_summary: OperationUsageSummaryView,
pub agent_refs: Vec<OperationAgentRefView>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperationDetailView {
pub id: String,
pub workspace_id: String,
pub name: String,
pub display_name: String,
pub category: String,
pub protocol: Protocol,
pub status: OperationStatus,
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 draft_version_ref: VersionRef,
pub published_version_ref: Option<VersionRef>,
pub agent_refs: Vec<OperationAgentRefView>,
}
#[derive(Clone, Debug, Serialize)]
pub struct VersionRef {
pub version: u32,
pub status: OperationStatus,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperationMutationResult {
pub operation_id: String,
pub workspace_id: String,
pub version: u32,
pub status: OperationStatus,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct ImportResponse { pub struct ImportResponse {
pub operation_id: String, pub operation_id: String,
@@ -280,6 +365,10 @@ pub struct GrpcMethodSummary {
pub output_schema: Schema, pub output_schema: Schema,
} }
fn default_operation_category() -> String {
"general".to_owned()
}
impl AdminService { impl AdminService {
pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self { pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self {
Self { Self {
@@ -630,9 +719,37 @@ impl AdminService {
pub async fn list_operations( pub async fn list_operations(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
) -> Result<Vec<OperationSummary>, ApiError> { ) -> Result<Vec<OperationSummaryView>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?; self.ensure_workspace_exists(workspace_id).await?;
Ok(self.registry.list_operations(workspace_id).await?) let summaries = self.registry.list_operations(workspace_id).await?;
let usage = self
.registry
.list_operation_usage_summaries(workspace_id, &today_start_utc()?)
.await?;
let agent_refs = self
.registry
.list_operation_agent_refs(workspace_id)
.await?;
let usage_by_operation = usage_map(usage);
let refs_by_operation = agent_ref_map(agent_refs);
Ok(summaries
.into_iter()
.map(|summary| {
let operation_id = summary.id.as_str().to_owned();
enrich_operation_summary(
summary,
usage_by_operation
.get(&operation_id)
.cloned()
.unwrap_or_else(default_usage_summary),
refs_by_operation
.get(&operation_id)
.cloned()
.unwrap_or_default(),
)
})
.collect())
} }
#[instrument(skip(self))] #[instrument(skip(self))]
@@ -640,12 +757,44 @@ impl AdminService {
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
operation_id: &OperationId, operation_id: &OperationId,
) -> Result<OperationSummary, ApiError> { ) -> Result<OperationDetailView, ApiError> {
self.registry let summary = self
.registry
.get_operation_summary(workspace_id, operation_id) .get_operation_summary(workspace_id, operation_id)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
ApiError::not_found(format!("operation {} was not found", operation_id.as_str())) ApiError::not_found(format!("operation {} was not found", operation_id.as_str()))
})?;
let agent_refs = self
.registry
.list_operation_agent_refs(workspace_id)
.await?;
let refs = agent_ref_map(agent_refs)
.remove(operation_id.as_str())
.unwrap_or_default();
Ok(OperationDetailView {
id: summary.id.as_str().to_owned(),
workspace_id: summary.workspace_id.as_str().to_owned(),
name: summary.name,
display_name: summary.display_name,
category: summary.category,
protocol: summary.protocol,
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,
draft_version_ref: VersionRef {
version: summary.current_draft_version,
status: summary.status,
},
published_version_ref: summary.latest_published_version.map(|version| VersionRef {
version,
status: OperationStatus::Published,
}),
agent_refs: refs,
}) })
} }
@@ -693,6 +842,7 @@ impl AdminService {
id: operation_id.clone(), id: operation_id.clone(),
name: payload.name, name: payload.name,
display_name: payload.display_name, display_name: payload.display_name,
category: payload.category,
protocol: payload.protocol, protocol: payload.protocol,
status: OperationStatus::Draft, status: OperationStatus::Draft,
version: 1, version: 1,
@@ -724,6 +874,7 @@ impl AdminService {
workspace_id: workspace_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(),
version: 1, version: 1,
status: OperationStatus::Draft, status: OperationStatus::Draft,
updated_at: snapshot.updated_at,
}) })
} }
@@ -743,6 +894,7 @@ impl AdminService {
id: operation_id.clone(), id: operation_id.clone(),
name: payload.operation.name, name: payload.operation.name,
display_name: payload.operation.display_name, display_name: payload.operation.display_name,
category: payload.operation.category,
protocol: payload.operation.protocol, protocol: payload.operation.protocol,
status: OperationStatus::Draft, status: OperationStatus::Draft,
version, version,
@@ -779,6 +931,62 @@ impl AdminService {
workspace_id: workspace_id.as_str().to_owned(), workspace_id: workspace_id.as_str().to_owned(),
version, version,
status: OperationStatus::Draft, status: OperationStatus::Draft,
updated_at: snapshot.updated_at,
})
}
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str()))]
pub async fn update_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
payload: UpdateOperationPayload,
) -> Result<OperationMutationResult, ApiError> {
let existing = self
.get_operation_version(
workspace_id,
operation_id,
self.get_operation(workspace_id, operation_id)
.await?
.current_draft_version,
)
.await?;
let updated_at = now_string()?;
let snapshot = RegistryOperation {
id: operation_id.clone(),
name: existing.snapshot.name,
display_name: payload.display_name,
category: payload.category,
protocol: existing.snapshot.protocol,
status: OperationStatus::Draft,
version: existing.version,
target: payload.target,
input_schema: payload.input_schema,
output_schema: payload.output_schema,
input_mapping: payload.input_mapping,
output_mapping: payload.output_mapping,
execution_config: payload.execution_config,
tool_description: payload.tool_description,
samples: existing.snapshot.samples,
generated_draft: existing.snapshot.generated_draft,
config_export: existing.snapshot.config_export,
created_at: existing.snapshot.created_at,
updated_at: updated_at.clone(),
published_at: existing.snapshot.published_at,
};
self.validate_registry_operation(&snapshot)?;
self.registry
.update_operation_draft(workspace_id, &snapshot)
.await?;
Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: snapshot.version,
status: snapshot.status,
updated_at,
}) })
} }
@@ -809,6 +1017,48 @@ impl AdminService {
}) })
} }
#[instrument(skip(self), fields(operation_id = %operation_id.as_str()))]
pub async fn archive_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
) -> Result<OperationMutationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let updated_at = now_string()?;
self.registry
.archive_operation(workspace_id, operation_id, &updated_at)
.await?;
Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: summary.current_draft_version,
status: OperationStatus::Archived,
updated_at,
})
}
#[instrument(skip(self), fields(operation_id = %operation_id.as_str()))]
pub async fn delete_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
) -> Result<OperationMutationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let updated_at = now_string()?;
self.registry
.delete_operation(workspace_id, operation_id)
.await?;
Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: summary.current_draft_version,
status: summary.status,
updated_at,
})
}
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), version = payload.version))] #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), version = payload.version))]
pub async fn run_test( pub async fn run_test(
&self, &self,
@@ -1328,6 +1578,7 @@ impl AdminService {
let payload = OperationPayload { let payload = OperationPayload {
name: document.operation.name.clone(), name: document.operation.name.clone(),
display_name: document.operation.display_name.clone(), display_name: document.operation.display_name.clone(),
category: document.operation.category.clone(),
protocol: document.operation.protocol, protocol: document.operation.protocol,
target: document.operation.target.clone(), target: document.operation.target.clone(),
input_schema: document.operation.input_schema.clone(), input_schema: document.operation.input_schema.clone(),
@@ -1515,6 +1766,13 @@ impl AdminService {
Ok(()) Ok(())
} }
fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> {
validate_protocol_target(operation.protocol, &operation.target)?;
operation.input_mapping.validate_paths()?;
operation.output_mapping.validate_paths()?;
Ok(())
}
async fn find_operation_by_name( async fn find_operation_by_name(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
@@ -1755,6 +2013,74 @@ fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket
)) ))
} }
fn today_start_utc() -> Result<String, ApiError> {
OffsetDateTime::now_utc()
.replace_time(time::Time::MIDNIGHT)
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))
}
fn default_usage_summary() -> OperationUsageSummaryView {
OperationUsageSummaryView {
calls_today: 0,
error_rate_pct: 0.0,
avg_latency_ms: 0,
}
}
fn usage_map(items: Vec<OperationUsageSummary>) -> BTreeMap<String, OperationUsageSummaryView> {
items
.into_iter()
.map(|item| {
(
item.operation_id.as_str().to_owned(),
OperationUsageSummaryView {
calls_today: item.calls_today,
error_rate_pct: item.error_rate_pct,
avg_latency_ms: item.avg_latency_ms,
},
)
})
.collect()
}
fn agent_ref_map(items: Vec<OperationAgentRef>) -> BTreeMap<String, Vec<OperationAgentRefView>> {
let mut map = BTreeMap::<String, Vec<OperationAgentRefView>>::new();
for item in items {
map.entry(item.operation_id.as_str().to_owned())
.or_default()
.push(OperationAgentRefView {
agent_id: item.agent_id.as_str().to_owned(),
agent_slug: item.agent_slug,
display_name: item.display_name,
});
}
map
}
fn enrich_operation_summary(
summary: OperationSummary,
usage_summary: OperationUsageSummaryView,
agent_refs: Vec<OperationAgentRefView>,
) -> OperationSummaryView {
OperationSummaryView {
id: summary.id.as_str().to_owned(),
workspace_id: summary.workspace_id.as_str().to_owned(),
name: summary.name,
display_name: summary.display_name,
category: summary.category,
protocol: summary.protocol,
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,
usage_summary,
agent_refs,
}
}
fn proto_service_summary(service: ProtoService) -> Result<GrpcServiceSummary, ApiError> { fn proto_service_summary(service: ProtoService) -> Result<GrpcServiceSummary, ApiError> {
let methods = service let methods = service
.unary_methods() .unary_methods()
+3
View File
@@ -706,6 +706,7 @@ mod tests {
id: OperationId::new(format!("op_{name}")), id: OperationId::new(format!("op_{name}")),
name: name.to_owned(), name: name.to_owned(),
display_name: "Create Lead".to_owned(), display_name: "Create Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest, protocol: Protocol::Rest,
status: OperationStatus::Published, status: OperationStatus::Published,
version: 1, version: 1,
@@ -766,6 +767,7 @@ mod tests {
id: OperationId::new(format!("op_{name}")), id: OperationId::new(format!("op_{name}")),
name: name.to_owned(), name: name.to_owned(),
display_name: "Create Lead GraphQL".to_owned(), display_name: "Create Lead GraphQL".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Graphql, protocol: Protocol::Graphql,
status: OperationStatus::Published, status: OperationStatus::Published,
version: 1, version: 1,
@@ -829,6 +831,7 @@ mod tests {
id: OperationId::new(format!("op_{name}")), id: OperationId::new(format!("op_{name}")),
name: name.to_owned(), name: name.to_owned(),
display_name: "Unary Echo gRPC".to_owned(), display_name: "Unary Echo gRPC".to_owned(),
category: "support".to_owned(),
protocol: Protocol::Grpc, protocol: Protocol::Grpc,
status: OperationStatus::Published, status: OperationStatus::Published,
version: 1, version: 1,
+8
View File
@@ -8,6 +8,10 @@ use crate::{
protocol::{ExportMode, GraphqlOperationType, HttpMethod, Protocol}, protocol::{ExportMode, GraphqlOperationType, HttpMethod, Protocol},
}; };
fn default_operation_category() -> String {
"general".to_owned()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum OperationStatus { pub enum OperationStatus {
@@ -144,6 +148,8 @@ pub struct Operation<TSchema, TMapping> {
pub id: OperationId, pub id: OperationId,
pub name: String, pub name: String,
pub display_name: String, pub display_name: String,
#[serde(default = "default_operation_category")]
pub category: String,
pub protocol: Protocol, pub protocol: Protocol,
pub status: OperationStatus, pub status: OperationStatus,
pub version: u32, pub version: u32,
@@ -241,6 +247,7 @@ mod tests {
id: OperationId::new("op_01"), id: OperationId::new("op_01"),
name: "crm_create_lead".to_owned(), name: "crm_create_lead".to_owned(),
display_name: "Create Lead".to_owned(), display_name: "Create Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest, protocol: Protocol::Rest,
status: OperationStatus::Draft, status: OperationStatus::Draft,
version: 1, version: 1,
@@ -315,6 +322,7 @@ mod tests {
id: OperationId::new("op_01"), id: OperationId::new("op_01"),
name: "crm_create_lead".to_owned(), name: "crm_create_lead".to_owned(),
display_name: "Create Lead".to_owned(), display_name: "Create Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest, protocol: Protocol::Rest,
status: OperationStatus::Published, status: OperationStatus::Published,
version: 3, version: 3,
+2
View File
@@ -29,6 +29,8 @@ pub enum RegistryError {
OperationNotFound { operation_id: String }, OperationNotFound { operation_id: String },
#[error("operation version {version} for {operation_id} was not found")] #[error("operation version {version} for {operation_id} was not found")]
OperationVersionNotFound { operation_id: String, version: u32 }, OperationVersionNotFound { operation_id: String, version: u32 },
#[error("operation {operation_id} cannot be deleted while it is bound to a published agent")]
OperationHasPublishedAgentBindings { operation_id: String },
#[error("operation {operation_id} must start with version 1, got {version}")] #[error("operation {operation_id} must start with version 1, got {version}")]
InvalidInitialVersion { operation_id: String, version: u32 }, InvalidInitialVersion { operation_id: String, version: u32 },
#[error("operation {operation_id} expected next version {expected}, got {actual}")] #[error("operation {operation_id} expected next version {expected}, got {actual}")]
+7 -6
View File
@@ -9,11 +9,12 @@ pub use model::{
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata,
InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord,
OperationSampleMetadata, OperationSummary, OperationVersionRecord, PlatformApiKeyRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary,
PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation, SampleKind, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest,
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceRecord, YamlImportJob,
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
}; };
pub use postgres::PostgresRegistry; pub use postgres::PostgresRegistry;
+6
View File
@@ -165,6 +165,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
workspace_id text null references workspaces(id) on delete cascade, workspace_id text null references workspaces(id) on delete cascade,
name text not null, name text not null,
display_name text not null, display_name text not null,
category text not null default 'general',
protocol text not null, protocol text not null,
status text not null, status text not null,
current_draft_version integer not null default 1, current_draft_version integer not null default 1,
@@ -180,6 +181,11 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
query("alter table operations add column if not exists workspace_id text null references workspaces(id) on delete cascade") query("alter table operations add column if not exists workspace_id text null references workspaces(id) on delete cascade")
.execute(pool) .execute(pool)
.await?; .await?;
query(
"alter table operations add column if not exists category text not null default 'general'",
)
.execute(pool)
.await?;
query("update operations set workspace_id = 'ws_default' where workspace_id is null") query("update operations set workspace_id = 'ws_default' where workspace_id is null")
.execute(pool) .execute(pool)
.await?; .await?;
+17
View File
@@ -155,6 +155,7 @@ pub struct OperationSummary {
pub workspace_id: WorkspaceId, pub workspace_id: WorkspaceId,
pub name: String, pub name: String,
pub display_name: String, pub display_name: String,
pub category: String,
pub protocol: Protocol, pub protocol: Protocol,
pub status: OperationStatus, pub status: OperationStatus,
pub current_draft_version: u32, pub current_draft_version: u32,
@@ -164,6 +165,22 @@ pub struct OperationSummary {
pub published_at: Option<String>, pub published_at: Option<String>,
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OperationUsageSummary {
pub operation_id: OperationId,
pub calls_today: u64,
pub error_rate_pct: f64,
pub avg_latency_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationAgentRef {
pub operation_id: OperationId,
pub agent_id: AgentId,
pub agent_slug: String,
pub display_name: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OperationVersionRecord { pub struct OperationVersionRecord {
pub operation_id: OperationId, pub operation_id: OperationId,
+289 -17
View File
@@ -18,13 +18,14 @@ use crate::{
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord,
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationSampleMetadata, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
OperationSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, RegistryOperation, SaveAgentBindingsRequest, SaveAuthProfileRequest,
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest,
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceRecord, YamlImportJob, UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, UsageTimelinePoint, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
YamlImportJobId, YamlImportJobStatus,
}, },
}; };
@@ -1084,6 +1085,7 @@ impl PostgresRegistry {
o.id, o.id,
o.name, o.name,
o.display_name, o.display_name,
o.category,
o.protocol, o.protocol,
to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at,
to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at,
@@ -1192,6 +1194,7 @@ impl PostgresRegistry {
workspace_id, workspace_id,
name, name,
display_name, display_name,
category,
protocol, protocol,
status, status,
current_draft_version, current_draft_version,
@@ -1200,16 +1203,17 @@ impl PostgresRegistry {
updated_at, updated_at,
published_at published_at
) values ( ) values (
$1, $2, $3, $4, $5, $6, $7, $8, $1, $2, $3, $4, $5, $6, $7, $8, $9,
$9::timestamptz,
$10::timestamptz, $10::timestamptz,
$11::timestamptz $11::timestamptz,
$12::timestamptz
)", )",
) )
.bind(snapshot.id.as_str()) .bind(snapshot.id.as_str())
.bind(workspace_id.as_str()) .bind(workspace_id.as_str())
.bind(&snapshot.name) .bind(&snapshot.name)
.bind(&snapshot.display_name) .bind(&snapshot.display_name)
.bind(&snapshot.category)
.bind(serialize_enum_text(&snapshot.protocol, "protocol")?) .bind(serialize_enum_text(&snapshot.protocol, "protocol")?)
.bind(serialize_enum_text(&snapshot.status, "status")?) .bind(serialize_enum_text(&snapshot.status, "status")?)
.bind(to_db_version(snapshot.version)) .bind(to_db_version(snapshot.version))
@@ -1283,6 +1287,203 @@ impl PostgresRegistry {
Ok(()) Ok(())
} }
pub async fn update_operation_draft(
&self,
workspace_id: &WorkspaceId,
snapshot: &RegistryOperation,
) -> Result<(), RegistryError> {
let Some(summary) = self
.get_operation_summary(workspace_id, &snapshot.id)
.await?
else {
return Err(RegistryError::OperationNotFound {
operation_id: snapshot.id.as_str().to_owned(),
});
};
if snapshot.version != summary.current_draft_version {
return Err(RegistryError::InvalidVersionSequence {
operation_id: snapshot.id.as_str().to_owned(),
expected: summary.current_draft_version,
actual: snapshot.version,
});
}
assert_immutable_fields(&summary, snapshot)?;
let mut tx = self.pool.begin().await?;
sqlx::query(
"update operations
set display_name = $1,
category = $2,
status = $3,
updated_at = $4::timestamptz
where workspace_id = $5 and id = $6",
)
.bind(&snapshot.display_name)
.bind(&snapshot.category)
.bind(serialize_enum_text(&snapshot.status, "status")?)
.bind(&snapshot.updated_at)
.bind(workspace_id.as_str())
.bind(snapshot.id.as_str())
.execute(&mut *tx)
.await?;
sqlx::query(
"update operation_versions
set status = $1,
target_json = $2,
input_schema_json = $3,
output_schema_json = $4,
input_mapping_json = $5,
output_mapping_json = $6,
execution_config_json = $7,
tool_description_json = $8,
samples_json = $9,
generated_draft_json = $10,
config_export_json = $11
where operation_id = $12 and version = $13",
)
.bind(serialize_enum_text(&snapshot.status, "status")?)
.bind(Json(serialize_json_value(&snapshot.target)?))
.bind(Json(serialize_json_value(&snapshot.input_schema)?))
.bind(Json(serialize_json_value(&snapshot.output_schema)?))
.bind(Json(serialize_json_value(&snapshot.input_mapping)?))
.bind(Json(serialize_json_value(&snapshot.output_mapping)?))
.bind(Json(serialize_json_value(&snapshot.execution_config)?))
.bind(Json(serialize_json_value(&snapshot.tool_description)?))
.bind(Json(
snapshot
.samples
.clone()
.map(|value| serialize_json_value(&value))
.transpose()?,
))
.bind(Json(
snapshot
.generated_draft
.clone()
.map(|value| serialize_json_value(&value))
.transpose()?,
))
.bind(Json(
snapshot
.config_export
.clone()
.map(|value| serialize_json_value(&value))
.transpose()?,
))
.bind(snapshot.id.as_str())
.bind(to_db_version(snapshot.version))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn archive_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
archived_at: &str,
) -> Result<(), RegistryError> {
let Some(summary) = self
.get_operation_summary(workspace_id, operation_id)
.await?
else {
return Err(RegistryError::OperationNotFound {
operation_id: operation_id.as_str().to_owned(),
});
};
let mut tx = self.pool.begin().await?;
sqlx::query(
"update operations
set status = $1,
updated_at = $2::timestamptz
where workspace_id = $3 and id = $4",
)
.bind(serialize_enum_text(&OperationStatus::Archived, "status")?)
.bind(archived_at)
.bind(workspace_id.as_str())
.bind(operation_id.as_str())
.execute(&mut *tx)
.await?;
sqlx::query(
"update operation_versions
set status = $1
where operation_id = $2 and version = $3",
)
.bind(serialize_enum_text(&OperationStatus::Archived, "status")?)
.bind(operation_id.as_str())
.bind(to_db_version(summary.current_draft_version))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn delete_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
) -> Result<(), RegistryError> {
if self
.has_published_agent_bindings_for_operation(workspace_id, operation_id)
.await?
{
return Err(RegistryError::OperationHasPublishedAgentBindings {
operation_id: operation_id.as_str().to_owned(),
});
}
let deleted = sqlx::query(
"delete from operations
where workspace_id = $1 and id = $2",
)
.bind(workspace_id.as_str())
.bind(operation_id.as_str())
.execute(&self.pool)
.await?
.rows_affected();
if deleted == 0 {
return Err(RegistryError::OperationNotFound {
operation_id: operation_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn has_published_agent_bindings_for_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
) -> Result<bool, RegistryError> {
let row = sqlx::query(
"select 1
from agents a
join published_agents pa on pa.agent_id = a.id
join agent_operation_bindings b
on b.agent_id = a.id and b.agent_version = pa.version
where a.workspace_id = $1
and b.operation_id = $2
limit 1",
)
.bind(workspace_id.as_str())
.bind(operation_id.as_str())
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
pub async fn list_operations( pub async fn list_operations(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
@@ -1293,6 +1494,7 @@ impl PostgresRegistry {
workspace_id, workspace_id,
name, name,
display_name, display_name,
category,
protocol, protocol,
status, status,
current_draft_version, current_draft_version,
@@ -1311,6 +1513,57 @@ impl PostgresRegistry {
rows.iter().map(map_operation_summary).collect() rows.iter().map(map_operation_summary).collect()
} }
pub async fn list_operation_usage_summaries(
&self,
workspace_id: &WorkspaceId,
created_after: &str,
) -> Result<Vec<OperationUsageSummary>, RegistryError> {
let rows = sqlx::query(
"select
operation_id,
count(*)::bigint as calls_today,
coalesce(
round((sum(case when status = 'error' then 1 else 0 end)::numeric / nullif(count(*), 0)) * 100, 2),
0
)::float8 as error_rate_pct,
coalesce(round(avg(duration_ms))::bigint, 0) as avg_latency_ms
from invocation_logs
where workspace_id = $1
and created_at >= $2::timestamptz
group by operation_id",
)
.bind(workspace_id.as_str())
.bind(created_after)
.fetch_all(&self.pool)
.await?;
rows.iter().map(map_operation_usage_summary).collect()
}
pub async fn list_operation_agent_refs(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<OperationAgentRef>, RegistryError> {
let rows = sqlx::query(
"select
b.operation_id,
a.id as agent_id,
a.slug as agent_slug,
a.display_name
from agents a
join published_agents pa on pa.agent_id = a.id
join agent_operation_bindings b
on b.agent_id = a.id and b.agent_version = pa.version
where a.workspace_id = $1
order by a.display_name asc, b.tool_name asc",
)
.bind(workspace_id.as_str())
.fetch_all(&self.pool)
.await?;
rows.iter().map(map_operation_agent_ref).collect()
}
pub async fn get_operation_summary( pub async fn get_operation_summary(
&self, &self,
workspace_id: &WorkspaceId, workspace_id: &WorkspaceId,
@@ -1322,6 +1575,7 @@ impl PostgresRegistry {
workspace_id, workspace_id,
name, name,
display_name, display_name,
category,
protocol, protocol,
status, status,
current_draft_version, current_draft_version,
@@ -1352,6 +1606,7 @@ impl PostgresRegistry {
o.workspace_id, o.workspace_id,
o.name, o.name,
o.display_name, o.display_name,
o.category,
o.protocol, o.protocol,
to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at,
to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at,
@@ -1395,6 +1650,7 @@ impl PostgresRegistry {
o.workspace_id, o.workspace_id,
o.name, o.name,
o.display_name, o.display_name,
o.category,
o.protocol, o.protocol,
to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at,
to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at,
@@ -1504,6 +1760,7 @@ impl PostgresRegistry {
o.workspace_id, o.workspace_id,
o.name, o.name,
o.display_name, o.display_name,
o.category,
o.protocol, o.protocol,
to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at,
to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at,
@@ -1545,6 +1802,7 @@ impl PostgresRegistry {
o.workspace_id, o.workspace_id,
o.name, o.name,
o.display_name, o.display_name,
o.category,
o.protocol, o.protocol,
to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at,
to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at,
@@ -2058,13 +2316,6 @@ fn assert_immutable_fields(
}); });
} }
if summary.display_name != snapshot.display_name {
return Err(RegistryError::ImmutableOperationFieldChanged {
operation_id: snapshot.id.as_str().to_owned(),
field: "display_name",
});
}
if summary.protocol != snapshot.protocol { if summary.protocol != snapshot.protocol {
return Err(RegistryError::ImmutableOperationFieldChanged { return Err(RegistryError::ImmutableOperationFieldChanged {
operation_id: snapshot.id.as_str().to_owned(), operation_id: snapshot.id.as_str().to_owned(),
@@ -2201,6 +2452,7 @@ fn map_operation_summary(row: &PgRow) -> Result<OperationSummary, RegistryError>
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?), workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
name: row.try_get("name")?, name: row.try_get("name")?,
display_name: row.try_get("display_name")?, display_name: row.try_get("display_name")?,
category: row.try_get("category")?,
protocol: deserialize_enum_text(&row.try_get::<String, _>("protocol")?, "protocol")?, protocol: deserialize_enum_text(&row.try_get::<String, _>("protocol")?, "protocol")?,
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?, status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
current_draft_version: from_db_version( current_draft_version: from_db_version(
@@ -2217,6 +2469,24 @@ fn map_operation_summary(row: &PgRow) -> Result<OperationSummary, RegistryError>
}) })
} }
fn map_operation_usage_summary(row: &PgRow) -> Result<OperationUsageSummary, RegistryError> {
Ok(OperationUsageSummary {
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
calls_today: to_u64(row.try_get::<i64, _>("calls_today")?, "calls_today")?,
error_rate_pct: row.try_get("error_rate_pct")?,
avg_latency_ms: to_u64(row.try_get::<i64, _>("avg_latency_ms")?, "avg_latency_ms")?,
})
}
fn map_operation_agent_ref(row: &PgRow) -> Result<OperationAgentRef, RegistryError> {
Ok(OperationAgentRef {
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
agent_id: AgentId::new(row.try_get::<String, _>("agent_id")?),
agent_slug: row.try_get("agent_slug")?,
display_name: row.try_get("display_name")?,
})
}
fn map_operation_version_record(row: &PgRow) -> Result<OperationVersionRecord, RegistryError> { fn map_operation_version_record(row: &PgRow) -> Result<OperationVersionRecord, RegistryError> {
let operation_id = OperationId::new(row.try_get::<String, _>("id")?); let operation_id = OperationId::new(row.try_get::<String, _>("id")?);
let version = from_db_version(row.try_get("version")?, "version")?; let version = from_db_version(row.try_get("version")?, "version")?;
@@ -2234,6 +2504,7 @@ fn map_operation_version_record(row: &PgRow) -> Result<OperationVersionRecord, R
id: operation_id, id: operation_id,
name: row.try_get("name")?, name: row.try_get("name")?,
display_name: row.try_get("display_name")?, display_name: row.try_get("display_name")?,
category: row.try_get("category")?,
protocol: deserialize_enum_text(&row.try_get::<String, _>("protocol")?, "protocol")?, protocol: deserialize_enum_text(&row.try_get::<String, _>("protocol")?, "protocol")?,
status, status,
version, version,
@@ -2809,6 +3080,7 @@ mod tests {
id: OperationId::new(id), id: OperationId::new(id),
name: format!("{id}_tool"), name: format!("{id}_tool"),
display_name: format!("Display {id}"), display_name: format!("Display {id}"),
category: "general".to_owned(),
protocol: Protocol::Rest, protocol: Protocol::Rest,
status, status,
version, version,
+3
View File
@@ -444,6 +444,7 @@ mod tests {
id: OperationId::new("op_rest_runtime"), id: OperationId::new("op_rest_runtime"),
name: "crm_create_lead".to_owned(), name: "crm_create_lead".to_owned(),
display_name: "Create Lead".to_owned(), display_name: "Create Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest, protocol: Protocol::Rest,
status: OperationStatus::Published, status: OperationStatus::Published,
version: 1, version: 1,
@@ -505,6 +506,7 @@ mod tests {
id: OperationId::new("op_graphql_runtime"), id: OperationId::new("op_graphql_runtime"),
name: "crm_create_lead_graphql".to_owned(), name: "crm_create_lead_graphql".to_owned(),
display_name: "Create Lead GraphQL".to_owned(), display_name: "Create Lead GraphQL".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Graphql, protocol: Protocol::Graphql,
status: OperationStatus::Published, status: OperationStatus::Published,
version: 1, version: 1,
@@ -579,6 +581,7 @@ mod tests {
id: OperationId::new("op_grpc_runtime"), id: OperationId::new("op_grpc_runtime"),
name: "echo_unary_grpc".to_owned(), name: "echo_unary_grpc".to_owned(),
display_name: "Unary Echo gRPC".to_owned(), display_name: "Unary Echo gRPC".to_owned(),
category: "support".to_owned(),
protocol: Protocol::Grpc, protocol: Protocol::Grpc,
status: OperationStatus::Published, status: OperationStatus::Published,
version: 1, version: 1,
+13 -10
View File
@@ -52,13 +52,15 @@ UI-файлы:
- `GET /api/admin/workspaces/{workspace_id}/operations` - `GET /api/admin/workspaces/{workspace_id}/operations`
- `GET /api/admin/workspaces/{workspace_id}/agents` - `GET /api/admin/workspaces/{workspace_id}/agents`
- `GET /api/admin/workspaces/{workspace_id}/usage` - `GET /api/admin/workspaces/{workspace_id}/usage`
Что еще не хватает:
- `PATCH /api/admin/workspaces/{workspace_id}/operations/{operation_id}` - `PATCH /api/admin/workspaces/{workspace_id}/operations/{operation_id}`
- `DELETE /api/admin/workspaces/{workspace_id}/operations/{operation_id}` - `DELETE /api/admin/workspaces/{workspace_id}/operations/{operation_id}`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive`
- server-side shape для list response с готовыми agent bindings и компактными summary fields
Что еще не хватает:
- frontend data adapter, который заменит mock merge на реальные `items/page/page_size/total`;
- wiring server-side filters `protocol/category/agent/status/search`;
- переключение карточек верхних метрик с seeded summary на реальный usage payload.
Что убрать из UI после интеграции: Что убрать из UI после интеграции:
@@ -69,8 +71,8 @@ UI-файлы:
Простой итог: Простой итог:
- эту страницу можно интегрировать первой; - эту страницу можно интегрировать первой;
- базовые list/fetch данные backend уже умеет; - backend уже отдает server-side category, usage summary и agent refs;
- надо только добить update/delete/archive и перестать хранить изменения в браузере. - дальше нужно убрать `localStorage`-состояние и посадить UI на реальные list/detail/mutation вызовы.
### 4.2. Wizard ### 4.2. Wizard
@@ -96,8 +98,10 @@ UI-файлы:
- `POST /api/admin/workspaces/{workspace_id}/operations` - `POST /api/admin/workspaces/{workspace_id}/operations`
- `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}` - `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}`
- `PATCH /api/admin/workspaces/{workspace_id}/operations/{operation_id}`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions`
- `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions/{version}` - `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions/{version}`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/test-runs` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/test-runs`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/input-json` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/input-json`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/output-json` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/output-json`
@@ -110,9 +114,8 @@ UI-файлы:
Что еще не хватает: Что еще не хватает:
- `PATCH /api/admin/workspaces/{workspace_id}/operations/{operation_id}` для edit mode - frontend adapter поверх `OperationDetail` и `OperationVersionDocument`, чтобы wizard перестал хранить edit-state в браузере;
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive` - финальное выравнивание step payload c canonical DTO, чтобы create/edit/export/import использовали одну модель на фронте.
- единая DTO-форма для create/edit, чтобы UI не собирал разные payload вручную по шагам
Что убрать из UI после интеграции: Что убрать из UI после интеграции:
@@ -124,7 +127,7 @@ UI-файлы:
- wizard почти готов для реального backend; - wizard почти готов для реального backend;
- это основной экран второй очереди после catalog; - это основной экран второй очереди после catalog;
- ключевой разрыв сейчас только в полноценном edit/update контракте. - backend уже закрывает create/edit/archive/test/import/export, дальше нужен только аккуратный frontend adapter.
### 4.3. Agents ### 4.3. Agents