999 lines
36 KiB
Rust
999 lines
36 KiB
Rust
use super::*;
|
|
|
|
impl PostgresRegistry {
|
|
pub async fn verify_operation_state(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
operation_id: &OperationId,
|
|
expected_state: &OperationStateExpectation,
|
|
) -> Result<(), RegistryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
let row = sqlx::query(
|
|
"select status, current_draft_version, latest_published_version
|
|
from operations where workspace_id = $1 and id = $2 for update",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.bind(operation_id.as_str())
|
|
.fetch_optional(&mut *tx)
|
|
.await?
|
|
.ok_or_else(|| RegistryError::OperationNotFound {
|
|
operation_id: operation_id.as_str().to_owned(),
|
|
})?;
|
|
verify_expected_state(&row, operation_id.as_str(), Some(expected_state))?;
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn create_operation(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
snapshot: &RegistryOperation,
|
|
created_by: Option<&str>,
|
|
) -> Result<(), RegistryError> {
|
|
if snapshot.version != 1 {
|
|
return Err(RegistryError::InvalidInitialVersion {
|
|
operation_id: snapshot.id.as_str().to_owned(),
|
|
version: snapshot.version,
|
|
});
|
|
}
|
|
|
|
if self
|
|
.get_operation_summary(workspace_id, &snapshot.id)
|
|
.await?
|
|
.is_some()
|
|
{
|
|
return Err(RegistryError::OperationAlreadyExists {
|
|
operation_id: snapshot.id.as_str().to_owned(),
|
|
});
|
|
}
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
if let Err(error) = insert_operation_rows(&mut tx, workspace_id, snapshot, created_by).await
|
|
{
|
|
if matches_operation_name_conflict(&error) {
|
|
return Err(RegistryError::OperationAlreadyExists {
|
|
operation_id: snapshot.id.as_str().to_owned(),
|
|
});
|
|
}
|
|
return Err(error);
|
|
}
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn create_version(
|
|
&self,
|
|
request: CreateVersionRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
self.create_version_inner(request, None).await
|
|
}
|
|
|
|
pub async fn create_version_cas(
|
|
&self,
|
|
request: CreateVersionRequest<'_>,
|
|
expected_state: &OperationStateExpectation,
|
|
) -> Result<(), RegistryError> {
|
|
self.create_version_inner(request, Some(expected_state))
|
|
.await
|
|
}
|
|
|
|
async fn create_version_inner(
|
|
&self,
|
|
request: CreateVersionRequest<'_>,
|
|
expected_state: Option<&OperationStateExpectation>,
|
|
) -> Result<(), RegistryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
let row = sqlx::query(
|
|
"select name, protocol, current_draft_version, status, latest_published_version
|
|
from operations
|
|
where workspace_id = $1 and id = $2
|
|
for update",
|
|
)
|
|
.bind(request.workspace_id.as_str())
|
|
.bind(request.snapshot.id.as_str())
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
let Some(row) = row else {
|
|
return Err(RegistryError::OperationNotFound {
|
|
operation_id: request.snapshot.id.as_str().to_owned(),
|
|
});
|
|
};
|
|
let status: OperationStatus =
|
|
deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?;
|
|
verify_expected_state(&row, request.snapshot.id.as_str(), expected_state)?;
|
|
if status == OperationStatus::Archived {
|
|
return Err(RegistryError::OperationArchived {
|
|
operation_id: request.snapshot.id.as_str().to_owned(),
|
|
});
|
|
}
|
|
if request.snapshot.status != OperationStatus::Draft {
|
|
return Err(RegistryError::InvalidOperationTransition {
|
|
operation_id: request.snapshot.id.as_str().to_owned(),
|
|
from: format!("{:?}", request.snapshot.status).to_ascii_lowercase(),
|
|
action: "create_version",
|
|
});
|
|
}
|
|
if row.try_get::<String, _>("name")? != request.snapshot.name {
|
|
return Err(RegistryError::ImmutableOperationFieldChanged {
|
|
operation_id: request.snapshot.id.as_str().to_owned(),
|
|
field: "name",
|
|
});
|
|
}
|
|
let protocol: crank_core::Protocol =
|
|
deserialize_enum_text(&row.try_get::<String, _>("protocol")?, "protocol")?;
|
|
if protocol != request.snapshot.protocol {
|
|
return Err(RegistryError::ImmutableOperationFieldChanged {
|
|
operation_id: request.snapshot.id.as_str().to_owned(),
|
|
field: "protocol",
|
|
});
|
|
}
|
|
let current = from_db_version(
|
|
row.try_get::<i32, _>("current_draft_version")?,
|
|
"current_draft_version",
|
|
)?;
|
|
let expected = current
|
|
.checked_add(1)
|
|
.filter(|value| i32::try_from(*value).is_ok())
|
|
.ok_or_else(|| RegistryError::InvalidVersionSequence {
|
|
operation_id: request.snapshot.id.as_str().to_owned(),
|
|
expected: current,
|
|
actual: request.snapshot.version,
|
|
})?;
|
|
if request.snapshot.version != expected {
|
|
return Err(RegistryError::OperationStaleVersion {
|
|
operation_id: request.snapshot.id.as_str().to_owned(),
|
|
expected,
|
|
actual: request.snapshot.version,
|
|
});
|
|
}
|
|
|
|
insert_version_row(
|
|
&mut tx,
|
|
request.snapshot,
|
|
request.change_note,
|
|
request.created_by,
|
|
)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
"update operations
|
|
set status = $1,
|
|
current_draft_version = $2,
|
|
updated_at = $3::timestamptz
|
|
where id = $4 and workspace_id = $5",
|
|
)
|
|
.bind(serialize_enum_text(&request.snapshot.status, "status")?)
|
|
.bind(to_db_version(request.snapshot.version))
|
|
.bind(request.snapshot.updated_at)
|
|
.bind(request.snapshot.id.as_str())
|
|
.bind(request.workspace_id.as_str())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn update_operation_draft(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
snapshot: &RegistryOperation,
|
|
) -> Result<(), RegistryError> {
|
|
self.update_operation_draft_inner(workspace_id, snapshot, None)
|
|
.await
|
|
}
|
|
|
|
pub async fn update_operation_draft_cas(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
snapshot: &RegistryOperation,
|
|
expected_state: &OperationStateExpectation,
|
|
) -> Result<(), RegistryError> {
|
|
self.update_operation_draft_inner(workspace_id, snapshot, Some(expected_state))
|
|
.await
|
|
}
|
|
|
|
async fn update_operation_draft_inner(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
snapshot: &RegistryOperation,
|
|
expected_state: Option<&OperationStateExpectation>,
|
|
) -> Result<(), RegistryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
let locked = sqlx::query(
|
|
"select current_draft_version, status, latest_published_version
|
|
from operations
|
|
where workspace_id = $1 and id = $2
|
|
for update",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.bind(snapshot.id.as_str())
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
let Some(locked) = locked else {
|
|
return Err(RegistryError::OperationNotFound {
|
|
operation_id: snapshot.id.as_str().to_owned(),
|
|
});
|
|
};
|
|
verify_expected_state(&locked, snapshot.id.as_str(), expected_state)?;
|
|
let current = from_db_version(
|
|
locked.try_get::<i32, _>("current_draft_version")?,
|
|
"current_draft_version",
|
|
)?;
|
|
let aggregate_status: OperationStatus =
|
|
deserialize_enum_text(&locked.try_get::<String, _>("status")?, "status")?;
|
|
if aggregate_status == OperationStatus::Archived {
|
|
return Err(RegistryError::OperationArchived {
|
|
operation_id: snapshot.id.as_str().to_owned(),
|
|
});
|
|
}
|
|
if snapshot.version != current {
|
|
return Err(RegistryError::OperationStaleVersion {
|
|
operation_id: snapshot.id.as_str().to_owned(),
|
|
expected: current,
|
|
actual: snapshot.version,
|
|
});
|
|
}
|
|
|
|
let mut next = snapshot.clone();
|
|
next.version = current
|
|
.checked_add(1)
|
|
.filter(|value| i32::try_from(*value).is_ok())
|
|
.ok_or_else(|| RegistryError::InvalidVersionSequence {
|
|
operation_id: snapshot.id.as_str().to_owned(),
|
|
expected: current,
|
|
actual: current,
|
|
})?;
|
|
next.status = OperationStatus::Draft;
|
|
next.published_at = None;
|
|
insert_version_row(&mut tx, &next, Some("saved draft"), None).await?;
|
|
|
|
sqlx::query(
|
|
"update operations
|
|
set display_name = $1,
|
|
category = $2,
|
|
security_level = $3,
|
|
status = 'draft',
|
|
current_draft_version = $4,
|
|
updated_at = $5::timestamptz
|
|
where workspace_id = $6 and id = $7",
|
|
)
|
|
.bind(&next.display_name)
|
|
.bind(&next.category)
|
|
.bind(serialize_enum_text(&next.security_level, "security_level")?)
|
|
.bind(to_db_version(next.version))
|
|
.bind(next.updated_at)
|
|
.bind(workspace_id.as_str())
|
|
.bind(next.id.as_str())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn archive_operation(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
operation_id: &OperationId,
|
|
archived_at: &time::OffsetDateTime,
|
|
) -> Result<(), RegistryError> {
|
|
self.archive_operation_inner(workspace_id, operation_id, archived_at, None)
|
|
.await
|
|
}
|
|
|
|
pub async fn archive_operation_cas(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
operation_id: &OperationId,
|
|
archived_at: &time::OffsetDateTime,
|
|
expected_state: &OperationStateExpectation,
|
|
) -> Result<(), RegistryError> {
|
|
self.archive_operation_inner(
|
|
workspace_id,
|
|
operation_id,
|
|
archived_at,
|
|
Some(expected_state),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn archive_operation_inner(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
operation_id: &OperationId,
|
|
archived_at: &time::OffsetDateTime,
|
|
expected_state: Option<&OperationStateExpectation>,
|
|
) -> Result<(), RegistryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
let status = sqlx::query(
|
|
"select status, current_draft_version, latest_published_version from operations
|
|
where workspace_id = $1 and id = $2
|
|
for update",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.bind(operation_id.as_str())
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
let Some(status) = status else {
|
|
return Err(RegistryError::OperationNotFound {
|
|
operation_id: operation_id.as_str().to_owned(),
|
|
});
|
|
};
|
|
verify_expected_state(&status, operation_id.as_str(), expected_state)?;
|
|
let status: OperationStatus =
|
|
deserialize_enum_text(&status.try_get::<String, _>("status")?, "status")?;
|
|
if status == OperationStatus::Archived {
|
|
tx.commit().await?;
|
|
return Ok(());
|
|
}
|
|
|
|
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?;
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_operation(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
operation_id: &OperationId,
|
|
) -> Result<(), RegistryError> {
|
|
self.delete_operation_inner(workspace_id, operation_id, None)
|
|
.await
|
|
}
|
|
|
|
pub async fn delete_operation_cas(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
operation_id: &OperationId,
|
|
expected_state: &OperationStateExpectation,
|
|
) -> Result<(), RegistryError> {
|
|
self.delete_operation_inner(workspace_id, operation_id, Some(expected_state))
|
|
.await
|
|
}
|
|
|
|
async fn delete_operation_inner(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
operation_id: &OperationId,
|
|
expected_state: Option<&OperationStateExpectation>,
|
|
) -> Result<(), RegistryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
let row = sqlx::query(
|
|
"select status, current_draft_version, latest_published_version
|
|
from operations
|
|
where workspace_id = $1 and id = $2
|
|
for update",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.bind(operation_id.as_str())
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
let Some(row) = row else {
|
|
return Err(RegistryError::OperationNotFound {
|
|
operation_id: operation_id.as_str().to_owned(),
|
|
});
|
|
};
|
|
verify_expected_state(&row, operation_id.as_str(), expected_state)?;
|
|
let status: OperationStatus =
|
|
deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?;
|
|
let ever_published = row
|
|
.try_get::<Option<i32>, _>("latest_published_version")?
|
|
.is_some();
|
|
let durable_reference = sqlx::query_scalar::<_, bool>(
|
|
"select exists (
|
|
select 1 from agent_operation_bindings where operation_id = $1
|
|
union all select 1 from approval_requests where operation_id = $1
|
|
union all select 1 from invocation_logs where operation_id = $1
|
|
union all select 1 from operation_samples where operation_id = $1
|
|
union all select 1 from descriptors where operation_id = $1
|
|
)",
|
|
)
|
|
.bind(operation_id.as_str())
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
if status == OperationStatus::Archived || ever_published || durable_reference {
|
|
return Err(RegistryError::OperationDeleteForbidden {
|
|
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(&mut *tx)
|
|
.await?
|
|
.rows_affected();
|
|
|
|
if deleted == 0 {
|
|
return Err(RegistryError::OperationNotFound {
|
|
operation_id: operation_id.as_str().to_owned(),
|
|
});
|
|
}
|
|
|
|
tx.commit().await?;
|
|
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 as \"present!\"
|
|
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",
|
|
workspace_id.as_str(),
|
|
operation_id.as_str(),
|
|
)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
Ok(row.is_some())
|
|
}
|
|
|
|
pub async fn list_operations(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
) -> Result<Vec<OperationSummary>, RegistryError> {
|
|
let rows = sqlx::query(
|
|
"select
|
|
operations.id,
|
|
operations.workspace_id,
|
|
operations.name,
|
|
operations.display_name,
|
|
operations.category,
|
|
operations.protocol,
|
|
operations.security_level,
|
|
ov.target_json,
|
|
operations.status,
|
|
operations.current_draft_version,
|
|
operations.latest_published_version,
|
|
(operations.status = 'draft'
|
|
and operations.latest_published_version is null
|
|
and not exists (select 1 from agent_operation_bindings b where b.operation_id = operations.id)
|
|
and not exists (select 1 from approval_requests ar where ar.operation_id = operations.id)
|
|
and not exists (select 1 from invocation_logs il where il.operation_id = operations.id)
|
|
and not exists (select 1 from operation_samples os where os.operation_id = operations.id)
|
|
and not exists (select 1 from descriptors d where d.operation_id = operations.id)) as can_delete,
|
|
operations.created_at,
|
|
operations.updated_at,
|
|
operations.published_at
|
|
from operations
|
|
join operation_versions ov
|
|
on ov.operation_id = operations.id
|
|
and ov.version = operations.current_draft_version
|
|
where operations.workspace_id = $1
|
|
order by operations.name asc",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.into_iter()
|
|
.map(|row| {
|
|
build_operation_summary(
|
|
row.try_get("id")?,
|
|
row.try_get("workspace_id")?,
|
|
row.try_get("name")?,
|
|
row.try_get("display_name")?,
|
|
row.try_get("category")?,
|
|
row.try_get("protocol")?,
|
|
row.try_get("security_level")?,
|
|
row.try_get("target_json")?,
|
|
row.try_get("status")?,
|
|
row.try_get("current_draft_version")?,
|
|
row.try_get("latest_published_version")?,
|
|
row.try_get("can_delete")?,
|
|
row.try_get("created_at")?,
|
|
row.try_get("updated_at")?,
|
|
row.try_get("published_at")?,
|
|
)
|
|
})
|
|
.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",
|
|
workspace_id.as_str(),
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.into_iter()
|
|
.map(|row| {
|
|
build_operation_agent_ref(
|
|
row.operation_id,
|
|
row.agent_id,
|
|
row.agent_slug,
|
|
row.display_name,
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub async fn get_operation_summary(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
operation_id: &OperationId,
|
|
) -> Result<Option<OperationSummary>, RegistryError> {
|
|
let row = sqlx::query(
|
|
"select
|
|
operations.id,
|
|
operations.workspace_id,
|
|
operations.name,
|
|
operations.display_name,
|
|
operations.category,
|
|
operations.protocol,
|
|
operations.security_level,
|
|
ov.target_json,
|
|
operations.status,
|
|
operations.current_draft_version,
|
|
operations.latest_published_version,
|
|
(operations.status = 'draft'
|
|
and operations.latest_published_version is null
|
|
and not exists (select 1 from agent_operation_bindings b where b.operation_id = operations.id)
|
|
and not exists (select 1 from approval_requests ar where ar.operation_id = operations.id)
|
|
and not exists (select 1 from invocation_logs il where il.operation_id = operations.id)
|
|
and not exists (select 1 from operation_samples os where os.operation_id = operations.id)
|
|
and not exists (select 1 from descriptors d where d.operation_id = operations.id)) as can_delete,
|
|
operations.created_at,
|
|
operations.updated_at,
|
|
operations.published_at
|
|
from operations
|
|
join operation_versions ov
|
|
on ov.operation_id = operations.id
|
|
and ov.version = operations.current_draft_version
|
|
where operations.workspace_id = $1 and operations.id = $2",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.bind(operation_id.as_str())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
row.map(|row| {
|
|
build_operation_summary(
|
|
row.try_get("id")?,
|
|
row.try_get("workspace_id")?,
|
|
row.try_get("name")?,
|
|
row.try_get("display_name")?,
|
|
row.try_get("category")?,
|
|
row.try_get("protocol")?,
|
|
row.try_get("security_level")?,
|
|
row.try_get("target_json")?,
|
|
row.try_get("status")?,
|
|
row.try_get("current_draft_version")?,
|
|
row.try_get("latest_published_version")?,
|
|
row.try_get("can_delete")?,
|
|
row.try_get("created_at")?,
|
|
row.try_get("updated_at")?,
|
|
row.try_get("published_at")?,
|
|
)
|
|
})
|
|
.transpose()
|
|
}
|
|
|
|
pub async fn get_operation_version(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
operation_id: &OperationId,
|
|
version: u32,
|
|
) -> Result<Option<OperationVersionRecord>, RegistryError> {
|
|
let row = sqlx::query!(
|
|
"select
|
|
o.id,
|
|
o.workspace_id,
|
|
ov.name,
|
|
ov.display_name,
|
|
ov.category,
|
|
ov.protocol,
|
|
ov.security_level,
|
|
ov.created_at as \"operation_created_at!: time::OffsetDateTime\",
|
|
ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",
|
|
ov.published_at as \"operation_published_at: time::OffsetDateTime\",
|
|
ov.version,
|
|
ov.status,
|
|
ov.target_json,
|
|
ov.input_schema_json,
|
|
ov.output_schema_json,
|
|
ov.input_mapping_json,
|
|
ov.output_mapping_json,
|
|
ov.execution_config_json,
|
|
ov.tool_description_json,
|
|
ov.samples_json,
|
|
ov.generated_draft_json,
|
|
ov.config_export_json,
|
|
ov.wizard_state_json,
|
|
ov.change_note,
|
|
ov.created_at as \"created_at!: time::OffsetDateTime\",
|
|
ov.created_by
|
|
from operation_versions ov
|
|
join operations o on o.id = ov.operation_id
|
|
where o.workspace_id = $1 and ov.operation_id = $2 and ov.version = $3",
|
|
workspace_id.as_str(),
|
|
operation_id.as_str(),
|
|
to_db_version(version),
|
|
)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
row.map(|row| {
|
|
build_operation_version_record(
|
|
row.id,
|
|
row.workspace_id,
|
|
row.name,
|
|
row.display_name,
|
|
row.category,
|
|
row.protocol,
|
|
row.security_level,
|
|
row.operation_created_at,
|
|
row.operation_updated_at,
|
|
row.operation_published_at,
|
|
row.version,
|
|
row.status,
|
|
row.target_json,
|
|
row.input_schema_json,
|
|
row.output_schema_json,
|
|
row.input_mapping_json,
|
|
row.output_mapping_json,
|
|
row.execution_config_json,
|
|
row.tool_description_json,
|
|
row.samples_json,
|
|
row.generated_draft_json,
|
|
row.config_export_json,
|
|
row.wizard_state_json,
|
|
row.change_note,
|
|
row.created_at,
|
|
row.created_by,
|
|
)
|
|
})
|
|
.transpose()
|
|
}
|
|
|
|
pub async fn list_operation_versions(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
operation_id: &OperationId,
|
|
) -> Result<Vec<OperationVersionRecord>, RegistryError> {
|
|
let rows = sqlx::query!(
|
|
"select
|
|
o.id,
|
|
o.workspace_id,
|
|
ov.name,
|
|
ov.display_name,
|
|
ov.category,
|
|
ov.protocol,
|
|
ov.security_level,
|
|
ov.created_at as \"operation_created_at!: time::OffsetDateTime\",
|
|
ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",
|
|
ov.published_at as \"operation_published_at: time::OffsetDateTime\",
|
|
ov.version,
|
|
ov.status,
|
|
ov.target_json,
|
|
ov.input_schema_json,
|
|
ov.output_schema_json,
|
|
ov.input_mapping_json,
|
|
ov.output_mapping_json,
|
|
ov.execution_config_json,
|
|
ov.tool_description_json,
|
|
ov.samples_json,
|
|
ov.generated_draft_json,
|
|
ov.config_export_json,
|
|
ov.wizard_state_json,
|
|
ov.change_note,
|
|
ov.created_at as \"created_at!: time::OffsetDateTime\",
|
|
ov.created_by
|
|
from operation_versions ov
|
|
join operations o on o.id = ov.operation_id
|
|
where o.workspace_id = $1 and ov.operation_id = $2
|
|
order by ov.version asc",
|
|
workspace_id.as_str(),
|
|
operation_id.as_str(),
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.into_iter()
|
|
.map(|row| {
|
|
build_operation_version_record(
|
|
row.id,
|
|
row.workspace_id,
|
|
row.name,
|
|
row.display_name,
|
|
row.category,
|
|
row.protocol,
|
|
row.security_level,
|
|
row.operation_created_at,
|
|
row.operation_updated_at,
|
|
row.operation_published_at,
|
|
row.version,
|
|
row.status,
|
|
row.target_json,
|
|
row.input_schema_json,
|
|
row.output_schema_json,
|
|
row.input_mapping_json,
|
|
row.output_mapping_json,
|
|
row.execution_config_json,
|
|
row.tool_description_json,
|
|
row.samples_json,
|
|
row.generated_draft_json,
|
|
row.config_export_json,
|
|
row.wizard_state_json,
|
|
row.change_note,
|
|
row.created_at,
|
|
row.created_by,
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub async fn publish_operation(
|
|
&self,
|
|
request: PublishRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
self.publish_operation_inner(request, None).await
|
|
}
|
|
|
|
pub async fn publish_operation_cas(
|
|
&self,
|
|
request: PublishRequest<'_>,
|
|
expected_state: &OperationStateExpectation,
|
|
) -> Result<(), RegistryError> {
|
|
self.publish_operation_inner(request, Some(expected_state))
|
|
.await
|
|
}
|
|
|
|
async fn publish_operation_inner(
|
|
&self,
|
|
request: PublishRequest<'_>,
|
|
expected_state: Option<&OperationStateExpectation>,
|
|
) -> Result<(), RegistryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
let row = sqlx::query(
|
|
"select status, current_draft_version, latest_published_version
|
|
from operations
|
|
where workspace_id = $1 and id = $2
|
|
for update",
|
|
)
|
|
.bind(request.workspace_id.as_str())
|
|
.bind(request.operation_id.as_str())
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
let Some(row) = row else {
|
|
return Err(RegistryError::OperationNotFound {
|
|
operation_id: request.operation_id.as_str().to_owned(),
|
|
});
|
|
};
|
|
verify_expected_state(&row, request.operation_id.as_str(), expected_state)?;
|
|
let aggregate_status: OperationStatus =
|
|
deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?;
|
|
if aggregate_status == OperationStatus::Archived {
|
|
return Err(RegistryError::OperationArchived {
|
|
operation_id: request.operation_id.as_str().to_owned(),
|
|
});
|
|
}
|
|
let current = from_db_version(
|
|
row.try_get::<i32, _>("current_draft_version")?,
|
|
"current_draft_version",
|
|
)?;
|
|
let latest = row
|
|
.try_get::<Option<i32>, _>("latest_published_version")?
|
|
.map(|value| from_db_version(value, "latest_published_version"))
|
|
.transpose()?;
|
|
if latest == Some(request.version) && current == request.version {
|
|
tx.commit().await?;
|
|
return Ok(());
|
|
}
|
|
if request.version != current || latest.is_some_and(|latest| request.version <= latest) {
|
|
return Err(RegistryError::OperationStaleVersion {
|
|
operation_id: request.operation_id.as_str().to_owned(),
|
|
expected: current,
|
|
actual: request.version,
|
|
});
|
|
}
|
|
let version_status = sqlx::query(
|
|
"select status, execution_config_json from operation_versions
|
|
where operation_id = $1 and version = $2",
|
|
)
|
|
.bind(request.operation_id.as_str())
|
|
.bind(to_db_version(request.version))
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
let Some(version_row) = version_status else {
|
|
return Err(RegistryError::OperationVersionNotFound {
|
|
operation_id: request.operation_id.as_str().to_owned(),
|
|
version: request.version,
|
|
});
|
|
};
|
|
let version_status: OperationStatus =
|
|
deserialize_enum_text(&version_row.try_get::<String, _>("status")?, "status")?;
|
|
if version_status != OperationStatus::Draft {
|
|
return Err(RegistryError::InvalidOperationTransition {
|
|
operation_id: request.operation_id.as_str().to_owned(),
|
|
from: format!("{version_status:?}").to_ascii_lowercase(),
|
|
action: "publish",
|
|
});
|
|
}
|
|
let execution_config: crank_core::ExecutionConfig =
|
|
deserialize_json_value(version_row.try_get::<Value, _>("execution_config_json")?)?;
|
|
if let Some(auth_profile_id) = execution_config
|
|
.auth_profile_ref
|
|
.as_ref()
|
|
.filter(|_| expected_state.is_some())
|
|
{
|
|
let available = sqlx::query_scalar::<_, i32>(
|
|
"select 1 from auth_profiles
|
|
where workspace_id = $1 and id = $2
|
|
for key share",
|
|
)
|
|
.bind(request.workspace_id.as_str())
|
|
.bind(auth_profile_id.as_str())
|
|
.fetch_optional(&mut *tx)
|
|
.await?
|
|
.is_some();
|
|
if !available {
|
|
return Err(RegistryError::OperationAuthProfileUnavailable {
|
|
operation_id: request.operation_id.as_str().to_owned(),
|
|
});
|
|
}
|
|
}
|
|
|
|
sqlx::query(
|
|
"insert into published_operations (
|
|
operation_id,
|
|
version,
|
|
published_at,
|
|
published_by
|
|
) values ($1, $2, $3::timestamptz, $4)
|
|
on conflict(operation_id) do update set
|
|
version = excluded.version,
|
|
published_at = excluded.published_at,
|
|
published_by = excluded.published_by
|
|
where published_operations.version < excluded.version",
|
|
)
|
|
.bind(request.operation_id.as_str())
|
|
.bind(to_db_version(request.version))
|
|
.bind(request.published_at)
|
|
.bind(request.published_by)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
"update operation_versions
|
|
set status = $1,
|
|
published_at = $2::timestamptz,
|
|
published_by = $3
|
|
where operation_id = $4 and version = $5",
|
|
)
|
|
.bind(serialize_enum_text(&OperationStatus::Published, "status")?)
|
|
.bind(request.published_at)
|
|
.bind(request.published_by)
|
|
.bind(request.operation_id.as_str())
|
|
.bind(to_db_version(request.version))
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
"update operations
|
|
set status = $1,
|
|
latest_published_version = $2,
|
|
published_at = $3::timestamptz,
|
|
updated_at = $4::timestamptz
|
|
where id = $5 and workspace_id = $6",
|
|
)
|
|
.bind(serialize_enum_text(&OperationStatus::Published, "status")?)
|
|
.bind(to_db_version(request.version))
|
|
.bind(request.published_at)
|
|
.bind(request.published_at)
|
|
.bind(request.operation_id.as_str())
|
|
.bind(request.workspace_id.as_str())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn verify_expected_state(
|
|
row: &PgRow,
|
|
operation_id: &str,
|
|
expected: Option<&OperationStateExpectation>,
|
|
) -> Result<(), RegistryError> {
|
|
let Some(expected) = expected else {
|
|
return Ok(());
|
|
};
|
|
let current = from_db_version(
|
|
row.try_get::<i32, _>("current_draft_version")?,
|
|
"current_draft_version",
|
|
)?;
|
|
let status: OperationStatus =
|
|
deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?;
|
|
let latest = row
|
|
.try_get::<Option<i32>, _>("latest_published_version")?
|
|
.map(|value| from_db_version(value, "latest_published_version"))
|
|
.transpose()?;
|
|
if current != expected.current_draft_version
|
|
|| status != expected.status
|
|
|| latest != expected.latest_published_version
|
|
{
|
|
return Err(RegistryError::OperationStaleVersion {
|
|
operation_id: operation_id.to_owned(),
|
|
expected: expected.current_draft_version,
|
|
actual: current,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn matches_operation_name_conflict(error: &RegistryError) -> bool {
|
|
let RegistryError::Storage(sqlx::Error::Database(error)) = error else {
|
|
return false;
|
|
};
|
|
error.code().as_deref() == Some("23505")
|
|
&& error.constraint() == Some("operations_workspace_name_idx")
|
|
}
|