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
+336 -10
View File
@@ -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<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)]
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<Vec<OperationSummary>, ApiError> {
) -> Result<Vec<OperationSummaryView>, 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<OperationSummary, ApiError> {
self.registry
) -> Result<OperationDetailView, ApiError> {
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<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))]
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<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> {
let methods = service
.unary_methods()