2136 lines
73 KiB
Rust
2136 lines
73 KiB
Rust
use crank_core::{
|
|
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, OperationId,
|
|
OperationStatus, Workspace, WorkspaceId,
|
|
};
|
|
use serde::{Serialize, de::DeserializeOwned};
|
|
use serde_json::Value;
|
|
use sqlx::{
|
|
PgPool, Postgres, Row, Transaction,
|
|
postgres::{PgPoolOptions, PgRow},
|
|
types::Json,
|
|
};
|
|
|
|
use crate::{
|
|
error::RegistryError,
|
|
migrations,
|
|
model::{
|
|
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateVersionRequest,
|
|
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata,
|
|
OperationSampleMetadata, OperationSummary, OperationVersionRecord, PublishAgentRequest,
|
|
PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest,
|
|
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
|
UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
|
YamlImportJobId, YamlImportJobStatus,
|
|
},
|
|
};
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct PostgresRegistry {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresRegistry {
|
|
pub async fn connect(database_url: &str) -> Result<Self, RegistryError> {
|
|
Self::connect_in_schema(database_url, None).await
|
|
}
|
|
|
|
pub fn pool(&self) -> &PgPool {
|
|
&self.pool
|
|
}
|
|
|
|
pub async fn migrate(&self) -> Result<(), RegistryError> {
|
|
migrations::apply_postgres(&self.pool).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, RegistryError> {
|
|
let rows = sqlx::query(
|
|
"select
|
|
id,
|
|
slug,
|
|
display_name,
|
|
status,
|
|
settings_json,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at
|
|
from workspaces
|
|
order by slug asc",
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.iter().map(map_workspace_record).collect()
|
|
}
|
|
|
|
pub async fn get_workspace(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
) -> Result<Option<WorkspaceRecord>, RegistryError> {
|
|
let row = sqlx::query(
|
|
"select
|
|
id,
|
|
slug,
|
|
display_name,
|
|
status,
|
|
settings_json,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at
|
|
from workspaces
|
|
where id = $1",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
row.as_ref().map(map_workspace_record).transpose()
|
|
}
|
|
|
|
pub async fn create_workspace(
|
|
&self,
|
|
request: CreateWorkspaceRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
let result = sqlx::query(
|
|
"insert into workspaces (
|
|
id,
|
|
slug,
|
|
display_name,
|
|
status,
|
|
settings_json,
|
|
created_at,
|
|
updated_at
|
|
) values (
|
|
$1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz
|
|
)",
|
|
)
|
|
.bind(request.workspace.id.as_str())
|
|
.bind(&request.workspace.slug)
|
|
.bind(&request.workspace.display_name)
|
|
.bind(serialize_enum_text(&request.workspace.status, "status")?)
|
|
.bind(Json(request.workspace.settings.clone()))
|
|
.bind(&request.workspace.created_at)
|
|
.bind(&request.workspace.updated_at)
|
|
.execute(&self.pool)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(_) => Ok(()),
|
|
Err(sqlx::Error::Database(error))
|
|
if error.constraint() == Some("workspaces_slug_key") =>
|
|
{
|
|
Err(RegistryError::WorkspaceSlugAlreadyExists {
|
|
slug: request.workspace.slug.clone(),
|
|
})
|
|
}
|
|
Err(error) => Err(RegistryError::Storage(error)),
|
|
}
|
|
}
|
|
|
|
pub async fn update_workspace(
|
|
&self,
|
|
request: UpdateWorkspaceRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
let result = sqlx::query(
|
|
"update workspaces
|
|
set slug = $1,
|
|
display_name = $2,
|
|
status = $3,
|
|
settings_json = $4,
|
|
updated_at = $5::timestamptz
|
|
where id = $6",
|
|
)
|
|
.bind(&request.workspace.slug)
|
|
.bind(&request.workspace.display_name)
|
|
.bind(serialize_enum_text(&request.workspace.status, "status")?)
|
|
.bind(Json(request.workspace.settings.clone()))
|
|
.bind(&request.workspace.updated_at)
|
|
.bind(request.workspace.id.as_str())
|
|
.execute(&self.pool)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(result) if result.rows_affected() == 0 => Err(RegistryError::WorkspaceNotFound {
|
|
workspace_id: request.workspace.id.as_str().to_owned(),
|
|
}),
|
|
Ok(_) => Ok(()),
|
|
Err(sqlx::Error::Database(error))
|
|
if error.constraint() == Some("workspaces_slug_key") =>
|
|
{
|
|
Err(RegistryError::WorkspaceSlugAlreadyExists {
|
|
slug: request.workspace.slug.clone(),
|
|
})
|
|
}
|
|
Err(error) => Err(RegistryError::Storage(error)),
|
|
}
|
|
}
|
|
|
|
pub async fn list_agents(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
) -> Result<Vec<AgentSummary>, RegistryError> {
|
|
let rows = sqlx::query(
|
|
"select
|
|
id,
|
|
workspace_id,
|
|
slug,
|
|
display_name,
|
|
description,
|
|
status,
|
|
current_draft_version,
|
|
latest_published_version,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
|
|
to_char(published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
|
|
from agents
|
|
where workspace_id = $1
|
|
order by slug asc",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.iter().map(map_agent_summary).collect()
|
|
}
|
|
|
|
pub async fn get_agent_summary(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
agent_id: &AgentId,
|
|
) -> Result<Option<AgentSummary>, RegistryError> {
|
|
let row = sqlx::query(
|
|
"select
|
|
id,
|
|
workspace_id,
|
|
slug,
|
|
display_name,
|
|
description,
|
|
status,
|
|
current_draft_version,
|
|
latest_published_version,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
|
|
to_char(published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
|
|
from agents
|
|
where workspace_id = $1 and id = $2",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.bind(agent_id.as_str())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
row.as_ref().map(map_agent_summary).transpose()
|
|
}
|
|
|
|
pub async fn create_agent(&self, request: CreateAgentRequest<'_>) -> Result<(), RegistryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
sqlx::query(
|
|
"insert into agents (
|
|
id,
|
|
workspace_id,
|
|
slug,
|
|
display_name,
|
|
description,
|
|
status,
|
|
current_draft_version,
|
|
latest_published_version,
|
|
created_at,
|
|
updated_at,
|
|
published_at
|
|
) values (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, $10::timestamptz, $11::timestamptz
|
|
)",
|
|
)
|
|
.bind(request.agent.id.as_str())
|
|
.bind(request.agent.workspace_id.as_str())
|
|
.bind(&request.agent.slug)
|
|
.bind(&request.agent.display_name)
|
|
.bind(&request.agent.description)
|
|
.bind(serialize_enum_text(&request.agent.status, "status")?)
|
|
.bind(to_db_version(request.agent.current_draft_version))
|
|
.bind(request.agent.latest_published_version.map(to_db_version))
|
|
.bind(&request.agent.created_at)
|
|
.bind(&request.agent.updated_at)
|
|
.bind(request.agent.published_at.as_deref())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
insert_agent_version_row(&mut tx, request.version).await?;
|
|
replace_agent_bindings_rows(
|
|
&mut tx,
|
|
&request.agent.id,
|
|
request.version.version,
|
|
request.bindings,
|
|
)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_agent_version(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
agent_id: &AgentId,
|
|
version: u32,
|
|
) -> Result<Option<AgentVersionRecord>, RegistryError> {
|
|
let summary = match self.get_agent_summary(workspace_id, agent_id).await? {
|
|
Some(value) => value,
|
|
None => return Ok(None),
|
|
};
|
|
|
|
let row = sqlx::query(
|
|
"select
|
|
agent_id,
|
|
version,
|
|
status,
|
|
instructions_json,
|
|
tool_selection_policy_json,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at
|
|
from agent_versions
|
|
where agent_id = $1 and version = $2",
|
|
)
|
|
.bind(agent_id.as_str())
|
|
.bind(to_db_version(version))
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
let Some(row) = row else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let bindings = self.list_agent_bindings(agent_id, version).await?;
|
|
Ok(Some(map_agent_version_record(&summary, &row, bindings)?))
|
|
}
|
|
|
|
pub async fn save_agent_bindings(
|
|
&self,
|
|
request: SaveAgentBindingsRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
if self
|
|
.get_agent_summary(request.workspace_id, request.agent_id)
|
|
.await?
|
|
.is_none()
|
|
{
|
|
return Err(RegistryError::AgentNotFound {
|
|
agent_id: request.agent_id.as_str().to_owned(),
|
|
});
|
|
}
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
replace_agent_bindings_rows(
|
|
&mut tx,
|
|
request.agent_id,
|
|
request.agent_version,
|
|
request.bindings,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn publish_agent(
|
|
&self,
|
|
request: PublishAgentRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
if self
|
|
.get_agent_version(request.workspace_id, request.agent_id, request.version)
|
|
.await?
|
|
.is_none()
|
|
{
|
|
return Err(RegistryError::AgentNotFound {
|
|
agent_id: request.agent_id.as_str().to_owned(),
|
|
});
|
|
}
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
sqlx::query(
|
|
"insert into published_agents (
|
|
agent_id,
|
|
version,
|
|
published_at,
|
|
published_by
|
|
) values ($1, $2, $3::timestamptz, $4)
|
|
on conflict(agent_id) do update set
|
|
version = excluded.version,
|
|
published_at = excluded.published_at,
|
|
published_by = excluded.published_by",
|
|
)
|
|
.bind(request.agent_id.as_str())
|
|
.bind(to_db_version(request.version))
|
|
.bind(request.published_at)
|
|
.bind(request.published_by)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
"update agent_versions
|
|
set status = $1
|
|
where agent_id = $2 and version = $3",
|
|
)
|
|
.bind(serialize_enum_text(&AgentStatus::Published, "status")?)
|
|
.bind(request.agent_id.as_str())
|
|
.bind(to_db_version(request.version))
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
"update agents
|
|
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(&AgentStatus::Published, "status")?)
|
|
.bind(to_db_version(request.version))
|
|
.bind(request.published_at)
|
|
.bind(request.published_at)
|
|
.bind(request.agent_id.as_str())
|
|
.bind(request.workspace_id.as_str())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_published_agent_tools_by_slug(
|
|
&self,
|
|
workspace_slug: &str,
|
|
agent_slug: &str,
|
|
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
|
|
let rows = sqlx::query(
|
|
"select
|
|
w.id as workspace_id,
|
|
w.slug as workspace_slug,
|
|
a.id as agent_id,
|
|
a.slug as agent_slug,
|
|
b.tool_name,
|
|
b.tool_title,
|
|
coalesce(b.tool_description_override, ov.tool_description_json->>'description') as tool_description,
|
|
o.id,
|
|
o.name,
|
|
o.display_name,
|
|
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,
|
|
to_char(o.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_published_at,
|
|
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.change_note,
|
|
to_char(ov.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
ov.created_by
|
|
from workspaces w
|
|
join agents a on a.workspace_id = w.id
|
|
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
|
|
join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version
|
|
join operations o on o.id = ov.operation_id and o.workspace_id = w.id
|
|
where w.slug = $1 and a.slug = $2 and b.enabled = true
|
|
order by b.tool_name asc",
|
|
)
|
|
.bind(workspace_slug)
|
|
.bind(agent_slug)
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
if rows.is_empty() {
|
|
let exists = sqlx::query(
|
|
"select 1
|
|
from workspaces w
|
|
join agents a on a.workspace_id = w.id
|
|
join published_agents pa on pa.agent_id = a.id
|
|
where w.slug = $1 and a.slug = $2",
|
|
)
|
|
.bind(workspace_slug)
|
|
.bind(agent_slug)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
if exists.is_none() {
|
|
return Err(RegistryError::PublishedAgentNotFound {
|
|
workspace_slug: workspace_slug.to_owned(),
|
|
agent_slug: agent_slug.to_owned(),
|
|
});
|
|
}
|
|
}
|
|
|
|
rows.iter().map(map_published_agent_tool).collect()
|
|
}
|
|
|
|
async fn connect_in_schema(
|
|
database_url: &str,
|
|
schema: Option<&str>,
|
|
) -> Result<Self, RegistryError> {
|
|
let pool = PgPoolOptions::new()
|
|
.max_connections(1)
|
|
.connect(database_url)
|
|
.await?;
|
|
|
|
if let Some(schema) = schema {
|
|
sqlx::query(&format!("set search_path to {schema}"))
|
|
.execute(&pool)
|
|
.await?;
|
|
}
|
|
|
|
let registry = Self { pool };
|
|
registry.migrate().await?;
|
|
Ok(registry)
|
|
}
|
|
|
|
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?;
|
|
|
|
sqlx::query(
|
|
"insert into operations (
|
|
id,
|
|
workspace_id,
|
|
name,
|
|
display_name,
|
|
protocol,
|
|
status,
|
|
current_draft_version,
|
|
latest_published_version,
|
|
created_at,
|
|
updated_at,
|
|
published_at
|
|
) values (
|
|
$1, $2, $3, $4, $5, $6, $7, $8,
|
|
$9::timestamptz,
|
|
$10::timestamptz,
|
|
$11::timestamptz
|
|
)",
|
|
)
|
|
.bind(snapshot.id.as_str())
|
|
.bind(workspace_id.as_str())
|
|
.bind(&snapshot.name)
|
|
.bind(&snapshot.display_name)
|
|
.bind(serialize_enum_text(&snapshot.protocol, "protocol")?)
|
|
.bind(serialize_enum_text(&snapshot.status, "status")?)
|
|
.bind(to_db_version(snapshot.version))
|
|
.bind(
|
|
snapshot
|
|
.published_at
|
|
.as_ref()
|
|
.map(|_| to_db_version(snapshot.version)),
|
|
)
|
|
.bind(&snapshot.created_at)
|
|
.bind(&snapshot.updated_at)
|
|
.bind(snapshot.published_at.as_deref())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
insert_version_row(&mut tx, snapshot, None, created_by).await?;
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn create_version(
|
|
&self,
|
|
request: CreateVersionRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
let Some(summary) = self
|
|
.get_operation_summary(request.workspace_id, &request.snapshot.id)
|
|
.await?
|
|
else {
|
|
return Err(RegistryError::OperationNotFound {
|
|
operation_id: request.snapshot.id.as_str().to_owned(),
|
|
});
|
|
};
|
|
|
|
assert_immutable_fields(&summary, request.snapshot)?;
|
|
|
|
let expected = summary.current_draft_version + 1;
|
|
if request.snapshot.version != expected {
|
|
return Err(RegistryError::InvalidVersionSequence {
|
|
operation_id: request.snapshot.id.as_str().to_owned(),
|
|
expected,
|
|
actual: request.snapshot.version,
|
|
});
|
|
}
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
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",
|
|
)
|
|
.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())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_operations(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
) -> Result<Vec<OperationSummary>, RegistryError> {
|
|
let rows = sqlx::query(
|
|
"select
|
|
id,
|
|
workspace_id,
|
|
name,
|
|
display_name,
|
|
protocol,
|
|
status,
|
|
current_draft_version,
|
|
latest_published_version,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
|
|
to_char(published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
|
|
from operations
|
|
where workspace_id = $1
|
|
order by name asc",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.iter().map(map_operation_summary).collect()
|
|
}
|
|
|
|
pub async fn get_operation_summary(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
operation_id: &OperationId,
|
|
) -> Result<Option<OperationSummary>, RegistryError> {
|
|
let row = sqlx::query(
|
|
"select
|
|
id,
|
|
workspace_id,
|
|
name,
|
|
display_name,
|
|
protocol,
|
|
status,
|
|
current_draft_version,
|
|
latest_published_version,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
|
|
to_char(published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
|
|
from operations
|
|
where workspace_id = $1 and id = $2",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.bind(operation_id.as_str())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
row.as_ref().map(map_operation_summary).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,
|
|
o.name,
|
|
o.display_name,
|
|
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,
|
|
to_char(o.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_published_at,
|
|
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.change_note,
|
|
to_char(ov.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
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",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.bind(operation_id.as_str())
|
|
.bind(to_db_version(version))
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
row.as_ref().map(map_operation_version_record).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,
|
|
o.name,
|
|
o.display_name,
|
|
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,
|
|
to_char(o.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_published_at,
|
|
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.change_note,
|
|
to_char(ov.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
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",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.bind(operation_id.as_str())
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.iter().map(map_operation_version_record).collect()
|
|
}
|
|
|
|
pub async fn publish_operation(
|
|
&self,
|
|
request: PublishRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
if self
|
|
.get_operation_version(request.workspace_id, request.operation_id, request.version)
|
|
.await?
|
|
.is_none()
|
|
{
|
|
return Err(RegistryError::OperationVersionNotFound {
|
|
operation_id: request.operation_id.as_str().to_owned(),
|
|
version: request.version,
|
|
});
|
|
}
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
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",
|
|
)
|
|
.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
|
|
where operation_id = $2 and version = $3",
|
|
)
|
|
.bind(serialize_enum_text(&OperationStatus::Published, "status")?)
|
|
.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",
|
|
)
|
|
.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())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_published_operation(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
) -> Result<Option<RegistryOperation>, RegistryError> {
|
|
let row = sqlx::query(
|
|
"select
|
|
o.id,
|
|
o.workspace_id,
|
|
o.name,
|
|
o.display_name,
|
|
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,
|
|
to_char(o.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_published_at,
|
|
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.change_note,
|
|
to_char(ov.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
ov.created_by
|
|
from published_operations po
|
|
join operation_versions ov
|
|
on ov.operation_id = po.operation_id and ov.version = po.version
|
|
join operations o on o.id = po.operation_id
|
|
where po.operation_id = $1",
|
|
)
|
|
.bind(operation_id.as_str())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
row.as_ref()
|
|
.map(|value| map_operation_version_record(value).map(|record| record.snapshot))
|
|
.transpose()
|
|
}
|
|
|
|
pub async fn list_published_operations(&self) -> Result<Vec<RegistryOperation>, RegistryError> {
|
|
let rows = sqlx::query(
|
|
"select
|
|
o.id,
|
|
o.workspace_id,
|
|
o.name,
|
|
o.display_name,
|
|
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,
|
|
to_char(o.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_published_at,
|
|
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.change_note,
|
|
to_char(ov.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
ov.created_by
|
|
from published_operations po
|
|
join operation_versions ov
|
|
on ov.operation_id = po.operation_id and ov.version = po.version
|
|
join operations o on o.id = po.operation_id
|
|
order by o.name asc",
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.iter()
|
|
.map(|row| map_operation_version_record(row).map(|record| record.snapshot))
|
|
.collect()
|
|
}
|
|
|
|
pub async fn save_auth_profile(
|
|
&self,
|
|
request: SaveAuthProfileRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
sqlx::query(
|
|
"insert into auth_profiles (
|
|
id,
|
|
workspace_id,
|
|
name,
|
|
kind,
|
|
config_json,
|
|
created_at,
|
|
updated_at
|
|
) values ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz)
|
|
on conflict(id) do update set
|
|
workspace_id = excluded.workspace_id,
|
|
name = excluded.name,
|
|
kind = excluded.kind,
|
|
config_json = excluded.config_json,
|
|
updated_at = excluded.updated_at",
|
|
)
|
|
.bind(request.profile.id.as_str())
|
|
.bind(request.workspace_id.as_str())
|
|
.bind(&request.profile.name)
|
|
.bind(serialize_enum_text(&request.profile.kind, "kind")?)
|
|
.bind(Json(serialize_json_value(&request.profile.config)?))
|
|
.bind(&request.profile.created_at)
|
|
.bind(&request.profile.updated_at)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_auth_profile(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
auth_profile_id: &crank_core::AuthProfileId,
|
|
) -> Result<Option<AuthProfile>, RegistryError> {
|
|
let row = sqlx::query(
|
|
"select
|
|
id,
|
|
workspace_id,
|
|
name,
|
|
kind,
|
|
config_json,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at
|
|
from auth_profiles
|
|
where workspace_id = $1 and id = $2",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.bind(auth_profile_id.as_str())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
row.as_ref().map(map_auth_profile).transpose()
|
|
}
|
|
|
|
pub async fn list_auth_profiles(
|
|
&self,
|
|
workspace_id: &WorkspaceId,
|
|
) -> Result<Vec<AuthProfile>, RegistryError> {
|
|
let rows = sqlx::query(
|
|
"select
|
|
id,
|
|
workspace_id,
|
|
name,
|
|
kind,
|
|
config_json,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at
|
|
from auth_profiles
|
|
where workspace_id = $1
|
|
order by name asc",
|
|
)
|
|
.bind(workspace_id.as_str())
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.iter().map(map_auth_profile).collect()
|
|
}
|
|
|
|
pub async fn save_sample_metadata(
|
|
&self,
|
|
request: SaveSampleMetadataRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
sqlx::query(
|
|
"insert into operation_samples (
|
|
id,
|
|
operation_id,
|
|
version,
|
|
sample_kind,
|
|
storage_ref,
|
|
content_type,
|
|
file_name,
|
|
created_at
|
|
) values ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz)
|
|
on conflict(id) do update set
|
|
operation_id = excluded.operation_id,
|
|
version = excluded.version,
|
|
sample_kind = excluded.sample_kind,
|
|
storage_ref = excluded.storage_ref,
|
|
content_type = excluded.content_type,
|
|
file_name = excluded.file_name,
|
|
created_at = excluded.created_at",
|
|
)
|
|
.bind(request.sample.id.as_str())
|
|
.bind(request.sample.operation_id.as_str())
|
|
.bind(to_db_version(request.sample.version))
|
|
.bind(serialize_enum_text(
|
|
&request.sample.sample_kind,
|
|
"sample_kind",
|
|
)?)
|
|
.bind(&request.sample.storage_ref)
|
|
.bind(&request.sample.content_type)
|
|
.bind(request.sample.file_name.as_deref())
|
|
.bind(&request.sample.created_at)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_sample_metadata(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
version: u32,
|
|
) -> Result<Vec<OperationSampleMetadata>, RegistryError> {
|
|
let rows = sqlx::query(
|
|
"select
|
|
id,
|
|
operation_id,
|
|
version,
|
|
sample_kind,
|
|
storage_ref,
|
|
content_type,
|
|
file_name,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at
|
|
from operation_samples
|
|
where operation_id = $1 and version = $2
|
|
order by created_at asc",
|
|
)
|
|
.bind(operation_id.as_str())
|
|
.bind(to_db_version(version))
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.iter().map(map_sample_metadata).collect()
|
|
}
|
|
|
|
pub async fn save_descriptor_metadata(
|
|
&self,
|
|
request: SaveDescriptorMetadataRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
sqlx::query(
|
|
"insert into descriptors (
|
|
id,
|
|
operation_id,
|
|
version,
|
|
descriptor_kind,
|
|
storage_ref,
|
|
source_name,
|
|
package_index_json,
|
|
created_at
|
|
) values ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz)
|
|
on conflict(id) do update set
|
|
operation_id = excluded.operation_id,
|
|
version = excluded.version,
|
|
descriptor_kind = excluded.descriptor_kind,
|
|
storage_ref = excluded.storage_ref,
|
|
source_name = excluded.source_name,
|
|
package_index_json = excluded.package_index_json,
|
|
created_at = excluded.created_at",
|
|
)
|
|
.bind(request.descriptor.id.as_str())
|
|
.bind(
|
|
request
|
|
.descriptor
|
|
.operation_id
|
|
.as_ref()
|
|
.map(|value| value.as_str()),
|
|
)
|
|
.bind(request.descriptor.version.map(to_db_version))
|
|
.bind(serialize_enum_text(
|
|
&request.descriptor.descriptor_kind,
|
|
"descriptor_kind",
|
|
)?)
|
|
.bind(&request.descriptor.storage_ref)
|
|
.bind(request.descriptor.source_name.as_deref())
|
|
.bind(serialize_option_json_value(&request.descriptor.package_index)?.map(Json))
|
|
.bind(&request.descriptor.created_at)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_descriptor_metadata(
|
|
&self,
|
|
operation_id: &OperationId,
|
|
version: u32,
|
|
) -> Result<Vec<DescriptorMetadata>, RegistryError> {
|
|
let rows = sqlx::query(
|
|
"select
|
|
id,
|
|
operation_id,
|
|
version,
|
|
descriptor_kind,
|
|
storage_ref,
|
|
source_name,
|
|
package_index_json,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at
|
|
from descriptors
|
|
where operation_id = $1 and version = $2
|
|
order by created_at asc",
|
|
)
|
|
.bind(operation_id.as_str())
|
|
.bind(to_db_version(version))
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.iter().map(map_descriptor_metadata).collect()
|
|
}
|
|
|
|
pub async fn create_yaml_import_job(
|
|
&self,
|
|
request: CreateYamlImportJobRequest<'_>,
|
|
) -> Result<(), RegistryError> {
|
|
sqlx::query(
|
|
"insert into yaml_import_jobs (
|
|
id,
|
|
source_sample_id,
|
|
status,
|
|
format_version,
|
|
mode,
|
|
result_operation_id,
|
|
result_version,
|
|
error_text,
|
|
created_at,
|
|
finished_at
|
|
) values ($1, $2, $3, $4, $5, null, null, null, $6::timestamptz, null)",
|
|
)
|
|
.bind(request.id.as_str())
|
|
.bind(request.source_sample_id.map(|value| value.as_str()))
|
|
.bind(serialize_enum_text(
|
|
&YamlImportJobStatus::Pending,
|
|
"status",
|
|
)?)
|
|
.bind(request.format_version)
|
|
.bind(serialize_enum_text(&request.mode, "mode")?)
|
|
.bind(request.created_at)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn finish_yaml_import_job(
|
|
&self,
|
|
job_id: &YamlImportJobId,
|
|
completion: &YamlImportJobCompletion,
|
|
) -> Result<(), RegistryError> {
|
|
let result = sqlx::query(
|
|
"update yaml_import_jobs
|
|
set status = $1,
|
|
result_operation_id = $2,
|
|
result_version = $3,
|
|
error_text = $4,
|
|
finished_at = $5::timestamptz
|
|
where id = $6",
|
|
)
|
|
.bind(serialize_enum_text(&completion.status, "status")?)
|
|
.bind(
|
|
completion
|
|
.result_operation_id
|
|
.as_ref()
|
|
.map(|value| value.as_str()),
|
|
)
|
|
.bind(completion.result_version.map(to_db_version))
|
|
.bind(completion.error_text.as_deref())
|
|
.bind(&completion.finished_at)
|
|
.bind(job_id.as_str())
|
|
.execute(&self.pool)
|
|
.await?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
return Err(RegistryError::YamlImportJobNotFound {
|
|
job_id: job_id.as_str().to_owned(),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_yaml_import_job(
|
|
&self,
|
|
job_id: &YamlImportJobId,
|
|
) -> Result<Option<YamlImportJob>, RegistryError> {
|
|
let row = sqlx::query(
|
|
"select
|
|
id,
|
|
source_sample_id,
|
|
status,
|
|
format_version,
|
|
mode,
|
|
result_operation_id,
|
|
result_version,
|
|
error_text,
|
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
|
to_char(finished_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as finished_at
|
|
from yaml_import_jobs
|
|
where id = $1",
|
|
)
|
|
.bind(job_id.as_str())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
row.as_ref().map(map_yaml_import_job).transpose()
|
|
}
|
|
|
|
async fn list_agent_bindings(
|
|
&self,
|
|
agent_id: &AgentId,
|
|
version: u32,
|
|
) -> Result<Vec<AgentOperationBinding>, RegistryError> {
|
|
let rows = sqlx::query(
|
|
"select
|
|
operation_id,
|
|
operation_version,
|
|
tool_name,
|
|
tool_title,
|
|
tool_description_override,
|
|
enabled
|
|
from agent_operation_bindings
|
|
where agent_id = $1 and agent_version = $2
|
|
order by tool_name asc",
|
|
)
|
|
.bind(agent_id.as_str())
|
|
.bind(to_db_version(version))
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
rows.iter().map(map_agent_binding).collect()
|
|
}
|
|
}
|
|
|
|
async fn insert_version_row(
|
|
tx: &mut Transaction<'_, Postgres>,
|
|
snapshot: &RegistryOperation,
|
|
change_note: Option<&str>,
|
|
created_by: Option<&str>,
|
|
) -> Result<(), RegistryError> {
|
|
sqlx::query(
|
|
"insert into operation_versions (
|
|
operation_id,
|
|
version,
|
|
status,
|
|
target_json,
|
|
input_schema_json,
|
|
output_schema_json,
|
|
input_mapping_json,
|
|
output_mapping_json,
|
|
execution_config_json,
|
|
tool_description_json,
|
|
samples_json,
|
|
generated_draft_json,
|
|
config_export_json,
|
|
change_note,
|
|
created_at,
|
|
created_by
|
|
) values (
|
|
$1, $2, $3, $4, $5, $6, $7, $8,
|
|
$9, $10, $11, $12, $13, $14, $15::timestamptz, $16
|
|
)",
|
|
)
|
|
.bind(snapshot.id.as_str())
|
|
.bind(to_db_version(snapshot.version))
|
|
.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(serialize_option_json_value(&snapshot.samples)?.map(Json))
|
|
.bind(serialize_option_json_value(&snapshot.generated_draft)?.map(Json))
|
|
.bind(serialize_option_json_value(&snapshot.config_export)?.map(Json))
|
|
.bind(change_note)
|
|
.bind(&snapshot.updated_at)
|
|
.bind(created_by)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn insert_agent_version_row(
|
|
tx: &mut Transaction<'_, Postgres>,
|
|
version: &AgentVersion,
|
|
) -> Result<(), RegistryError> {
|
|
sqlx::query(
|
|
"insert into agent_versions (
|
|
agent_id,
|
|
version,
|
|
status,
|
|
instructions_json,
|
|
tool_selection_policy_json,
|
|
created_at
|
|
) values (
|
|
$1, $2, $3, $4, $5, $6::timestamptz
|
|
)",
|
|
)
|
|
.bind(version.agent_id.as_str())
|
|
.bind(to_db_version(version.version))
|
|
.bind(serialize_enum_text(&version.status, "status")?)
|
|
.bind(Json(version.instructions.clone()))
|
|
.bind(Json(version.tool_selection_policy.clone()))
|
|
.bind(&version.created_at)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn replace_agent_bindings_rows(
|
|
tx: &mut Transaction<'_, Postgres>,
|
|
agent_id: &AgentId,
|
|
version: u32,
|
|
bindings: &[AgentOperationBinding],
|
|
) -> Result<(), RegistryError> {
|
|
sqlx::query(
|
|
"delete from agent_operation_bindings
|
|
where agent_id = $1 and agent_version = $2",
|
|
)
|
|
.bind(agent_id.as_str())
|
|
.bind(to_db_version(version))
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
|
|
for binding in bindings {
|
|
sqlx::query(
|
|
"insert into agent_operation_bindings (
|
|
agent_id,
|
|
agent_version,
|
|
operation_id,
|
|
operation_version,
|
|
tool_name,
|
|
tool_title,
|
|
tool_description_override,
|
|
enabled
|
|
) values ($1, $2, $3, $4, $5, $6, $7, $8)",
|
|
)
|
|
.bind(agent_id.as_str())
|
|
.bind(to_db_version(version))
|
|
.bind(binding.operation_id.as_str())
|
|
.bind(to_db_version(binding.operation_version))
|
|
.bind(&binding.tool_name)
|
|
.bind(&binding.tool_title)
|
|
.bind(binding.tool_description_override.as_deref())
|
|
.bind(binding.enabled)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn assert_immutable_fields(
|
|
summary: &OperationSummary,
|
|
snapshot: &RegistryOperation,
|
|
) -> Result<(), RegistryError> {
|
|
if summary.name != snapshot.name {
|
|
return Err(RegistryError::ImmutableOperationFieldChanged {
|
|
operation_id: snapshot.id.as_str().to_owned(),
|
|
field: "name",
|
|
});
|
|
}
|
|
|
|
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(),
|
|
field: "protocol",
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn map_workspace_record(row: &PgRow) -> Result<WorkspaceRecord, RegistryError> {
|
|
Ok(WorkspaceRecord {
|
|
workspace: Workspace {
|
|
id: WorkspaceId::new(row.try_get::<String, _>("id")?),
|
|
slug: row.try_get("slug")?,
|
|
display_name: row.try_get("display_name")?,
|
|
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
|
settings: row.try_get::<Json<Value>, _>("settings_json")?.0,
|
|
created_at: row.try_get("created_at")?,
|
|
updated_at: row.try_get("updated_at")?,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn map_agent_summary(row: &PgRow) -> Result<AgentSummary, RegistryError> {
|
|
Ok(AgentSummary {
|
|
id: AgentId::new(row.try_get::<String, _>("id")?),
|
|
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
|
|
slug: row.try_get("slug")?,
|
|
display_name: row.try_get("display_name")?,
|
|
description: row.try_get("description")?,
|
|
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
|
current_draft_version: from_db_version(
|
|
row.try_get("current_draft_version")?,
|
|
"current_draft_version",
|
|
)?,
|
|
latest_published_version: row
|
|
.try_get::<Option<i32>, _>("latest_published_version")?
|
|
.map(|value| from_db_version(value, "latest_published_version"))
|
|
.transpose()?,
|
|
created_at: row.try_get("created_at")?,
|
|
updated_at: row.try_get("updated_at")?,
|
|
published_at: row.try_get("published_at")?,
|
|
})
|
|
}
|
|
|
|
fn map_operation_summary(row: &PgRow) -> Result<OperationSummary, RegistryError> {
|
|
Ok(OperationSummary {
|
|
id: OperationId::new(row.try_get::<String, _>("id")?),
|
|
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
|
|
name: row.try_get("name")?,
|
|
display_name: row.try_get("display_name")?,
|
|
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(
|
|
row.try_get("current_draft_version")?,
|
|
"current_draft_version",
|
|
)?,
|
|
latest_published_version: row
|
|
.try_get::<Option<i32>, _>("latest_published_version")?
|
|
.map(|value| from_db_version(value, "latest_published_version"))
|
|
.transpose()?,
|
|
created_at: row.try_get("created_at")?,
|
|
updated_at: row.try_get("updated_at")?,
|
|
published_at: row.try_get("published_at")?,
|
|
})
|
|
}
|
|
|
|
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")?;
|
|
let status = deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?;
|
|
|
|
Ok(OperationVersionRecord {
|
|
operation_id: operation_id.clone(),
|
|
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
|
|
version,
|
|
status,
|
|
change_note: row.try_get("change_note")?,
|
|
created_at: row.try_get("created_at")?,
|
|
created_by: row.try_get("created_by")?,
|
|
snapshot: RegistryOperation {
|
|
id: operation_id,
|
|
name: row.try_get("name")?,
|
|
display_name: row.try_get("display_name")?,
|
|
protocol: deserialize_enum_text(&row.try_get::<String, _>("protocol")?, "protocol")?,
|
|
status,
|
|
version,
|
|
target: deserialize_json_value(row.try_get::<Json<Value>, _>("target_json")?.0)?,
|
|
input_schema: deserialize_json_value(
|
|
row.try_get::<Json<Value>, _>("input_schema_json")?.0,
|
|
)?,
|
|
output_schema: deserialize_json_value(
|
|
row.try_get::<Json<Value>, _>("output_schema_json")?.0,
|
|
)?,
|
|
input_mapping: deserialize_json_value(
|
|
row.try_get::<Json<Value>, _>("input_mapping_json")?.0,
|
|
)?,
|
|
output_mapping: deserialize_json_value(
|
|
row.try_get::<Json<Value>, _>("output_mapping_json")?.0,
|
|
)?,
|
|
execution_config: deserialize_json_value(
|
|
row.try_get::<Json<Value>, _>("execution_config_json")?.0,
|
|
)?,
|
|
tool_description: deserialize_json_value(
|
|
row.try_get::<Json<Value>, _>("tool_description_json")?.0,
|
|
)?,
|
|
samples: row
|
|
.try_get::<Option<Json<Value>>, _>("samples_json")?
|
|
.map(|value| deserialize_json_value(value.0))
|
|
.transpose()?,
|
|
generated_draft: row
|
|
.try_get::<Option<Json<Value>>, _>("generated_draft_json")?
|
|
.map(|value| deserialize_json_value(value.0))
|
|
.transpose()?,
|
|
config_export: row
|
|
.try_get::<Option<Json<Value>>, _>("config_export_json")?
|
|
.map(|value| deserialize_json_value(value.0))
|
|
.transpose()?,
|
|
created_at: row.try_get("operation_created_at")?,
|
|
updated_at: row.try_get("operation_updated_at")?,
|
|
published_at: row.try_get("operation_published_at")?,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn map_auth_profile(row: &PgRow) -> Result<AuthProfile, RegistryError> {
|
|
Ok(AuthProfile {
|
|
id: crank_core::AuthProfileId::new(row.try_get::<String, _>("id")?),
|
|
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
|
|
name: row.try_get("name")?,
|
|
kind: deserialize_enum_text(&row.try_get::<String, _>("kind")?, "kind")?,
|
|
config: deserialize_json_value(row.try_get::<Json<Value>, _>("config_json")?.0)?,
|
|
created_at: row.try_get("created_at")?,
|
|
updated_at: row.try_get("updated_at")?,
|
|
})
|
|
}
|
|
|
|
fn map_agent_binding(row: &PgRow) -> Result<AgentOperationBinding, RegistryError> {
|
|
Ok(AgentOperationBinding {
|
|
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
|
|
operation_version: from_db_version(row.try_get("operation_version")?, "operation_version")?,
|
|
tool_name: row.try_get("tool_name")?,
|
|
tool_title: row.try_get("tool_title")?,
|
|
tool_description_override: row.try_get("tool_description_override")?,
|
|
enabled: row.try_get("enabled")?,
|
|
})
|
|
}
|
|
|
|
fn map_agent_version_record(
|
|
summary: &AgentSummary,
|
|
row: &PgRow,
|
|
bindings: Vec<AgentOperationBinding>,
|
|
) -> Result<AgentVersionRecord, RegistryError> {
|
|
let version = from_db_version(row.try_get("version")?, "version")?;
|
|
let status = deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?;
|
|
|
|
Ok(AgentVersionRecord {
|
|
agent_id: summary.id.clone(),
|
|
workspace_id: summary.workspace_id.clone(),
|
|
version,
|
|
status,
|
|
created_at: row.try_get("created_at")?,
|
|
bindings,
|
|
snapshot: AgentVersion {
|
|
agent_id: summary.id.clone(),
|
|
version,
|
|
status,
|
|
instructions: row.try_get::<Json<Value>, _>("instructions_json")?.0,
|
|
tool_selection_policy: row
|
|
.try_get::<Json<Value>, _>("tool_selection_policy_json")?
|
|
.0,
|
|
created_at: row.try_get("created_at")?,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn map_published_agent_tool(row: &PgRow) -> Result<PublishedAgentTool, RegistryError> {
|
|
let record = map_operation_version_record(row)?;
|
|
|
|
Ok(PublishedAgentTool {
|
|
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
|
|
workspace_slug: row.try_get("workspace_slug")?,
|
|
agent_id: AgentId::new(row.try_get::<String, _>("agent_id")?),
|
|
agent_slug: row.try_get("agent_slug")?,
|
|
tool_name: row.try_get("tool_name")?,
|
|
tool_title: row.try_get("tool_title")?,
|
|
tool_description: row.try_get("tool_description")?,
|
|
operation: record.snapshot,
|
|
})
|
|
}
|
|
|
|
fn map_sample_metadata(row: &PgRow) -> Result<OperationSampleMetadata, RegistryError> {
|
|
Ok(OperationSampleMetadata {
|
|
id: crank_core::SampleId::new(row.try_get::<String, _>("id")?),
|
|
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
|
|
version: from_db_version(row.try_get("version")?, "version")?,
|
|
sample_kind: deserialize_enum_text(
|
|
&row.try_get::<String, _>("sample_kind")?,
|
|
"sample_kind",
|
|
)?,
|
|
storage_ref: row.try_get("storage_ref")?,
|
|
content_type: row.try_get("content_type")?,
|
|
file_name: row.try_get("file_name")?,
|
|
created_at: row.try_get("created_at")?,
|
|
})
|
|
}
|
|
|
|
fn map_descriptor_metadata(row: &PgRow) -> Result<DescriptorMetadata, RegistryError> {
|
|
Ok(DescriptorMetadata {
|
|
id: crank_core::DescriptorId::new(row.try_get::<String, _>("id")?),
|
|
operation_id: row
|
|
.try_get::<Option<String>, _>("operation_id")?
|
|
.map(OperationId::new),
|
|
version: row
|
|
.try_get::<Option<i32>, _>("version")?
|
|
.map(|value| from_db_version(value, "version"))
|
|
.transpose()?,
|
|
descriptor_kind: deserialize_enum_text(
|
|
&row.try_get::<String, _>("descriptor_kind")?,
|
|
"descriptor_kind",
|
|
)?,
|
|
storage_ref: row.try_get("storage_ref")?,
|
|
source_name: row.try_get("source_name")?,
|
|
package_index: row
|
|
.try_get::<Option<Json<Value>>, _>("package_index_json")?
|
|
.map(|value| value.0),
|
|
created_at: row.try_get("created_at")?,
|
|
})
|
|
}
|
|
|
|
fn map_yaml_import_job(row: &PgRow) -> Result<YamlImportJob, RegistryError> {
|
|
Ok(YamlImportJob {
|
|
id: YamlImportJobId::new(row.try_get::<String, _>("id")?),
|
|
source_sample_id: row
|
|
.try_get::<Option<String>, _>("source_sample_id")?
|
|
.map(crank_core::SampleId::new),
|
|
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
|
format_version: row.try_get("format_version")?,
|
|
mode: deserialize_enum_text(&row.try_get::<String, _>("mode")?, "mode")?,
|
|
result_operation_id: row
|
|
.try_get::<Option<String>, _>("result_operation_id")?
|
|
.map(OperationId::new),
|
|
result_version: row
|
|
.try_get::<Option<i32>, _>("result_version")?
|
|
.map(|value| from_db_version(value, "result_version"))
|
|
.transpose()?,
|
|
error_text: row.try_get("error_text")?,
|
|
created_at: row.try_get("created_at")?,
|
|
finished_at: row.try_get("finished_at")?,
|
|
})
|
|
}
|
|
|
|
fn serialize_json_value<T: Serialize>(value: &T) -> Result<Value, RegistryError> {
|
|
Ok(serde_json::to_value(value)?)
|
|
}
|
|
|
|
fn serialize_option_json_value<T: Serialize>(
|
|
value: &Option<T>,
|
|
) -> Result<Option<Value>, RegistryError> {
|
|
value.as_ref().map(serialize_json_value).transpose()
|
|
}
|
|
|
|
fn deserialize_json_value<T: DeserializeOwned>(value: Value) -> Result<T, RegistryError> {
|
|
Ok(serde_json::from_value(value)?)
|
|
}
|
|
|
|
fn serialize_enum_text<T: Serialize>(
|
|
value: &T,
|
|
field: &'static str,
|
|
) -> Result<String, RegistryError> {
|
|
serde_json::to_value(value)?
|
|
.as_str()
|
|
.map(ToOwned::to_owned)
|
|
.ok_or(RegistryError::InvalidEnumRepresentation { field })
|
|
}
|
|
|
|
fn deserialize_enum_text<T: DeserializeOwned>(
|
|
value: &str,
|
|
field: &'static str,
|
|
) -> Result<T, RegistryError> {
|
|
serde_json::from_value(Value::String(value.to_owned()))
|
|
.map_err(|_| RegistryError::InvalidEnumRepresentation { field })
|
|
}
|
|
|
|
fn to_db_version(value: u32) -> i32 {
|
|
value as i32
|
|
}
|
|
|
|
fn from_db_version(value: i32, field: &'static str) -> Result<u32, RegistryError> {
|
|
u32::try_from(value).map_err(|_| RegistryError::InvalidNumericValue {
|
|
field,
|
|
value: i64::from(value),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
collections::BTreeMap,
|
|
env,
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use crank_core::{
|
|
ApiKeyHeaderAuthConfig, AuthConfig, AuthKind, AuthProfile, ConfigExport, ExecutionConfig,
|
|
ExportMode, GeneratedDraft, GeneratedDraftStatus, HttpMethod, OperationId, OperationStatus,
|
|
Protocol, RestTarget, RetryPolicy, Samples, SecretRef, Target, ToolDescription,
|
|
ToolExample, WorkspaceId,
|
|
};
|
|
use crank_mapping::{MappingRule, MappingSet};
|
|
use crank_schema::{Schema, SchemaKind};
|
|
use serde_json::json;
|
|
use sqlx::{Executor, PgPool, postgres::PgPoolOptions};
|
|
|
|
use crate::{
|
|
PostgresRegistry, RegistryError,
|
|
model::{
|
|
CreateVersionRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata,
|
|
OperationSampleMetadata, PublishRequest, RegistryOperation, SampleKind,
|
|
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
|
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
|
},
|
|
};
|
|
|
|
fn test_workspace_id() -> WorkspaceId {
|
|
WorkspaceId::new("ws_default")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stores_versions_and_published_operations() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let operation_v1 = test_operation("op_rest_01", 1, OperationStatus::Draft);
|
|
|
|
registry
|
|
.create_operation(&test_workspace_id(), &operation_v1, Some("alice"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let operation_v2 = test_operation("op_rest_01", 2, OperationStatus::Draft);
|
|
|
|
registry
|
|
.create_version(CreateVersionRequest {
|
|
workspace_id: &test_workspace_id(),
|
|
snapshot: &operation_v2,
|
|
change_note: Some("add output mapping"),
|
|
created_by: Some("alice"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
registry
|
|
.publish_operation(PublishRequest {
|
|
workspace_id: &test_workspace_id(),
|
|
operation_id: &operation_v2.id,
|
|
version: operation_v2.version,
|
|
published_at: "2026-03-25T12:10:00Z",
|
|
published_by: Some("alice"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let summary = registry
|
|
.get_operation_summary(&test_workspace_id(), &operation_v2.id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let versions = registry
|
|
.list_operation_versions(&test_workspace_id(), &operation_v2.id)
|
|
.await
|
|
.unwrap();
|
|
let published = registry
|
|
.get_published_operation(&operation_v2.id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(summary.current_draft_version, 2);
|
|
assert_eq!(summary.latest_published_version, Some(2));
|
|
assert_eq!(summary.status, OperationStatus::Published);
|
|
assert_eq!(versions.len(), 2);
|
|
assert_eq!(
|
|
versions[1].change_note.as_deref(),
|
|
Some("add output mapping")
|
|
);
|
|
assert_eq!(published.version, 2);
|
|
assert!(published.is_published());
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_out_of_order_versions() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let operation = test_operation("op_rest_02", 1, OperationStatus::Draft);
|
|
|
|
registry
|
|
.create_operation(&test_workspace_id(), &operation, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let invalid = test_operation("op_rest_02", 3, OperationStatus::Draft);
|
|
let error = registry
|
|
.create_version(CreateVersionRequest {
|
|
workspace_id: &test_workspace_id(),
|
|
snapshot: &invalid,
|
|
change_note: None,
|
|
created_by: None,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(
|
|
error,
|
|
RegistryError::InvalidVersionSequence {
|
|
expected: 2,
|
|
actual: 3,
|
|
..
|
|
}
|
|
));
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stores_auth_profiles_and_artifact_metadata() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let operation = test_operation("op_rest_03", 1, OperationStatus::Draft);
|
|
|
|
registry
|
|
.create_operation(&test_workspace_id(), &operation, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let auth_profile = AuthProfile {
|
|
id: "auth_crank".into(),
|
|
workspace_id: test_workspace_id(),
|
|
name: "Crank API key".to_owned(),
|
|
kind: AuthKind::ApiKeyHeader,
|
|
config: AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig {
|
|
header_name: "X-Api-Key".to_owned(),
|
|
secret_ref: SecretRef::new("vault://crank/api-key"),
|
|
}),
|
|
created_at: "2026-03-25T12:00:00Z".to_owned(),
|
|
updated_at: "2026-03-25T12:00:00Z".to_owned(),
|
|
};
|
|
|
|
registry
|
|
.save_auth_profile(SaveAuthProfileRequest {
|
|
workspace_id: &test_workspace_id(),
|
|
profile: &auth_profile,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let input_sample = OperationSampleMetadata {
|
|
id: "sample_input".into(),
|
|
operation_id: operation.id.clone(),
|
|
version: 1,
|
|
sample_kind: SampleKind::InputJson,
|
|
storage_ref: "file:///tmp/input.json".to_owned(),
|
|
content_type: "application/json".to_owned(),
|
|
file_name: Some("input.json".to_owned()),
|
|
created_at: "2026-03-25T12:01:00Z".to_owned(),
|
|
};
|
|
let descriptor = DescriptorMetadata {
|
|
id: "descriptor_01".into(),
|
|
operation_id: Some(operation.id.clone()),
|
|
version: Some(1),
|
|
descriptor_kind: DescriptorKind::DescriptorSet,
|
|
storage_ref: "file:///tmp/schema.desc".to_owned(),
|
|
source_name: Some("schema.desc".to_owned()),
|
|
package_index: Some(json!({ "crm.v1": ["LeadService"] })),
|
|
created_at: "2026-03-25T12:02:00Z".to_owned(),
|
|
};
|
|
|
|
registry
|
|
.save_sample_metadata(SaveSampleMetadataRequest {
|
|
sample: &input_sample,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
registry
|
|
.save_descriptor_metadata(SaveDescriptorMetadataRequest {
|
|
descriptor: &descriptor,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let auth_profiles = registry
|
|
.list_auth_profiles(&test_workspace_id())
|
|
.await
|
|
.unwrap();
|
|
let samples = registry
|
|
.list_sample_metadata(&operation.id, 1)
|
|
.await
|
|
.unwrap();
|
|
let descriptors = registry
|
|
.list_descriptor_metadata(&operation.id, 1)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(auth_profiles, vec![auth_profile]);
|
|
assert_eq!(samples, vec![input_sample]);
|
|
assert_eq!(descriptors, vec![descriptor]);
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stores_and_finishes_yaml_import_jobs() {
|
|
let database = TestDatabase::new().await;
|
|
let registry = database.registry().await;
|
|
let job_id = YamlImportJobId::new("job_yaml_01");
|
|
let operation = test_operation("op_rest_04", 1, OperationStatus::Draft);
|
|
|
|
registry
|
|
.create_operation(&test_workspace_id(), &operation, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
registry
|
|
.create_yaml_import_job(CreateYamlImportJobRequest {
|
|
id: &job_id,
|
|
source_sample_id: None,
|
|
format_version: "v1",
|
|
mode: ExportMode::Portable,
|
|
created_at: "2026-03-25T12:00:00Z",
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
registry
|
|
.finish_yaml_import_job(
|
|
&job_id,
|
|
&YamlImportJobCompletion {
|
|
status: YamlImportJobStatus::Completed,
|
|
result_operation_id: Some(operation.id.clone()),
|
|
result_version: Some(2),
|
|
error_text: None,
|
|
finished_at: "2026-03-25T12:05:00Z".to_owned(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let job = registry
|
|
.get_yaml_import_job(&job_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(job.status, YamlImportJobStatus::Completed);
|
|
assert_eq!(job.result_version, Some(2));
|
|
assert_eq!(job.mode, ExportMode::Portable);
|
|
|
|
database.cleanup().await;
|
|
}
|
|
|
|
struct TestDatabase {
|
|
admin_pool: PgPool,
|
|
database_url: String,
|
|
schema: String,
|
|
}
|
|
|
|
impl TestDatabase {
|
|
async fn new() -> Self {
|
|
let database_url = env::var("TEST_DATABASE_URL")
|
|
.expect("TEST_DATABASE_URL must point to a reachable PostgreSQL database");
|
|
let admin_pool = PgPoolOptions::new()
|
|
.max_connections(1)
|
|
.connect(&database_url)
|
|
.await
|
|
.unwrap();
|
|
let schema = format!(
|
|
"test_registry_{}_{}",
|
|
std::process::id(),
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
);
|
|
|
|
admin_pool
|
|
.execute(sqlx::query(&format!("create schema {schema}")))
|
|
.await
|
|
.unwrap();
|
|
|
|
Self {
|
|
admin_pool,
|
|
database_url,
|
|
schema,
|
|
}
|
|
}
|
|
|
|
async fn registry(&self) -> PostgresRegistry {
|
|
PostgresRegistry::connect_in_schema(&self.database_url, Some(&self.schema))
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn cleanup(&self) {
|
|
self.admin_pool
|
|
.execute(sqlx::query(&format!(
|
|
"drop schema if exists {} cascade",
|
|
self.schema
|
|
)))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
fn test_operation(id: &str, version: u32, status: OperationStatus) -> RegistryOperation {
|
|
RegistryOperation {
|
|
id: OperationId::new(id),
|
|
name: format!("{id}_tool"),
|
|
display_name: format!("Display {id}"),
|
|
protocol: Protocol::Rest,
|
|
status,
|
|
version,
|
|
target: Target::Rest(RestTarget {
|
|
base_url: "https://api.example.com".to_owned(),
|
|
method: HttpMethod::Post,
|
|
path_template: "/v1/leads".to_owned(),
|
|
static_headers: BTreeMap::from([("X-Static".to_owned(), "true".to_owned())]),
|
|
}),
|
|
input_schema: Schema {
|
|
kind: SchemaKind::Object,
|
|
description: Some("input".to_owned()),
|
|
required: true,
|
|
nullable: false,
|
|
default_value: None,
|
|
fields: BTreeMap::from([(
|
|
"email".to_owned(),
|
|
Schema {
|
|
kind: SchemaKind::String,
|
|
description: None,
|
|
required: true,
|
|
nullable: false,
|
|
default_value: None,
|
|
fields: BTreeMap::new(),
|
|
items: None,
|
|
enum_values: Vec::new(),
|
|
variants: Vec::new(),
|
|
},
|
|
)]),
|
|
items: None,
|
|
enum_values: Vec::new(),
|
|
variants: Vec::new(),
|
|
},
|
|
output_schema: Schema {
|
|
kind: SchemaKind::Object,
|
|
description: Some("output".to_owned()),
|
|
required: true,
|
|
nullable: false,
|
|
default_value: None,
|
|
fields: BTreeMap::from([(
|
|
"id".to_owned(),
|
|
Schema {
|
|
kind: SchemaKind::String,
|
|
description: None,
|
|
required: true,
|
|
nullable: false,
|
|
default_value: None,
|
|
fields: BTreeMap::new(),
|
|
items: None,
|
|
enum_values: Vec::new(),
|
|
variants: Vec::new(),
|
|
},
|
|
)]),
|
|
items: None,
|
|
enum_values: Vec::new(),
|
|
variants: Vec::new(),
|
|
},
|
|
input_mapping: MappingSet {
|
|
rules: vec![MappingRule {
|
|
source: "$.mcp.email".to_owned(),
|
|
target: "$.request.body.email".to_owned(),
|
|
required: true,
|
|
default_value: None,
|
|
transform: None,
|
|
condition: None,
|
|
notes: None,
|
|
}],
|
|
},
|
|
output_mapping: MappingSet {
|
|
rules: vec![MappingRule {
|
|
source: "$.response.body.id".to_owned(),
|
|
target: "$.output.id".to_owned(),
|
|
required: true,
|
|
default_value: None,
|
|
transform: None,
|
|
condition: None,
|
|
notes: None,
|
|
}],
|
|
},
|
|
execution_config: ExecutionConfig {
|
|
timeout_ms: 10_000,
|
|
retry_policy: Some(RetryPolicy { max_attempts: 3 }),
|
|
auth_profile_ref: Some("auth_crank".into()),
|
|
headers: BTreeMap::from([("X-Request-Id".to_owned(), "static".to_owned())]),
|
|
protocol_options: None,
|
|
},
|
|
tool_description: ToolDescription {
|
|
title: "Create lead".to_owned(),
|
|
description: "Creates CRM lead".to_owned(),
|
|
tags: vec!["crm".to_owned()],
|
|
examples: vec![ToolExample {
|
|
input: json!({ "email": "a@example.com" }),
|
|
}],
|
|
},
|
|
samples: Some(Samples {
|
|
input_json_sample_ref: Some("sample_input".into()),
|
|
output_json_sample_ref: Some("sample_output".into()),
|
|
proto_file_ref: None,
|
|
descriptor_ref: None,
|
|
}),
|
|
generated_draft: Some(GeneratedDraft {
|
|
status: GeneratedDraftStatus::Available,
|
|
source_types: vec!["input_json".to_owned(), "output_json".to_owned()],
|
|
generated_at: Some("2026-03-25T11:59:00Z".to_owned()),
|
|
input_schema_generated: true,
|
|
output_schema_generated: true,
|
|
input_mapping_generated: true,
|
|
output_mapping_generated: true,
|
|
warnings: Vec::new(),
|
|
}),
|
|
config_export: Some(ConfigExport {
|
|
format_version: "v1".to_owned(),
|
|
export_mode: ExportMode::Portable,
|
|
}),
|
|
created_at: "2026-03-25T11:58:00Z".to_owned(),
|
|
updated_at: format!("2026-03-25T12:{version:02}:00Z"),
|
|
published_at: None,
|
|
}
|
|
}
|
|
}
|