diff --git a/TASKS.md b/TASKS.md index 959ed3d..643552d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -11,6 +11,8 @@ DoD: - `test-ui` остается нетронутым как fallback - UI продолжает раздаваться как статический контейнер через текущий deployment path - 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 diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index ebf8a7f..d80adf7 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -1,6 +1,6 @@ use axum::{ Router, - routing::{get, post}, + routing::{delete, get, post}, }; use crate::{ @@ -16,10 +16,10 @@ use crate::{ auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs}, operations::{ - create_operation, create_version, export_operation, generate_draft, get_operation, - get_operation_version, list_grpc_services, list_operations, publish_operation, - run_test, upload_descriptor_set, upload_input_json, upload_output_json, - upload_proto_descriptor, + archive_operation, create_operation, create_version, delete_operation, + export_operation, generate_draft, get_operation, get_operation_version, + list_grpc_services, list_operations, publish_operation, run_test, update_operation, + upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor, }, 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() .route("/operations", get(list_operations).post(create_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/{version}", @@ -40,6 +45,10 @@ pub fn build_app(state: AppState) -> Router { "/operations/{operation_id}/publish", post(publish_operation), ) + .route( + "/operations/{operation_id}/archive", + post(archive_operation), + ) .route("/operations/{operation_id}/test-runs", post(run_test)) .route( "/operations/{operation_id}/samples/input-json", @@ -84,10 +93,7 @@ pub fn build_app(state: AppState) -> Router { "/invitations", get(list_invitations).post(create_invitation), ) - .route( - "/invitations/{invitation_id}", - axum::routing::delete(delete_invitation), - ) + .route("/invitations/{invitation_id}", delete(delete_invitation)) .route( "/platform-api-keys", get(list_platform_api_keys).post(create_platform_api_key), @@ -98,7 +104,7 @@ pub fn build_app(state: AppState) -> Router { ) .route( "/platform-api-keys/{key_id}", - axum::routing::delete(delete_platform_api_key), + delete(delete_platform_api_key), ) .route("/logs", get(list_logs)) .route("/logs/{log_id}", get(get_log)) @@ -213,6 +219,150 @@ mod tests { 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::() + .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::() + .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::() + .await + .unwrap(); + assert_eq!(updated["status"], "draft"); + + let detail = client + .get(format!("{base_url}/operations/{operation_id}")) + .send() + .await + .unwrap() + .json::() + .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::() + .await + .unwrap(); + assert_eq!(archived["status"], "archived"); + + let deleted = client + .delete(format!("{base_url}/operations/{operation_id}")) + .send() + .await + .unwrap() + .json::() + .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] async fn creates_binds_and_publishes_agent() { let registry = test_registry().await; @@ -984,6 +1134,7 @@ mod tests { OperationPayload { name: name.to_owned(), display_name: "Create Lead".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Rest, target: Target::Rest(RestTarget { base_url: base_url.to_owned(), @@ -1035,6 +1186,7 @@ mod tests { OperationPayload { name: name.to_owned(), display_name: "Create Lead GraphQL".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Graphql, target: Target::Graphql(GraphqlTarget { endpoint: endpoint.to_owned(), @@ -1089,6 +1241,7 @@ mod tests { OperationPayload { name: name.to_owned(), display_name: "Unary Echo gRPC".to_owned(), + category: "support".to_owned(), protocol: Protocol::Grpc, target: Target::Grpc(GrpcTarget { server_addr: server_addr.to_owned(), diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index b962ea4..0ee380e 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -126,6 +126,11 @@ impl From for ApiError { } => Self::not_found(format!( "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 } => { Self::not_found(format!("auth profile {auth_profile_id} was not found")) } diff --git a/apps/admin-api/src/routes/operations.rs b/apps/admin-api/src/routes/operations.rs index 59aa591..6820ffb 100644 --- a/apps/admin-api/src/routes/operations.rs +++ b/apps/admin-api/src/routes/operations.rs @@ -12,7 +12,7 @@ use crate::{ error::ApiError, service::{ ExportQuery, GenerateDraftPayload, ImportQuery, NewVersionPayload, OperationPayload, - PublishPayload, TestRunPayload, + PublishPayload, TestRunPayload, UpdateOperationPayload, }, state::AppState, }; @@ -43,7 +43,13 @@ pub async fn list_operations( .service .list_operations(&path.workspace_id.as_str().into()) .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( @@ -72,6 +78,36 @@ pub async fn get_operation( Ok(Json(json!(operation))) } +pub async fn update_operation( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, 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, + State(state): State, +) -> Result, 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( Path(path): Path, State(state): State, @@ -119,6 +155,20 @@ pub async fn publish_operation( Ok(Json(json!(published))) } +pub async fn archive_operation( + Path(path): Path, + State(state): State, +) -> Result, 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( Path(path): Path, State(state): State, diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index ecdbe27..2f75082 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::PathBuf; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; @@ -15,11 +16,12 @@ use crank_registry::{ AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, - MembershipRecord, OperationSampleMetadata, OperationSummary, OperationVersionRecord, - PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, - SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, - SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, - UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, WorkspaceRecord, + MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, + OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PostgresRegistry, + PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, SaveAgentBindingsRequest, + SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, + UsageSummary, UsageTimelinePoint, WorkspaceRecord, }; use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation}; use crank_schema::Schema; @@ -43,6 +45,8 @@ pub struct AdminService { pub struct OperationPayload { pub name: String, pub display_name: String, + #[serde(default = "default_operation_category")] + pub category: String, pub protocol: Protocol, pub target: Target, pub input_schema: Schema, @@ -240,6 +244,7 @@ pub struct CreatedOperationResponse { pub workspace_id: String, pub version: u32, pub status: OperationStatus, + pub updated_at: String, } #[derive(Clone, Debug, Serialize)] @@ -250,6 +255,86 @@ pub struct PublishResponse { 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, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, + pub usage_summary: OperationUsageSummaryView, + pub agent_refs: Vec, +} + +#[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, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, + pub draft_version_ref: VersionRef, + pub published_version_ref: Option, + pub agent_refs: Vec, +} + +#[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)] pub struct ImportResponse { pub operation_id: String, @@ -280,6 +365,10 @@ pub struct GrpcMethodSummary { pub output_schema: Schema, } +fn default_operation_category() -> String { + "general".to_owned() +} + impl AdminService { pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self { Self { @@ -630,9 +719,37 @@ impl AdminService { pub async fn list_operations( &self, workspace_id: &WorkspaceId, - ) -> Result, ApiError> { + ) -> Result, ApiError> { 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))] @@ -640,13 +757,45 @@ impl AdminService { &self, workspace_id: &WorkspaceId, operation_id: &OperationId, - ) -> Result { - self.registry + ) -> Result { + let summary = self + .registry .get_operation_summary(workspace_id, operation_id) .await? .ok_or_else(|| { 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, + }) } #[instrument(skip(self))] @@ -693,6 +842,7 @@ impl AdminService { id: operation_id.clone(), name: payload.name, display_name: payload.display_name, + category: payload.category, protocol: payload.protocol, status: OperationStatus::Draft, version: 1, @@ -724,6 +874,7 @@ impl AdminService { workspace_id: workspace_id.as_str().to_owned(), version: 1, status: OperationStatus::Draft, + updated_at: snapshot.updated_at, }) } @@ -743,6 +894,7 @@ impl AdminService { id: operation_id.clone(), name: payload.operation.name, display_name: payload.operation.display_name, + category: payload.operation.category, protocol: payload.operation.protocol, status: OperationStatus::Draft, version, @@ -779,6 +931,62 @@ impl AdminService { workspace_id: workspace_id.as_str().to_owned(), version, 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 { + 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 { + 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 { + 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))] pub async fn run_test( &self, @@ -1328,6 +1578,7 @@ impl AdminService { let payload = OperationPayload { name: document.operation.name.clone(), display_name: document.operation.display_name.clone(), + category: document.operation.category.clone(), protocol: document.operation.protocol, target: document.operation.target.clone(), input_schema: document.operation.input_schema.clone(), @@ -1515,6 +1766,13 @@ impl AdminService { 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( &self, workspace_id: &WorkspaceId, @@ -1755,6 +2013,74 @@ fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket )) } +fn today_start_utc() -> Result { + 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) -> BTreeMap { + 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) -> BTreeMap> { + let mut map = BTreeMap::>::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, +) -> 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 { let methods = service .unary_methods() diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 91db26e..f66de57 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -706,6 +706,7 @@ mod tests { id: OperationId::new(format!("op_{name}")), name: name.to_owned(), display_name: "Create Lead".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Rest, status: OperationStatus::Published, version: 1, @@ -766,6 +767,7 @@ mod tests { id: OperationId::new(format!("op_{name}")), name: name.to_owned(), display_name: "Create Lead GraphQL".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Graphql, status: OperationStatus::Published, version: 1, @@ -829,6 +831,7 @@ mod tests { id: OperationId::new(format!("op_{name}")), name: name.to_owned(), display_name: "Unary Echo gRPC".to_owned(), + category: "support".to_owned(), protocol: Protocol::Grpc, status: OperationStatus::Published, version: 1, diff --git a/crates/crank-core/src/operation.rs b/crates/crank-core/src/operation.rs index 48d9f39..99c42a3 100644 --- a/crates/crank-core/src/operation.rs +++ b/crates/crank-core/src/operation.rs @@ -8,6 +8,10 @@ use crate::{ protocol::{ExportMode, GraphqlOperationType, HttpMethod, Protocol}, }; +fn default_operation_category() -> String { + "general".to_owned() +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OperationStatus { @@ -144,6 +148,8 @@ pub struct Operation { pub id: OperationId, pub name: String, pub display_name: String, + #[serde(default = "default_operation_category")] + pub category: String, pub protocol: Protocol, pub status: OperationStatus, pub version: u32, @@ -241,6 +247,7 @@ mod tests { id: OperationId::new("op_01"), name: "crm_create_lead".to_owned(), display_name: "Create Lead".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Rest, status: OperationStatus::Draft, version: 1, @@ -315,6 +322,7 @@ mod tests { id: OperationId::new("op_01"), name: "crm_create_lead".to_owned(), display_name: "Create Lead".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Rest, status: OperationStatus::Published, version: 3, diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index cb5d1e6..f407c4c 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -29,6 +29,8 @@ pub enum RegistryError { OperationNotFound { operation_id: String }, #[error("operation version {version} for {operation_id} was not found")] 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}")] InvalidInitialVersion { operation_id: String, version: u32 }, #[error("operation {operation_id} expected next version {expected}, got {actual}")] diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index d5e2cfa..99278fd 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -9,11 +9,12 @@ pub use model::{ CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, - OperationSampleMetadata, OperationSummary, OperationVersionRecord, PlatformApiKeyRecord, - PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation, SampleKind, - SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, - SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, - UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, - WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, + OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary, + OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, + PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest, + SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, + UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceRecord, YamlImportJob, + YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, }; pub use postgres::PostgresRegistry; diff --git a/crates/crank-registry/src/migrations.rs b/crates/crank-registry/src/migrations.rs index 355beaf..12089e2 100644 --- a/crates/crank-registry/src/migrations.rs +++ b/crates/crank-registry/src/migrations.rs @@ -165,6 +165,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { workspace_id text null references workspaces(id) on delete cascade, name text not null, display_name text not null, + category text not null default 'general', protocol text not null, status text not null, 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") .execute(pool) .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") .execute(pool) .await?; diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index c159ffd..311db22 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -155,6 +155,7 @@ pub struct OperationSummary { pub workspace_id: WorkspaceId, pub name: String, pub display_name: String, + pub category: String, pub protocol: Protocol, pub status: OperationStatus, pub current_draft_version: u32, @@ -164,6 +165,22 @@ pub struct OperationSummary { pub published_at: Option, } +#[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)] pub struct OperationVersionRecord { pub operation_id: OperationId, diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index fe3820b..f6b3ed8 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -18,13 +18,14 @@ use crate::{ AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord, - InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationSampleMetadata, - OperationSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, - PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, - UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceRecord, YamlImportJob, - YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, + InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, + OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, + PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool, + RegistryOperation, SaveAgentBindingsRequest, SaveAuthProfileRequest, + SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest, + UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, + UsageTimelinePoint, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, + YamlImportJobId, YamlImportJobStatus, }, }; @@ -1084,6 +1085,7 @@ impl PostgresRegistry { o.id, o.name, o.display_name, + o.category, 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.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, name, display_name, + category, protocol, status, current_draft_version, @@ -1200,16 +1203,17 @@ impl PostgresRegistry { updated_at, published_at ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, - $9::timestamptz, + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz, - $11::timestamptz + $11::timestamptz, + $12::timestamptz )", ) .bind(snapshot.id.as_str()) .bind(workspace_id.as_str()) .bind(&snapshot.name) .bind(&snapshot.display_name) + .bind(&snapshot.category) .bind(serialize_enum_text(&snapshot.protocol, "protocol")?) .bind(serialize_enum_text(&snapshot.status, "status")?) .bind(to_db_version(snapshot.version)) @@ -1283,6 +1287,203 @@ impl PostgresRegistry { 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 { + 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( &self, workspace_id: &WorkspaceId, @@ -1293,6 +1494,7 @@ impl PostgresRegistry { workspace_id, name, display_name, + category, protocol, status, current_draft_version, @@ -1311,6 +1513,57 @@ impl PostgresRegistry { rows.iter().map(map_operation_summary).collect() } + pub async fn list_operation_usage_summaries( + &self, + workspace_id: &WorkspaceId, + created_after: &str, + ) -> Result, 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, 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( &self, workspace_id: &WorkspaceId, @@ -1322,6 +1575,7 @@ impl PostgresRegistry { workspace_id, name, display_name, + category, protocol, status, current_draft_version, @@ -1352,6 +1606,7 @@ impl PostgresRegistry { o.workspace_id, o.name, o.display_name, + o.category, 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.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.name, o.display_name, + o.category, 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.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.name, o.display_name, + o.category, 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.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.name, o.display_name, + o.category, 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.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 { return Err(RegistryError::ImmutableOperationFieldChanged { operation_id: snapshot.id.as_str().to_owned(), @@ -2201,6 +2452,7 @@ fn map_operation_summary(row: &PgRow) -> Result workspace_id: WorkspaceId::new(row.try_get::("workspace_id")?), name: row.try_get("name")?, display_name: row.try_get("display_name")?, + category: row.try_get("category")?, protocol: deserialize_enum_text(&row.try_get::("protocol")?, "protocol")?, status: deserialize_enum_text(&row.try_get::("status")?, "status")?, current_draft_version: from_db_version( @@ -2217,6 +2469,24 @@ fn map_operation_summary(row: &PgRow) -> Result }) } +fn map_operation_usage_summary(row: &PgRow) -> Result { + Ok(OperationUsageSummary { + operation_id: OperationId::new(row.try_get::("operation_id")?), + calls_today: to_u64(row.try_get::("calls_today")?, "calls_today")?, + error_rate_pct: row.try_get("error_rate_pct")?, + avg_latency_ms: to_u64(row.try_get::("avg_latency_ms")?, "avg_latency_ms")?, + }) +} + +fn map_operation_agent_ref(row: &PgRow) -> Result { + Ok(OperationAgentRef { + operation_id: OperationId::new(row.try_get::("operation_id")?), + agent_id: AgentId::new(row.try_get::("agent_id")?), + agent_slug: row.try_get("agent_slug")?, + display_name: row.try_get("display_name")?, + }) +} + fn map_operation_version_record(row: &PgRow) -> Result { let operation_id = OperationId::new(row.try_get::("id")?); let version = from_db_version(row.try_get("version")?, "version")?; @@ -2234,6 +2504,7 @@ fn map_operation_version_record(row: &PgRow) -> Result("protocol")?, "protocol")?, status, version, @@ -2809,6 +3080,7 @@ mod tests { id: OperationId::new(id), name: format!("{id}_tool"), display_name: format!("Display {id}"), + category: "general".to_owned(), protocol: Protocol::Rest, status, version, diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index 34642c1..08ffbff 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -444,6 +444,7 @@ mod tests { id: OperationId::new("op_rest_runtime"), name: "crm_create_lead".to_owned(), display_name: "Create Lead".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Rest, status: OperationStatus::Published, version: 1, @@ -505,6 +506,7 @@ mod tests { id: OperationId::new("op_graphql_runtime"), name: "crm_create_lead_graphql".to_owned(), display_name: "Create Lead GraphQL".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Graphql, status: OperationStatus::Published, version: 1, @@ -579,6 +581,7 @@ mod tests { id: OperationId::new("op_grpc_runtime"), name: "echo_unary_grpc".to_owned(), display_name: "Unary Echo gRPC".to_owned(), + category: "support".to_owned(), protocol: Protocol::Grpc, status: OperationStatus::Published, version: 1, diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md index e94bfe8..87cf0f3 100644 --- a/docs/alpine-ui-integration-plan.md +++ b/docs/alpine-ui-integration-plan.md @@ -52,13 +52,15 @@ UI-файлы: - `GET /api/admin/workspaces/{workspace_id}/operations` - `GET /api/admin/workspaces/{workspace_id}/agents` - `GET /api/admin/workspaces/{workspace_id}/usage` - -Что еще не хватает: - - `PATCH /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` -- 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 после интеграции: @@ -69,8 +71,8 @@ UI-файлы: Простой итог: - эту страницу можно интегрировать первой; -- базовые list/fetch данные backend уже умеет; -- надо только добить update/delete/archive и перестать хранить изменения в браузере. +- backend уже отдает server-side category, usage summary и agent refs; +- дальше нужно убрать `localStorage`-состояние и посадить UI на реальные list/detail/mutation вызовы. ### 4.2. Wizard @@ -96,8 +98,10 @@ UI-файлы: - `POST /api/admin/workspaces/{workspace_id}/operations` - `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` - `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}/samples/input-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 -- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive` -- единая DTO-форма для create/edit, чтобы UI не собирал разные payload вручную по шагам +- frontend adapter поверх `OperationDetail` и `OperationVersionDocument`, чтобы wizard перестал хранить edit-state в браузере; +- финальное выравнивание step payload c canonical DTO, чтобы create/edit/export/import использовали одну модель на фронте. Что убрать из UI после интеграции: @@ -124,7 +127,7 @@ UI-файлы: - wizard почти готов для реального backend; - это основной экран второй очереди после catalog; -- ключевой разрыв сейчас только в полноценном edit/update контракте. +- backend уже закрывает create/edit/archive/test/import/export, дальше нужен только аккуратный frontend adapter. ### 4.3. Agents