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
+8
View File
@@ -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<TSchema, TMapping> {
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,
+2
View File
@@ -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}")]
+7 -6
View File
@@ -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;
+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,
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?;
+17
View File
@@ -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<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)]
pub struct OperationVersionRecord {
pub operation_id: OperationId,
+289 -17
View File
@@ -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<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(
&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<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(
&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<OperationSummary, RegistryError>
workspace_id: WorkspaceId::new(row.try_get::<String, _>("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::<String, _>("protocol")?, "protocol")?,
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
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> {
let operation_id = OperationId::new(row.try_get::<String, _>("id")?);
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,
name: row.try_get("name")?,
display_name: row.try_get("display_name")?,
category: row.try_get("category")?,
protocol: deserialize_enum_text(&row.try_get::<String, _>("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,
+3
View File
@@ -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,